This guide is for applications that sign users in with an enterprise
identity provider through saml/sp (package samlsp): what the module
supports, the exact trust policy it applies, how to key accounts, how to
deploy and roll keys, how to read its errors, and how it was tested.
The package documentation describes the API.
security-considerations.md
is the threat-model view, and examples/saml-sp is a runnable application.
- A Service Provider (SP) for SP-initiated Web Browser SSO (Profiles §4.1). The application sends the browser to the IdP with an AuthnRequest and receives the signed Response at its assertion consumer service (ACS).
- Not an identity provider. It never issues assertions or tokens. An
application that turns a SAML login into credentials of its own (an
identity broker, an OpenID provider fronting enterprise IdPs) is a
relying party here. Such a broker MUST honour the assertion's
ProxyRestriction(Core §2.5.1.6,Identity.ProxyRestriction):- a
Countof 0 forbids issuing anything to another party; Audiences, when present, lists the only parties it may issue to.
- a
- Not Single Logout. Logging out of the application ends the application's session only (see §5).
| Feature | Support |
|---|---|
| AuthnRequest | HTTP-Redirect binding; signed with rsa-sha256 when Config.SigningKey is set. BeginLogin fails with ErrInvalidConfig when the IdP's metadata sets WantAuthnRequestsSigned and the SP has no key |
| Response | HTTP-POST binding to the connection's ACS URL; with Connection.ResponseOrigins, only from the IdP's origins (see §6) |
| SP metadata | One document per connection (Metadata, ServeMetadata): its own entity ID and ACS, use="signing" keys only, no encryption key, AdditionalSigningCertificates for a rollover |
| IdP metadata | Imported (ParseIdentityProviderMetadata), fetched once (FetchIdentityProviderMetadata) or kept current (MetadataSource); the entity ID is pinned |
| NameID policy | Connection.NameIDFormat is requested and enforced (ErrNameIDFormatMismatch) unless it is unspecified |
ForceAuthn |
Requested; a response authenticated before the request is refused (ErrStaleAuthentication) |
IsPassive |
Requested; the IdP's NoPassive status comes back as a *StatusError |
| Requested authentication context | exact comparison, at most 8 classes; enforced (ErrAuthnContextMismatch) |
| Conditions | NotBefore/NotOnOrAfter, every AudienceRestriction (each must name the SP), OneTimeUse (single use is enforced anyway), ProxyRestriction (surfaced) |
| Session data | SessionIndex, SessionNotOnOrAfter, AuthnInstant, AuthnContextClassRef, attributes in document order |
Not supported, each refused with a typed error rather than half-handled:
| Feature | Outcome |
|---|---|
| IdP-initiated SSO (unsolicited responses) | ErrUnsolicitedResponse |
| Single Logout, Artifact binding, ECP, any binding but those above | Not implemented; anything but a form POST at the ACS is ErrMalformedRequest |
| Encrypted assertions, IDs or attributes | ErrEncryptedAssertionUnsupported; the SP metadata publishes no encryption key |
| Several assertions, or an assertion out of place | ErrMalformedResponse |
BaseID subjects, several SubjectConfirmations or AuthnStatements, unknown conditions |
ErrInvalidAssertion |
| RSASSA-PSS, SHA-1 without the opt-in, inclusive C14N | ErrUnsupportedAlgorithm |
| Signed metadata | The embedded signature is ignored; trust comes from the import (see §3.4) |
Trust comes from the connection only: the pinned IdP entity ID and its 1 to 8 signing certificates. Nothing in a response extends it.
- The Response, its Assertion or both must be signed (Profiles §4.1.3.5
and §4.1.4.5, errata E26 and E93). A signed Response covers its single
assertion and must carry a
Destination. - A signature's
KeyInfoonly selects a trusted certificate: it must equal one exactly. AKeyInfowithout certificate (aKeyNameorKeyValueonly) selects nothing and counts as absent, except on an Assertion whose Response is signed (ErrInvalidSignature: that signature covers it). WithoutKeyInfo, the three most recent valid certificates are tried, the one that verified the connection's last such signature first. - Every value is read from the verified, canonical bytes (gosaml2 and
goxmldsig), never from the raw document. A comment injected into a
NameIDcannot truncate it; the corpus and the Keycloak wrapping tests check this on real Apache Santuario output.
| Value | Read from | Must equal |
|---|---|---|
Response Issuer and Assertion Issuer (one each, required) |
the verified response | the connection's IdP entity ID; the Format, if any, is entity |
Destination |
Response (compared when present, required when the Response is signed) | the connection's ACS URL |
| Received location | the HTTP request: escaped path, r.Host, query string |
the ACS URL's path and host, without a query string (Core §3.2.2, Bindings §3.5.5.2) |
Origin, or Referer without Origin |
the HTTP request, when Connection.ResponseOrigins is set |
one of those origins |
InResponseTo |
the bearer SubjectConfirmationData (required; absent means unsolicited) and, on a Success, the Response (required, Core §3.2.2) |
the request ID of the browser's transaction |
Recipient |
bearer SubjectConfirmationData |
the connection's ACS URL |
Audience |
every AudienceRestriction (at least one) |
each names the connection's SP entity ID |
NameID |
Subject (exactly one, never BaseID) |
its format, when the connection requests one |
| Accepted | |
|---|---|
| Signature methods | RSA and ECDSA with SHA-256, SHA-384 or SHA-512; rsa-sha1 and ecdsa-sha1 only with Connection.InsecureAllowSHA1 |
| Digests | SHA-256, SHA-384, SHA-512; SHA-1 only with the opt-in |
| Transforms | exactly enveloped-signature, then exclusive C14N (with or without comments); no ds:Object (errata E91) |
| IdP keys | RSA 2048 to 8192 bits, ECDSA P-256, P-384, P-521; ECDSA values in ASN.1 DER (raw r‖s fails closed) |
| SP request signing | RSA, rsa-sha256 |
ClockSkew is 3 minutes by default and 5 at most (errata E92). It cannot
be 0: one nanosecond is the strictest setting. With now the SP's clock
and skew the clock skew:
| Check | Accepted when |
|---|---|
Conditions NotBefore |
NotBefore ≤ now + skew |
Conditions NotOnOrAfter |
now < NotOnOrAfter + skew |
Bearer SubjectConfirmationData NotOnOrAfter (required) |
now < NotOnOrAfter + skew |
Response and Assertion IssueInstant |
request start − skew ≤ IssueInstant ≤ now + skew |
AuthnInstant |
AuthnInstant ≤ now + skew; with ForceAuthn, also AuthnInstant ≥ request start − skew |
SessionNotOnOrAfter |
now < SessionNotOnOrAfter + skew |
| IdP certificate validity | checked at now − skew: from NotBefore + skew to NotAfter + skew |
| Transaction | now before the transaction's expiry (TransactionTTL, 10 minutes by default, 1 hour at most) |
A certificate whose validity starts when the IdP switches to it is refused
for the first ClockSkew: import new certificates ahead of their use. The
Keycloak run hit this with a freshly generated realm key.
- Importing is the act of trust.
ParseIdentityProviderMetadatareads administrator-provided metadata. An embedded signature is not verified, and an aggregate is accepted only with a pinned entity ID. - Fetching (
FetchIdentityProviderMetadata,MetadataSource) trusts TLS to the configured URL and the pinned entity ID. The URL is configuration, never a value read from a request or a SAML message. Requests go through the coreoutboundclient: public destinations only, no redirect, bounded time and size. - No response content triggers a fetch. A response signed by an unknown key is refused; it does not refresh the metadata, so an attacker cannot make the SP fetch.
- Refresh and failure of a
MetadataSource:
| Setting | Default | Meaning |
|---|---|---|
| refresh interval | cacheDuration, or 1 hour when absent |
clamped to [MinRefresh, MaxRefresh] (5 minutes, 24 hours); an explicit PT0S asks for MinRefresh |
| early expiry | — | a document whose validUntil comes before its scheduled refresh is refreshed once it expires, at most once per RetryInterval |
RetryInterval |
1 minute | spacing of the attempts after a failure or an early expiry, whatever the traffic |
MaxStale |
7 days | how long the last good document is served while refreshes fail; never past its validUntil |
| afterwards | — | BeginLogin and CompleteLogin fail closed with ErrMetadataExpired |
A refresh starts at the first use after it is due and runs in the background: while a usable document is cached, no login waits for it, so a slow or hung metadata endpoint never reaches the login path. Only a caller with nothing usable waits. A new key is therefore trusted from the first login after the refresh completes.
- A transaction is consumed atomically, one winner across instances, once
the response passed the browser binding, the transaction checks, the
status and the Response
InResponseTo, and before any signature is verified: a login allows one verification. A response refused from then on ends the login, and any later POST getsErrTransactionConsumed: the user starts over. A response refused earlier consumes nothing. - Only the browser holding the binding cookie gets that far. A page that
knows a victim's in-flight RelayState handle and request ID (a leaked IdP
redirect URL) can make her browser post a forged response, which burns
her login;
Connection.ResponseOriginsrefuses such a POST first (see §5). - Each stored transaction carries a MAC under a server-held key
(
Config.TransactionKeys) over its ID, browser binding, payload and expiry, verified before any stored field is used: an altered record fails withErrTransactionIntegrityand consumes nothing. - The assertion then goes to the replay cache, keyed by a digest of the
connection, the issuer and the assertion ID (
ErrAssertionReplayed). The Response ID is not a replay key: a re-wrapped assertion is the same assertion. - Retention is the last instant the assertion could be accepted, plus a
one-minute margin:
min(SubjectConfirmationData NotOnOrAfter + skew, rounded up to the second; transaction expiry) + 1 minute. - Purging is per store:
saml/sp/storage/memorynever purges (development and tests only);saml/sp/store/sqlships no job, and its package documentation gives the cleanup statements;saml/sp/store/redisuses key expiry, so Redis must run withnoeviction.
| Limit | Value |
|---|---|
ACS body, or the SAMLResponse value of a pre-parsed form |
MaxResponseSize: 256 KiB by default, 16 KiB to 1 MiB |
| XML document | 1 MiB, 4096 tokens, depth 64 |
| Elements | 450 (MaxResponseElements, sized from goxmldsig's canonicalization budget) |
| Per element | 16 attributes, 16 namespace declarations, 32 in-scope prefixes |
| Attribute value | 8 KiB |
| Refused outright | DTDs, entities, processing instructions, two attributes of one local name, prefixed attributes named like SAML or XML-DSig ones, xml: attributes other than xml:lang, xml:space, xml:base and xml:id |
| Verifications per login | one: the transaction is consumed before the signatures are verified (at most three gosaml2 runs for a signature without KeyInfo) |
A response carrying hundreds of group memberships exceeds the element cap
(ErrResponseTooLarge). Filter the groups the IdP releases rather than
raising limits.
An Identity is unique within its connection only. In a multi-tenant
application one tenant can configure an IdP whose entity ID collides with
another tenant's, so the IdP entity ID is a claim of the connection, not a
global name.
The stable external-identity key is, for a persistent NameID:
(ConnectionID, Issuer, NameQualifier or Issuer, SPNameQualifier or Audience, Format, Value)
ConnectionIDcomes first and is generated by the application, never chosen by a tenant.- An absent
NameQualifierstands for the issuer, and an absentSPNameQualifierfor the SP entity ID (Identity.Audience). - Values are compared exactly, with no trimming or case folding.
- Encode the key without ambiguity: a connection ID or a value may contain
any separator. Length-prefix each field, as
accountKeyinexamples/saml-spdoes (<len>:<field>;for each), or store the SHA-256 of that encoding.
| NameID format | As a key |
|---|---|
persistent |
Yes. Configure every IdP to send it (see the per-IdP notes in security-considerations.md) |
transient |
Never: valid for one session only (Core §8.3.8) |
emailAddress, unspecified |
No, unless the operator asserts, for that connection, that the value is immutable and never reassigned. Email addresses are renamed and reassigned |
- Never link accounts by email automatically. An IdP vouching for an address says nothing about the account another tenant's IdP vouches for.
- Subject identifier attributes. The SAML V2.0 Subject Identifier
Attributes Profile (
urn:oasis:names:tc:SAML:attribute:subject-id,urn:oasis:names:tc:SAML:attribute:pairwise-id) is an alternative to a persistent NameID, read withIdentity.AttributeValues. Their values are scoped (value@scope): check the scope against the scopes this connection may assert. - Logs. A persistent NameID is a pseudonymous identifier (Core §8.3.7): treat it as personal data. Log the connection ID and a digest rather than the raw value where possible.
- Connection re-creation gives a new connection ID, so new keys. Migrate the old identities explicitly, by connection, never by email.
A broker typically stores the key in a table like this:
CREATE TABLE external_identity (
tenant_id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
provider VARCHAR(80) NOT NULL, -- 'saml:' || connection ID
subject VARCHAR(128) NOT NULL, -- hex SHA-256 of the encoded key
PRIMARY KEY (provider, subject)
);-
The application owns the session. Create it after
CompleteLoginsucceeds, with a fresh session identifier (no fixation). Bound its lifetime bySessionNotOnOrAfter, asexamples/saml-spdoes. -
Do not mint a
SameSite=Strictsession at the ACS. The redirect that follows the ACS is still a cross-site navigation: Chrome sends aLaxcookie set at the ACS with it, but not aStrictone (browser suite, BR1). The user would appear signed out until the next same-site navigation. -
Local logout is not Single Logout. The IdP session lives on, and the next login is silent, without credentials (browser suite, BE1). Say so in the logout UI. Use
ForceAuthnwhen the application needs a fresh authentication, for example a step-up or a switch of account. -
Login CSRF has two halves.
- Response injection: an attacker posts her own valid response into the
victim's browser. The browser binding refuses it
(
ErrMissingBrowserBindingorErrBrowserBindingMismatch), and a sibling host cannot plant the__Host-cookie (BR3, BR4). - Forced initiation: an attacker page navigates the victim to the
login of a connection whose IdP signs her in as the attacker, typically
the attacker's own tenant.
BeginLogincannot tell: the victim's browser started that login. The application must start logins only from a same-origin user gesture: a GET whoseSec-Fetch-Siteissame-originornone(a link of the site, the address bar), or a POST carrying a CSRF token. Refusecross-siteandsame-site. - A leaked in-flight request: the IdP redirect URL carries the request
ID and the RelayState handle. Whoever learns it while the login is
pending (browser history, a proxy log) can sign in at the IdP as
herself, get the response to the victim's request and auto-post it from
a page the victim visits: the victim's browser attaches its binding
cookie and is signed in as the attacker, or, with a forged response,
loses that login.
Connection.ResponseOriginsrefuses a POST from another origin than the IdP's (see §6); without it, keepTransactionTTLas short as the IdP interaction allows, never log full IdP redirect URLs, and show the signed-in account after a login.
examples/saml-spgates its login this way and also accepts a request withoutSec-Fetch-Site, which browsers older than Fetch Metadata do not send. That trade-off keeps those browsers working and leaves them open to forced initiation. A strict deployment refuses a missing header too, or starts logins with a CSRF-protected POST. The browser suite (BR8) shows the example refusing a login forced from another site and from a sibling host. It also shows a header-less forced login completing, because the library leaves intent to the application. - Response injection: an attacker posts her own valid response into the
victim's browser. The browser binding refuses it
(
mux.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) {
switch r.Header.Get("Sec-Fetch-Site") {
case "same-origin", "none": // add "" to admit browsers without Fetch Metadata
default:
http.Error(w, "start the sign-in from this site", http.StatusForbidden)
return
}
// ... BeginLogin and redirect, as in examples/saml-sp
})-
TransactionsandReplayCachemust be shared by every instance and read from the primary:saml/sp/store/sqlon PostgreSQL, MySQL (with or withoutclientFoundRows) or SQLite;saml/sp/store/redis, withnoeviction.
Both are raced across instances in the test suites (see §9).
-
Keep the instances' clocks synchronized well within the one-minute replay margin and the clock skew.
-
Rate-limit the login entry point:
BeginLoginwrites to the store for unauthenticated users. Rate-limit the ACS per client too: a POST that passes the binding check costs one signature verification.
Config.TransactionKeysis required: 1 to 4 keys of at least 32 bytes from a cryptographic source (openssl rand -hex 32), the same on every instance, kept in a secret store.TransactionKeys[0]protects the transactionsBeginLoginstores; every key is accepted byCompleteLogin(rotation: §7).- The keys are the records' only integrity protection. Use keys distinct
from the OpenID Connect client's and the session's, and replace a leaked
key at once: the logins in progress then fail once
(
ErrTransactionIntegrity). - The stores remain trusted for availability and single consumption: give the SP least-privilege access (a Redis ACL limited to its key prefix, a database user limited to its two tables).
- One connection per trusted IdP, typically per tenant, each with its own SP entity ID and ACS URL. Two connections trusting one IdP must not share them: the per-connection SP identity is what keeps one tenant's responses out of the other's connection.
- Configure absolute URLs. Never derive them from
HostorX-Forwarded-*headers. - The
ConnectionResolverruns on every login and every ACS request. Cache it, and invalidate the cache when a connection changes. With aMetadataSource, callIdentityProvider(ctx)in the resolver.
- Exempt it from the application's CSRF protection: the POST comes from the
IdP's page. The browser binding and
InResponseToare its CSRF defense. - Do not let a middleware parse its form first.
CompleteLoginbounds theSAMLResponsevalue either way, but Go's own form limit is 10 MB. Any body limit in front of it must be at leastMaxResponseSize. - Answer every refusal with one generic page and HTTP 200 (Bindings
§3.5.6), and log the error. Never show the reason or the IdP's
StatusMessage: both are attacker-influenced. - Mount
CompleteLoginat exactly the connection's ACS URL: it refuses a request received on another path or host (r.Host) or with a query string (ErrDestinationMismatch, Core §3.2.2). Connection.ResponseOrigins(optional, at most 8) lists the origins of the IdP pages that post the responses, serialized as browsers send them inOrigin(https://idp.example.com, a port only when not the default).CompleteLoginthen requires the request's singleOriginheader — or, from a browser that sends noOrigin, itsReferer's origin — to be one of them, and refusesnull, twoOriginheaders or neither header (ErrOriginMismatch), before the transaction is touched. It needs an IdP page that lets browsers send its origin: withReferrer-Policy: no-referrerorsame-origin, from a sandboxed page or after a cross-origin redirect, browsers sendOrigin: null. Keycloak's default realm sendsno-referrerfrom its POST page; setting the realm's Referrer-Policy header tostrict-origin-when-cross-origin(Realm settings → Security defenses → Headers) makes it usable (Keycloak live run, K8). In Chrome, the IdP's POST carries the IdP's origin and an attacker's auto-post carries the attacker's (browser suite, BR1 and BR3).
-
The binding cookie is
__Host-samlsp:Secure,HttpOnly,Path=/, noDomain,SameSite=None. TLS termination at a proxy is fine: what matters is that the browser sees https. Plain http works only on loopback, for development. -
The login route and the ACS must be on the same host. A
__Host-cookie is host-only: a login started onapp.example.comis refused at an ACS onsso.example.com(ErrMissingBrowserBinding, browser suite BR6). -
The ACS path and host must reach the application unchanged: a proxy that strips a prefix, or rewrites
Host(nginx's defaultproxy_set_header Host $proxy_host), makes every response fail the location check (ErrDestinationMismatch). Forward the original host (proxy_set_header Host $host;), or passCompleteLogina copy of the request whoseHostcomes from your trusted proxy's header — only when every request passes through that proxy, which overwrites the header (a handler must not modify the request it receives):acs := func(w http.ResponseWriter, r *http.Request) { req := r if host := r.Header.Get("X-Forwarded-Host"); host != "" { // written by our proxy only req = r.Clone(r.Context()) req.Host = host } res, err := sp.CompleteLogin(req.Context(), req, connectionID) // ... }
Behind several proxies
X-Forwarded-Hostmay be a comma-separated list: take the value your own proxy wrote, never one a client could have sent. -
Browsers that mishandle
SameSite=None(Safari on iOS 12 and macOS 10.14, Chrome 51–66) cannot complete a login. Blocking third-party cookies did not break the login in Chrome (BR2): the ACS POST is a top-level navigation, not a third-party request.
The library never redirects: LoginOptions.AppState comes back from
CompleteLogin. An application redirecting to it must accept local paths
only, with an allowlist rather than a cleanup:
- one leading
/and no//; - RFC 3986 path and query characters only: no control character, white
space,
\or non-ASCII byte; - still a local path once percent-decoded.
Browsers drop TAB, LF and CR inside URLs and read \ as /, so
/<TAB>/evil.example leaves the site. safeReturn in examples/saml-sp
implements this rule. The browser suite (BE2) plays twelve hostile targets
against it. Against a check that only refused //, \, CR and LF, the
TAB payload lands on the attacker's site.
- The IdP publishes the new certificate next to the current one (errata E68: any listed key may sign).
- The SP learns it. With imported metadata, re-import the document on
every instance. With a
MetadataSource, wait one refresh interval (MaxRefreshat worst) plus the clock skew. Each instance refreshes on its own, at the first use after the refresh is due, in the background, so an idle instance learns the key at its first login after that. - The IdP switches to the new key. The certificate in its
KeyInfomust equal the imported one exactly, and it is accepted only from itsNotBefore + ClockSkew. - The IdP retires the old certificate. Responses it signs are refused once every instance has re-imported or refreshed.
TestMetadataSourceFollowsKeyRollover, TestIdPKeyRollover and the
Keycloak case K4 cover these steps.
- Announce: set
AdditionalSigningCertificatesto the new certificate. The SP metadata lists both,use="signing". Every IdP re-imports or refreshes the SP metadata. - Switch: the new key becomes
SigningKey, and the old certificate may stay inAdditionalSigningCertificatesfor a while. - Retire: drop the old certificate from the metadata.
Keycloak keeps one SP signing certificate per SAML client, so step 1 does not apply. A request signed by the new key gets no response until the client's certificate is replaced, and once it is, a request signed by the previous key gets none (Keycloak case K5). Replace it at the moment the SP switches, or let Keycloak read the SP metadata URL (not tested here).
- Add the new key last in
Config.TransactionKeyson every instance. - Once every instance runs with it, move it first: new transactions are protected by it, the old key still verifies the pending ones.
- Remove the old key once
TransactionTTL(10 minutes by default) has elapsed. A key removed earlier fails the logins it protected (ErrTransactionIntegrity), as does a leaked key replaced at once.
TestCompleteLogin_TransactionKeyRotation covers these steps.
In-flight logins of a deleted connection fail closed (ErrUnknownConnection),
and a transaction is never completed by another connection
(ErrTransactionMismatch). Identities are keyed by the connection ID: plan
their migration before re-creating a connection.
Every refusal matches ErrRejected (which wraps
security.ErrInvalidCredentials) and one specific sentinel. Store and
resolver failures are returned as they are and do not match ErrRejected.
| Error | Typical cause | Action |
|---|---|---|
ErrMissingBrowserBinding |
the login started on another host than the ACS; the browser drops SameSite=None cookies; cookies are blocked for the site; a response posted from another browser |
serve the login and the ACS from one host; check the browser; nothing to do for an attack |
ErrBrowserBindingMismatch |
the response belongs to a login of another browser (login CSRF attempt) | none: expected under attack |
ErrTransactionNotFound |
the RelayState was replaced by another handle; the transaction store is not shared by every instance; the transaction was purged | share the store; check that the IdP returns the RelayState unchanged |
ErrTransactionExpired |
the user took longer than TransactionTTL at the IdP |
restart the login; raise TransactionTTL (1 hour at most) if this is common |
ErrTransactionConsumed |
double submission, the back button, a replay; an earlier response of this login was refused after the consumption (one verification per login) | none; the user restarts the login |
ErrTransactionIntegrity |
the record was altered in the store; the instances do not share the same TransactionKeys; a key was removed before TransactionTTL elapsed; logins in progress across the upgrade that introduced the MAC |
give every instance the same keys; investigate access to the store |
ErrOriginMismatch |
ResponseOrigins is set and the IdP page posts with Origin: null (Referrer-Policy: no-referrer, Keycloak's default) or from another origin; a login CSRF attempt |
set ResponseOrigins to the origin the IdP page sends, or leave it empty (see §6) |
ErrUnsolicitedResponse |
IdP-initiated SSO; an IdP that drops the RelayState or puts something else than the handle there; a Success Response without InResponseTo |
start logins at the SP; fix the IdP |
ErrInResponseToMismatch |
the response answers another login of the same browser (two tabs) | restart the login |
ErrAssertionReplayed |
the same assertion posted twice, or a broken IdP reusing IDs | none |
ErrDestinationMismatch, ErrRecipientMismatch |
the ACS URL registered at the IdP differs from the connection's; a proxy rewrites the path or the Host; the ACS was reached with a query string |
align the URLs, character for character; forward the original Host |
ErrAudienceMismatch |
the SP entity ID registered at the IdP differs | align the entity ID |
ErrIssuerMismatch |
wrong IdP entity ID in the connection | re-import the IdP metadata |
ErrInvalidSignature |
the IdP signs with a key not imported yet (rollover); an unsigned response; tampering | import the IdP's current metadata; enable signing at the IdP |
ErrUnsupportedAlgorithm |
SHA-1, RSASSA-PSS or inclusive C14N at the IdP | configure RSA-SHA256 and exclusive C14N; InsecureAllowSHA1 only for a legacy IdP |
ErrEncryptedAssertionUnsupported |
the IdP encrypts assertions | disable assertion encryption for this SP at the IdP |
ErrAssertionExpired, ErrAssertionNotYetValid |
clock drift between IdP and SP beyond ClockSkew; a slow user; a session already ended |
synchronize the clocks (NTP) |
ErrStaleAuthentication |
ForceAuthn requested, but the IdP reused its session |
fix the IdP, or accept no ForceAuthn |
ErrNameIDFormatMismatch |
the IdP sends another NameID format than the connection requests | configure the IdP (persistent) |
ErrAuthnContextMismatch |
the IdP authenticated with another context class | align the requested classes |
ErrResponseTooLarge |
too many attributes or groups; an oversized body | filter what the IdP releases |
ErrMalformedResponse, ErrInvalidAssertion, ErrMalformedRequest |
a response shape the SP refuses (several assertions, an unknown condition, a non-POST request) | configure the IdP to send one assertion with a persistent NameID |
*StatusError (ErrResponseStatus) |
the IdP refused: NoPassive (an IsPassive login without a session), AuthnFailed, RequestDenied, InvalidNameIDPolicy |
per status; its AppState and ConnectionID identify the login |
ErrMetadataUnavailable, ErrInvalidMetadata, ErrMetadataExpired |
the metadata URL is unreachable, refused by the outbound policy, or serves another entity; an EntitiesDescriptor without a pinned entity ID; refreshes have failed for MaxStale |
check the URL, Outbound.AllowedPrefixes and RootCAs for an on-premises IdP; pass the entity ID |
ErrInvalidConfig from New or BeginLogin |
no TransactionKeys, or a key under 32 bytes; the IdP wants signed requests and the SP has no key |
configure the transaction keys; configure SigningKey and SigningCertificate |
| Suite | Where | Covers |
|---|---|---|
| Unit tests | saml/sp |
every rule, with generated fixtures |
| Pinned adversarial corpus | saml/sp/testdata/corpus, TestCorpus |
390 entries over 360 signed responses: wrong issuer, audience, destination, recipient and InResponseTo; the received location and the origin of the POST; time windows; statuses; missing fields; untrusted keys and KeyInfo without certificate; XSW1–XSW8 and wrapping variants; duplicate IDs; substitution; algorithms; malformed XML; size and decompression limits; parser differentials; records altered in the store. Each entry pins the full refusal message, or the exact identity, and whether the login was consumed. TestCorpusCoversEveryRule requires every refusal rule of the sources in some entry |
| crewjam/saml IdP | internal/interop/saml |
SSO over TLS, metadata exchange both ways, request signatures checked by an independent verifier, IdP and SP key rollover, SHA-1 policy, two IdPs and two connections, cross-tenant substitution, replay, concurrent instances |
| Keycloak replays | internal/interop/saml/testdata/keycloak-* |
15 pinned Keycloak responses (14 from 26.7.4, one from 26.0.7); XSW1–XSW8, signature removal, tampering and comment injection applied to the signed 26.7.4 ones |
| Shared stores | internal/interop/saml |
three SP instances with their own handles on a SQLite file (WAL) and on miniredis and, in the "Store suites (real backends)" job (make test-backends), on PostgreSQL 16, MySQL 8.0 (also with clientFoundRows, and as a latin1 database under a non-strict sql_mode) and Redis 7.4; 16 contenders per race, held at the store until all arrive; the conformance suite, retention over time included |
| Metadata retrieval | internal/interop/saml |
a MetadataSource behind a connection: key rollover, a hung refresh that never delays a login, fetches driven by time only, entity pin, policy refusals |
Regenerate the corpus after a deliberate change with
go test -run '^TestCorpusGenerate$' -count=1 ./saml/sp -args -corpus.update
from the repository root. With the Go toolchain recorded in the manifest
(generatedWith), regeneration is byte-identical; another Go version
generates other RSA keys and rewrites every file, so regenerate in a commit
of its own. The generator checks every declared outcome first. TestCorpus
only replays the pinned files and passes with any toolchain.
internal/interop/saml/compose.yaml starts Keycloak, PostgreSQL, MySQL and
Redis, each pinned by digest, on non-default loopback ports. It takes the
passwords from the environment and persists nothing:
export SAMLSP_KEYCLOAK_ADMIN_PASSWORD="$(openssl rand -hex 16)"
export SAMLSP_DB_PASSWORD="$(openssl rand -hex 16)"
docker compose -f internal/interop/saml/compose.yaml up -d --wait
cd internal/interop/saml
# Keycloak live (each run creates and deletes a realm with a random user);
# append "-args -keycloak.capture" to rewrite testdata/keycloak-26.7.4.
# The 26.0.7 fixture is rewritten only by TestCaptureKeycloakFixture with
# "-args -keycloak.fixture", against a Keycloak 26.0.7.
SAMLSP_KEYCLOAK_URL=http://127.0.0.1:18080 go test -count=1 -run '^TestKeycloakLive$' .
# Shared stores on real servers, which CI runs too (make test-backends);
# MySQL also as a latin1 database under a non-strict sql_mode, which used
# to alter non-ASCII payloads. A backend whose variable is unset is skipped:
SAMLSP_REDIS_ADDR=127.0.0.1:16379 \
SAMLSP_POSTGRES_DSN="postgres://samlsp:$SAMLSP_DB_PASSWORD@127.0.0.1:15432/samlsp?sslmode=disable" \
SAMLSP_MYSQL_DSN="root:$SAMLSP_DB_PASSWORD@tcp(127.0.0.1:13306)/" \
SAMLSP_RACE_ROUNDS=10 go test -count=1 -race -run 'TestSharedStore' .make e2e-saml runs the browser suite (e2e/saml; make e2e runs it,
then e2e/oidc): Tales scenarios against the real examples/saml-sp
binary in Chrome. It needs Chrome and Tales:
go install github.com/tales-testing/tales/cmd/tales@d50dd8b # v0.1.0-rc.23It also needs the ports 8084, 18443 and 18480 free on loopback. A helper
process serves every site (sp.test, acs.sp.test, evil.sp.test,
idp.test, attacker.test) on one TLS port. Its runtime certificate is
trusted by SPKI hash through Chrome's own flags, so no trust store changes.
CI does not run this suite: re-run it after any change to the example or
to the cookie, login or ACS code of saml/sp.
| Scenario | Shows |
|---|---|
| BR1, BR2 | the ACS POST is cross-site, carries the IdP's Origin and only the SameSite=None binding cookie, also with third-party cookies blocked; the redirect after it carries Lax cookies, not Strict ones |
| BR3 | a valid response auto-posted into another browser is refused and stays usable by its owner; the POST carries the attacker's Origin |
| BR4 | a sibling host cannot plant the __Host- cookie (Domain, nameless and case-variant plants); an unprefixed cookie is planted and ignored |
| BR5 | tampered, swapped, missing and URL RelayStates are refused, consume nothing and never leave the site |
| BR6 | a login started on another host than the ACS fails |
| BR7 | an IdP-initiated response is unsolicited |
| BR8 | the example's Sec-Fetch-Site gate refuses a login forced from another site or from a sibling host, and a click on the site still signs in; without Fetch Metadata, a forced login completes |
| BE1 | logout is local: the next login is silent |
| BE2 | twelve hostile return targets never leave the site |
Recorded on 2026-10-12, re-run on 2026-10-13 after the security audit fixes:
| Peer | Version | How | Result |
|---|---|---|---|
| crewjam/saml IdP | v0.5.1 | CI, in process | pass |
| Keycloak (Apache Santuario) | 26.7.4 (sha256:82a77884…) |
live, K1–K8 | 17/17 subtests pass |
| Chrome | 154.0.8037.98 | Tales v0.1.0-rc.23, make e2e |
16 scenarios pass |
| PostgreSQL | 16 (sha256:ca0bd484…) |
CI (make test-backends): conformance suite and races (10 rounds, 16 contenders, race detector) |
pass |
| MySQL | 8.0 (sha256:7dcddc01…), with and without clientFoundRows, and as a latin1 database under sql_mode='' |
CI (make test-backends): conformance suite, races (10 rounds, 16 contenders, race detector) and a non-ASCII AppState round trip |
pass |
| Redis | 7.4 (sha256:4fa24486…), noeviction |
CI (make test-backends): conformance suite and races (10 rounds, 16 contenders, race detector) |
pass |
What the Keycloak run established:
- Keycloak applies the client's NameID format only when the AuthnRequest
asks for it or Force name ID format is on. Set
Connection.NameIDFormatto persistent. - A fresh realm key is refused for
ClockSkew. - RSASSA-PSS (
RSA_SHA256_MGF1) and encrypted assertions are refused. IsPassivewithout a session answersResponder/NoPassive.- The page carrying its POST form sends the realm's
Referrer-Policy: no-referrer(browsers then postOrigin: null), andstrict-origin-when-cross-originonce the realm's header is changed (K8): seeConnection.ResponseOriginsin §6.
Known gaps:
- No other live IdP (AD FS, Entra ID, Okta, Shibboleth) was run. Use the manual procedure below.
- The browser suite covers Chrome only.
Before going live with an IdP, run examples/saml-sp against it:
- Put the example behind a TLS terminator with a certificate the browser
trusts, on the host the users will use, and set
SAMLSP_BASE_URLto its https origin. The example listens on127.0.0.1:8084; setSAMLSP_ADDRwhen the terminator runs elsewhere. - Save the IdP's metadata to a file and set
SAMLSP_IDP_METADATAto it.SAMLSP_TRANSACTION_KEYSsets the transaction keys (hex, separated by commas; a random key for the process when unset), andSAMLSP_RESPONSE_ORIGINSthe IdP origins allowed to post to the ACS (see §6). - Register
<base>/saml/corp/metadataat the IdP, then sign in from the home page. - Check:
- the login succeeds. The example requests a persistent NameID and logs any refusal with its sentinel (see §8): a wrong format, an unsigned or SHA-1 response, encryption, clock drift or too many attributes;
- the NameID shown is the same at every login, and is not an email;
- the mail attribute appears when the IdP releases
urn:oid:0.9.2342.19200300.100.1.3; - a logout followed by a login is silent (local logout).
- Remove the registration when done. The example keeps everything in memory and writes nothing.