Skip to content

Single sign-on security

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:

  1. Every sign-in is bound to the browser that started it, with single-use state kept on the server.
  2. 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.
  3. Accounts are linked by the IdP’s stable identifier, and by email only at a domain your workspace has claimed.
  4. 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.
  5. Failures are recorded, not explained: the person sees a generic message with a reference; the reason goes to the audit log.
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

When someone selects Continue with SSO, SpeculaPro:

  1. creates a random handle and a random state;
  2. 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;
  3. sets the handle in a cookie and sends the browser to the IdP with the state (OIDC state, SAML RelayState).
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).

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.

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.

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.

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.

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
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.

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.

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 as auth.login_failed with reason sso_required and 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_failed with reason sso_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.
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.

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.

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_endpoint and jwks_uri the 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.

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.

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.

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.

  • 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.