Single sign-on security
In short
Section titled “In short”This page is for security reviewers. It describes, check by check, what SpeculaPro does when someone signs in through your identity provider (IdP), and what it deliberately doesn’t support. How to set SSO up is in Single sign-on.
The design rests on five rules:
- Every sign-in is bound to the browser that started it, with single-use state kept on the server.
- Only signed, current, correctly addressed answers are accepted: ID tokens with PKCE, state and nonce for OIDC; individually signed assertions for SAML, each accepted once.
- Accounts are linked by the IdP’s stable identifier, and by email only at a domain your workspace has claimed.
- There is always a way back in: break-glass administrators keep a password and authenticator, and SpeculaPro refuses changes that would remove the last usable one.
- Failures are recorded, not explained: the person sees a generic message with a reference; the reason goes to the audit log.
Protocols and libraries
Section titled “Protocols and libraries”| Protocols | OpenID Connect (authorization code flow with PKCE) and SAML 2.0 (SP-initiated by default). One connection per workspace |
| OIDC libraries | github.com/coreos/go-oidc/v3 v3.21.0, golang.org/x/oauth2 v0.37.0 |
| SAML libraries | github.com/crewjam/saml v0.5.1, github.com/russellhaering/goxmldsig v1.6.1 (XML signatures), github.com/mattermost/xml-roundtrip-validator v0.1.0 |
| Vulnerability scanning | govulncheck runs on every change. At release of this feature it reported no reachable vulnerabilities |
Binding a sign-in to one browser
Section titled “Binding a sign-in to one browser”When someone selects Continue with SSO, SpeculaPro:
- creates a random handle and a random state;
- stores the flow on the server (Redis) under a fingerprint of the state: the workspace, the protocol, the return path, a fingerprint of the handle, and the OIDC nonce and PKCE verifier or the SAML request ID;
- sets the handle in a cookie and sends the browser to the IdP with the state (OIDC
state, SAMLRelayState).
| Cookie property | Value |
|---|---|
| Name | __Host-sp_sso (sp_sso only when secure cookies are switched off, which you shouldn’t) |
HttpOnly, Secure, Path=/ |
Yes |
SameSite |
Lax, so the browser sends it on the IdP’s top-level redirect back |
| Lifetime | 10 minutes |
When the answer comes back, the state is taken from Redis (read and deleted in one step, so it
works once) and the cookie’s handle must match the stored fingerprint, compared in constant time. A
missing, expired, reused or foreign state is refused (state_unknown, state_browser_mismatch). This
stops an attacker from completing a sign-in in a victim’s browser with the attacker’s own response
(login CSRF).
SAML and the Lax cookie
Section titled “SAML and the Lax cookie”The IdP returns a SAML response as a cross-site form POST, and browsers don’t send SameSite=Lax
cookies on cross-site POSTs. So the ACS endpoint doesn’t process the response. It only stashes
it in Redis for 2 minutes under a new random key, and redirects the browser to a GET on
SpeculaPro’s own …/finish address. That top-level GET carries the cookie, and only then is the
response validated against the browser’s flow. The stash is single use too.
The browser-facing SSO endpoints sit outside the JSON API’s same-origin check on purpose, because the IdP’s redirect and POST are cross-site by nature. They are protected by the binding above, single use and rate limits instead.
OpenID Connect checks
Section titled “OpenID Connect checks”Starting. SpeculaPro uses the authorization code flow with PKCE (S256), a random state and
a random nonce. The person’s typed email, if any, is passed as login_hint.
Discovery. The issuer’s /.well-known/openid-configuration is read when settings are saved with
SSO on, then cached and re-read at most hourly. The discovery document’s issuer must equal the
configured issuer. It must name a JWKS, an authorization and a token endpoint.
Token exchange. The code is exchanged with the PKCE verifier and the client secret. The client secret is stored encrypted with the workspace’s key, is never returned by the API, and appears in the audit log only as (changed).
ID token checks:
| Check | Rule |
|---|---|
| Signature | Verified against the issuer’s JWKS. Keys are cached and fetched again when a token names an unknown key ID, so IdP key rotation works without a restart |
| Algorithm | Asymmetric only: RS256/384/512, ES256/384/512, PS256/384/512, EdDSA. none and HMAC (HS*, which would be signed with the client secret) are never accepted |
iss |
Must equal the configured issuer |
aud |
Must contain the client ID |
azp |
Required, and must equal the client ID, when the token has more than one audience |
exp |
Must not have passed, with 2 minutes of tolerance for clock drift |
nbf |
If present, must not be in the future. The library’s tolerance, after the 2-minute adjustment above, accepts an nbf up to about 3 minutes ahead |
iat |
Required. Not more than 2 minutes in the future, and not before the sign-in started (10 minutes plus the 2-minute tolerance) |
nonce |
Must equal the stored nonce (constant-time comparison) |
email_verified |
Must be true when Require a verified email is on, which is the default |
If the ID token has no email, SpeculaPro asks the IdP’s userinfo endpoint, and uses the answer only if
its sub matches the token’s.
Sign-out. For a session started with OIDC, signing out of SpeculaPro also returns the IdP’s
end_session_endpoint (RP-initiated logout) as sso_logout_url, with SpeculaPro’s /login as the
post-logout redirect. The web app sends the browser there. The endpoint is used only over HTTPS.
SAML checks
Section titled “SAML checks”Starting. SP-initiated sign-in sends a signed AuthnRequest (RSA-SHA256) with the
HTTP-Redirect binding, asking for the answer by HTTP-POST. Each workspace has its own 2048-bit RSA
signing key, created when an administrator saves SAML settings and stored encrypted with the
workspace’s key; its certificate is published in the SP metadata, which also states
AuthnRequestsSigned and WantAssertionsSigned. The public SP metadata address never creates a
key: it answers 404 unless the workspace uses SAML and its key already exists, so an anonymous
request can’t change anything.
IdP metadata. Fetched from a URL (refreshed after 24 hours, keeping the last good copy if the IdP is unreachable) or pasted. It must be at most 1 MB, contain an IdP descriptor, and round-trip through the XML validator.
Response and assertion checks:
| Check | Rule |
|---|---|
| XML structure | The response must round-trip through an XML parser without changing meaning, which defends against parser-confusion attacks on XML signatures |
| Signature | Each assertion must carry its own valid signature from a certificate in the IdP metadata. A signature on the response is removed before validation, so a signed response wrapping an unsigned assertion is refused (saml_signature_invalid). Encrypted assertions are decrypted with the workspace’s SP key and must then be signed the same way |
| Issuer | The assertion’s issuer must be the IdP’s entity ID from the metadata |
| Status | The response status must be success |
InResponseTo |
Must match the request ID stored for this browser’s flow, on the response and in each subject confirmation (not checked for IdP-initiated sign-ins, which have no request) |
Destination |
If present on the response, must be the ACS URL |
| Recipient | Each bearer subject confirmation’s Recipient must be the ACS URL |
| Subject confirmation | Must be bearer, with confirmation data |
| Audience | An AudienceRestriction is required and must contain the SP entity ID. (The library would accept an assertion with none; SpeculaPro doesn’t) |
| Validity window | NotBefore and NotOnOrAfter on the conditions, and the subject confirmation’s NotOnOrAfter, with 2 minutes of clock tolerance |
| Issue time | The response and assertion IssueInstant must be no more than 3 minutes old |
| NameID | Required. Transient NameIDs are refused, because the NameID is how the person is recognised next time |
| Replay | Each assertion ID is remembered in Redis per workspace until its last NotOnOrAfter plus 2 minutes (at least 1 minute, at most 24 hours), and accepted once (assertion_replayed). If Redis can’t be reached, the sign-in is refused |
A malformed assertion that would crash the library is caught and refused as
saml_assertion_malformed.
IdP-initiated sign-in (from the IdP’s portal) is off by default and refused
(idp_initiated_not_allowed). An administrator can turn it on, with a warning in the screen: such a
response isn’t tied to a request from the person’s browser, so a stolen or forwarded response could
sign someone in. Every other check, including one-time use, still applies.
SAML single logout
Section titled “SAML single logout”Single logout (SAML 2.0 profiles, section 4.4) works in both directions, at one address per
workspace, published in the SP metadata: /api/v1/auth/sso/saml/<workspace>/slo.
Signing out of SpeculaPro (SP-initiated). The session ends first, whatever happens next. When
the session was started with SAML, the IdP’s metadata lists a SingleLogoutService at an HTTPS
address, and the session recorded the NameID from its sign-in, sign-out also returns
sso_logout_url, where the browser goes next with a LogoutRequest signed with the workspace’s
key (RSA-SHA256), naming the NameID and SessionIndex exactly as the IdP asserted them and valid for
5 minutes. The IdP’s answer is not
acted on; the browser lands on the sign-in page either way. Which binding is used follows the IdP’s
metadata, HTTP-Redirect first:
| The IdP’s metadata offers | sso_logout_url is |
How the request is signed |
|---|---|---|
| HTTP-Redirect | The IdP’s own logout address, with the request in the query string | The redirect binding’s detached signature |
| HTTP-POST only | A page on SpeculaPro, /api/v1/auth/sso/saml/<workspace>/slo/request?k=<key>, that posts the request to the IdP |
An enveloped signature inside the request |
| Neither | Absent: only the SpeculaPro session ends |
The HTTP-POST page identifies the browser by nothing but the key in its address, because the session it belonged to is already over. The key is 256 bits of randomness, is stored only as a hash, works once and for 2 minutes, and is bound to the workspace in the address. An unknown, used or expired key, or one for another workspace, sends the browser to the sign-in page, where the person was going anyway. The page’s content security policy allows one script (the auto-submit, by its hash) and form submission to the IdP’s address only; everything written into the page is escaped. The endpoint shares the single logout rate limit.
Signing out at the IdP (IdP-initiated). A LogoutRequest arriving by HTTP-Redirect or
HTTP-POST is checked in this order, and nothing in an unsigned message is believed:
| Check | Refused as |
|---|---|
| Signed by a certificate in the workspace’s IdP metadata: the detached signature of the redirect binding, or an enveloped signature for POST. RSA with SHA-256, SHA-384 or SHA-512; SHA-1 is refused | slo_unsigned, slo_signature_invalid, slo_signature_algorithm |
| Well-formed XML that reads the same to every parser, at most 256 KiB, with an ID and a NameID | slo_request_malformed, slo_nameid_missing |
Destination is exactly this workspace’s single logout URL |
slo_destination_mismatch |
Issuer is the workspace’s IdP |
slo_issuer_mismatch |
Issued within the last 3 minutes (plus the 2-minute clock tolerance) and not past its NotOnOrAfter |
slo_request_expired |
| Its ID hasn’t been seen in the last 10 minutes | slo_request_replayed |
Then every unexpired SAML session in that workspace with that NameID ends, or only those with
the SessionIndex values the request names, and a signed LogoutResponse goes back to the IdP
(by HTTP-Redirect when the IdP offers it, otherwise by an auto-submitting HTTP-POST page whose
content security policy allows only that one script and that one destination).
- No cookie is needed or trusted. IdPs often call from another site or a hidden frame, where the session cookie isn’t sent. Sessions are found by what the IdP called them, so the endpoint can’t be used to sign out anyone the IdP didn’t name with a valid signature.
- Replay protection fails closed. The seen-ID record is in Redis; if Redis is unavailable the request is refused, not accepted.
- Sessions that predate single logout have no NameID recorded and aren’t reached by it.
- OIDC back-channel logout isn’t supported. With OIDC, a sign-out at the IdP doesn’t end SpeculaPro sessions. Use SCIM deactivation where that matters.
Ended sessions are audited as auth.sso_logout (the IdP, the NameID, the sessions named, how many
ended and whose); refusals as auth.sso_logout_failed with the reason.
SCIM provisioning
Section titled “SCIM provisioning”An IdP can create, update and deactivate accounts and push groups over SCIM 2.0 at /scim/v2. See
SCIM provisioning and the SCIM API.
| Property | How |
|---|---|
| Tokens | A bearer token of the form scim_<12 characters>_<secret>, with a 256-bit random secret. Only its SHA-256 is stored; it is shown once and compared in constant time. An unknown prefix costs the same comparison as a wrong secret |
| One workspace | The token names the workspace. After authentication everything runs inside that workspace’s row-level security, so another workspace’s people and groups don’t exist to the request. Before it, one narrow database function (sp_scim_find_token) looks the token up by prefix, as for API keys |
| Separate from everything else | A SCIM token is not a session and not an API key: it works only under /scim/v2, where no cookie is read |
| One refusal | A malformed, unknown, revoked or expired token, a suspended workspace and a workspace served by another region all get the same 401 |
| Creating a token | Needs sso.manage and counts as a single sign-on change, so it goes through approval where the workspace requires it; the secret is then released once to the person who asked. Revoking never waits. At most 10 working tokens per workspace |
| Deactivation | Disables the account and revokes its sessions in one transaction; a disabled account is refused on its next request. The API keys the person created are marked rotation due, or revoked when the workspace chose that, in the same transaction. DELETE deactivates: nobody is deleted |
| What it can’t do | Deactivate, re-address or change the roles of a break-glass administrator; leave the workspace without an active administrator; reactivate an account an administrator disabled; set a password |
| Roles | From the same Group → role mapping as sign-in, only while role mapping is on, applied directly. The controls are on the mapping and on the token |
| Privileged roles | Off by default: SCIM neither gives nor takes away a role that holds an administration, approval or security permission, whatever a group maps to. See below |
| Rate limits | About 3,000 requests a minute per token; 30 wrong tokens a minute per network address |
| Audit | Every change is recorded as the system with via: scim and the token’s name and ID |
A token alone can’t make an administrator
Section titled “A token alone can’t make an administrator”A SCIM token is a single secret held by another system. Without a guard, whoever obtained it could push a group that maps to Administrator and take the workspace over. So privileged roles are outside SCIM’s reach unless the workspace says otherwise.
| What counts as privileged | A role holding any permission in the Administration group, any approval (checker) permission, the approving side of any dual-controlled change, or a security permission (users.manage, sso.manage, api_keys.manage, webhooks.manage, settings.manage). It is read from what the role holds, so custom roles are covered by their permissions, not their names. Of the built-in roles: Administrator, MLRO, L2 investigator, Rule approver and Data protection officer. A permission this version doesn’t recognise counts as privileged |
| While the setting is off (default) | SCIM doesn’t give a privileged role and doesn’t take one away: a person keeps exactly the privileged roles they hold. The rest of what their groups map to applies as usual. A privileged single sign-on default role isn’t given to a person SCIM creates |
| The request still succeeds | The IdP gets a normal answer, since a refused IdP retries for ever. What was left out is in the audit log (privileged_roles_withheld, privileged_roles_kept) and in the server log as a warning |
| Sign-in is covered too | At an SSO sign-in, groups pushed over SCIM count with the groups the IdP asserts. While the setting is off, a privileged role that only a pushed group maps to isn’t given at sign-in either (sso.roles_sync_skipped). A privileged role the IdP’s own signed assertion maps to is given, as before |
| Switching it on | PUT /api/v1/settings/sso/scim/privileged-roles with sso.manage. It is a single sign-on change and goes through approval where the workspace requires it; the approval is tied to the privileged mappings as they were, so mapping another group to a privileged role meanwhile makes the request stale. Switching it off applies at once, without approval |
| It changes nobody by itself | Switching the setting doesn’t rework anyone’s roles. It applies to the next change the IdP pushes |
Account linking and provisioning
Section titled “Account linking and provisioning”| Situation | What happens |
|---|---|
The IdP identifier (OIDC issuer + sub; SAML IdP entity ID + NameID) is already linked |
That account signs in. An email change at the IdP doesn’t create a second account; the account keeps its email |
| Not linked, and the email’s domain is claimed by the workspace (and verified, where verification is required) | Matched to the account with that email in this workspace and linked. For OIDC with verification on, email_verified must be true |
| That account is already linked to a different identifier at the same IdP | Refused (email_linked_to_other_subject): an address reassigned to someone else doesn’t inherit the account |
| No account with that email | Created only if just-in-time provisioning is on (off by default) and a role can be assigned. With SCIM, the IdP has usually created the account already, and this sign-in is matched to it by email |
| The account is disabled | Refused (user_disabled), whatever the IdP says |
| The account is invited | Activated, and its temporary password removed |
Each workspace’s links live in that workspace’s own rows, under the same row-level security as everything else. An IdP connected to one workspace can never reach accounts in another.
Domain verification. With SP_SSO_VERIFY_DOMAINS=true (the default, and always on for SaaS), a
claimed domain counts only after SpeculaPro finds a DNS TXT record at
_speculapro-challenge.<domain> with the value speculapro-domain-verification=<random code>. Self-hosted
installations, where administrators are trusted, may turn this off.
Roles from groups. When mapping is on, roles are set from the IdP’s groups at every sign-in,
except for break-glass administrators, whose roles never change this way. If no group matches, the
sign-in is refused by default (no_role_mapping), or the default role is used. A change that would
leave the workspace with no active administrator is skipped and audited (sso.roles_sync_skipped).
Role changes are audited as user.roles with via: sso. Groups the IdP pushed over SCIM count
together with the groups the sign-in carries, so a sign-in that carries none doesn’t take away the
roles SCIM groups gave.
Multi-factor authentication
Section titled “Multi-factor authentication”SpeculaPro trusts the IdP’s statement that it checked a second factor when the OIDC amr or acr
values, or the SAML AuthnContextClassRef (or Microsoft’s authnmethodsreferences attribute), include
one of the administrator’s values that mean multi-factor. The session is then recorded as
multi-factor.
| The IdP reports multi-factor? | Require multi-factor at the IdP | Result |
|---|---|---|
| Yes | Either | Signed in |
| No | On | Refused (idp_mfa_missing) |
| No | Off, person has a SpeculaPro authenticator | They must enter its code within 5 minutes; at most 5 wrong codes per IdP sign-in |
| No | Off, no authenticator | Signed in without multi-factor; where the workspace requires two-factor, the session can only set up an authenticator until they do |
Each SpeculaPro code works once, as with password sign-in.
The code step is covered by the account lockout. A wrong code after SSO counts towards the
lockout exactly like a wrong password
(audited as auth.login_failed, reason bad_totp_after_sso). While the account is locked, the code
step refuses even the right code (reason locked_totp_after_sso). So starting a new IdP sign-in
never buys more guesses: after 10 failures in a row, password or code, the account locks for 15
minutes.
A trusted IdP multi-factor sign-in isn’t stopped by a local lockout. When the IdP reports multi-factor and no SpeculaPro code is asked for, the person is signed in even if their SpeculaPro password is currently locked. This is deliberate: the lockout protects SpeculaPro’s own password and code from guessing, and in this case the IdP has authenticated the person with both factors. To stop someone signing in, disable their account in SpeculaPro, or at the IdP.
Enforcement and break-glass access
Section titled “Enforcement and break-glass access”When a workspace requires SSO:
- Password sign-in is refused for everyone except break-glass administrators, with the answer
sso_required. The same answer is given for an unknown account, an ordinary account, and a break-glass administrator’s wrong password, with the same password-hashing time, so the answer reveals neither which accounts exist nor which are break-glass. Attempts refused because SSO is required are audited asauth.login_failedwith reasonsso_requiredand don’t count towards the account lockout; a break-glass administrator’s wrong password counts, as usual. - Passkey sign-in is refused in the same way, for everyone except break-glass administrators: a
passkey is not a way around required SSO. The attempt is audited as
auth.login_failedwith reasonsso_required. - Existing password sessions end for everyone except break-glass administrators when enforcement is turned on.
- Administrators can’t issue temporary passwords except to break-glass administrators, and new invitations have no password: the first SSO sign-in activates them.
- At least one usable break-glass administrator (active, able to manage people, with their own password and an authenticator) is required to turn enforcement on, and any change that would leave none is refused: removing them from the list, disabling them, changing their roles, or resetting their two-factor.
Rate limits
Section titled “Rate limits”| What | Limit |
|---|---|
| Checking whether a workspace offers SSO | 60 a minute per network address |
| Starting a sign-in | 30 a minute per network address, and 300 a minute per workspace |
| The IdP’s answer (OIDC callback and SAML ACS together) | 30 a minute per network address |
| SpeculaPro code after SSO | 5 attempts per IdP sign-in |
| SAML single logout messages | 60 a minute per network address |
| SCIM requests | About 3,000 a minute per token; 30 wrong tokens a minute per network address |
Like password sign-in, the rate limits use Redis and let requests through if Redis is unavailable. SSO itself needs Redis for its single-use state, so if Redis is down, SSO sign-in fails and break-glass administrators sign in with their password.
The public address
Section titled “The public address”Identity providers send people back to SpeculaPro’s public address (SP_SSO_PUBLIC_URL, or
SP_NOTIFY_PUBLIC_URL when it isn’t set). Outside development, single sign-on can’t be turned on
while that address is local (localhost, a .localhost name, or a loopback or unspecified
address): the settings are refused with this deployment’s public address is “…”: identity
providers would send people back to their own computer. This stops a misconfigured installation
from registering callback addresses that would hand sign-in answers to the wrong machine.
Outbound requests to the IdP
Section titled “Outbound requests to the IdP”Discovery documents, JWKS, the token and userinfo endpoints and SAML metadata are fetched through the same guarded HTTP client as webhooks:
- every connection is checked after DNS resolution, so private, loopback, link-local, cloud metadata and other internal addresses (including IPv6 forms that carry an IPv4 address) are refused even if a hostname is later re-pointed;
- redirects aren’t followed, no outbound proxy is used, and each request times out after 10 seconds;
- the issuer URL and SAML metadata URL an administrator enters must use HTTPS, and so must the
authorization_endpoint,token_endpointandjwks_urithe issuer’s discovery document advertises: a document pointing at a plain-HTTP endpoint is refused with the discovery document’s jwks_uri must be an https URL. Plain HTTP is accepted only in development (SP_SSO_ALLOW_HTTP).
A self-hosted installation whose IdP is on its own network can allow private addresses with
SP_SSO_ALLOW_PRIVATE=true. Even with it, link-local addresses (169.254.0.0/16, fe80::/10), cloud metadata addresses and names (169.254.169.254, fd00:ec2::254, 100.100.100.200, metadata.google.internal and similar), 0.0.0.0/8 and carrier-grade NAT (100.64.0.0/10) stay refused, also when written in IPv4-mapped or NAT64 form: allow-private opens only RFC 1918 networks, IPv6 unique-local and loopback. See Which addresses are refused.
Return paths
Section titled “Return paths”After sign-in, SpeculaPro returns the person to the page they asked for only if it is a relative
path inside the app (the home page or a path under /alerts, /cases, /rules, /entities,
/transactions, /decisions, /settings or /reports). Absolute URLs, //host, backslashes,
encoded slashes, control characters and .. segments send them to the home page instead, so the
sign-in flow can’t be used as an open redirect.
Failures and auditing
Section titled “Failures and auditing”The person only ever sees Single sign-on didn’t complete. Try again, or ask your administrator (reference …), or a rate-limit message. The reference is the request ID in the server logs.
In a workspace SpeculaPro can identify, each failure is audited as auth.sso_failed (or
sso.test_failed for a connection test) with a reason code and technical detail. Failures before the
workspace is known, such as an expired or unknown state, a lost SAML stash, or an unknown workspace in
the address, are audited too, as installation-level auth.sso_failed entries: they belong to no
workspace (tenant_id is empty), record workspace_known: false, and are visible to whoever runs
SpeculaPro in the audit table, not in any workspace’s audit log. The reason codes are listed in
Why a sign-in failed; every other change and
sign-in is in What is recorded.
Connection tests
Section titled “Connection tests”An administrator’s Test connection runs a real round trip against the saved settings, enabled or not, but creates no session: the account matching and role mapping run in a database transaction that is rolled back. The result, including every claim received, is kept in Redis for 10 minutes and is readable only by the administrator who ran it.
Not supported
Section titled “Not supported”- OIDC back-channel and front-channel logout. A sign-out at an OIDC IdP doesn’t end SpeculaPro sessions.
- SCIM bulk operations, password changes, sorting and ETags.
- SAML HTTP-POST binding for sign-in requests. Sign-in requests use HTTP-Redirect only (logout requests can go by either binding). IdPs that accept only POST sign-in requests won’t work.
- More than one IdP per workspace.