The Authentication Verifier can act as a SAML 2.0 Service Provider (SP): users of a realm
sign in at the organisation's identity provider (IdP) — Microsoft Entra ID, Okta, ADFS,
Keycloak, Shibboleth, … — and come back with the usual _ea_ session cookie. Nothing changes
for the API servers that validate sessions: a SAML session is a session like any other, with
as_as (auth scheme) set to "sa".
- What is supported
- Architecture
- Prerequisites
- Step 1 — Create the SP signing key
- Step 2 — Configure the realm
- Step 3 — Register the realm at the IdP
- Step 4 — Sign users in from your application
- Login flow
- Identity mapping
- Security checks
- Certificate and key rotation
- Troubleshooting
| Supported | Not supported |
|---|---|
| SP-initiated Web Browser SSO (SAML Profiles §4.1) | IdP-initiated (unsolicited) responses |
AuthnRequest over HTTP-Redirect, signed with RSA-SHA256 |
HTTP-POST or Artifact binding for requests |
Response over HTTP-POST |
Artifact binding for responses |
| Signed assertion, signed response, or both | Encrypted assertions |
| RSA or ECDSA signatures with SHA-256/384/512 | SHA-1 signatures or digests |
| IdP metadata pasted by an administrator | Fetching metadata from a URL |
| One IdP per realm | Federations / multi-IdP discovery |
Local logout (DELETE /sessions) |
SAML Single Logout |
SAML is a login method for the users of a realm. It is never an administrator credential:
admins keep signing in with the methods of the _ realm, which refuses SAML settings.
flowchart LR
subgraph user["End user"]
B["Web browser"]
end
subgraph app["Your application"]
APP["Web application<br/>(origin listed in allowed_return_origins)"]
API["API server"]
end
subgraph idp["Identity provider"]
IDP["SAML 2.0 IdP<br/>Entra ID, Okta, ADFS, Keycloak, Shibboleth"]
DIR[("User directory<br/>AD, LDAP")]
MFA["MFA factors"]
end
subgraph verifier["Authentication Verifier deployment"]
EA["Authentication Verifier<br/>SAML 2.0 Service Provider<br/>/saml/{realm_id}/login, /acs, /metadata"]
KEY["SP signing key and certificate<br/>(PEM files of saml_sp_params)"]
DB[("Auth database<br/>SQLite, PostgreSQL, MySQL<br/>realms and saml_params")]
SS[("Session store<br/>SQLite, PostgreSQL, MySQL, Redis<br/>sessions, pending SAML requests, replay cache")]
end
ADM["Realm administrator<br/>admin UI or admin API"]
IDPADM["IdP administrator"]
B -- "1. opens the application" --> APP
B -- "2. login redirect and ACS form post" --> EA
B -- "3. signs in" --> IDP
IDP --> DIR
IDP --> MFA
B -- "4. API calls with the _ea_ cookie" --> API
API -- "5. validates the session (/sessions)" --> EA
EA --> DB
EA --> SS
KEY -.-> EA
ADM -. "pastes IdP metadata, sets SAML realm settings" .-> EA
EA -. "SP metadata" .-> IDPADM
IDPADM -. "registers the SP" .-> IDP
The Authentication Verifier and the IdP never talk to each other directly: every SAML message
travels through the browser (front channel), and trust is set up once, out of band, by
exchanging metadata. The application and the API server only ever see the _ea_ session.
-
A server built with the
samlfeature. SAML statically links the xmlsec and libxml2 C libraries, so it is off by default. Either use a prebuilt SAML build or build it yourself:- Prebuilt: the release publishes a SAML variant next to the standard one — packages
whose file name ends in
-saml(for exampleauth-verifier_<version>-1_amd64-saml.deb) and the Docker imageghcr.io/cosmian/auth-verifier-saml:<version>, a separate package from the standardghcr.io/cosmian/auth-verifier. It is a static build only and installs under the same package name as the standard server, so install one or the other. - From source with Nix:
nix-build -A auth-verifier-static-saml - From source with cargo, inside the Nix shell:
nix-shell --run "cargo build --release --features auth_verifier/saml"A server built without the feature refuses realms carrying SAML settings (HTTP 400) and refuses to start if
[saml_sp_params]is configured. - Prebuilt: the release publishes a SAML variant next to the standard one — packages
whose file name ends in
-
HTTPS. The ACS URL must be
https://, and the login cookie that protects against login CSRF isSecure. -
An SP signing key in the server configuration — Step 1. Without it the
/samlroutes don't exist and realms can't enable SAML.
One RSA key signs the AuthnRequests of every SAML realm, and its certificate is published in
the SP metadata that IdPs import. It is server configuration: it never goes through the
database or the admin API.
openssl req -x509 -newkey rsa:3072 -sha256 -days 1095 -nodes \
-subj "/CN=auth.example.com SAML SP" \
-keyout saml-sp.key.pem -out saml-sp.cert.pem
chmod 600 saml-sp.key.pem[saml_sp_params]
saml_rsa_private_key = "/etc/cosmian/saml-sp.key.pem"
saml_certificate = "/etc/cosmian/saml-sp.cert.pem"The server checks the pair at startup and stops if a file is unreadable, the key isn't RSA or is shorter than 2048 bits, or the certificate doesn't match the key. A self-signed certificate is fine: IdPs pin the certificate from the metadata rather than validating a chain. See Server configuration.
In the admin UI, open the realm (Realms → Edit) and tick SAML 2.0 (single sign-on).
The same settings can be sent through the API as auth_params.saml_params on
POST /admins/realms or PUT /admins/realms/{realm_id}:
{
"saml_params": {
"metadata_xml": "<md:EntityDescriptor entityID=\"https://idp.example.com/…\">…</md:EntityDescriptor>",
"sp_entity_id": "https://auth.example.com/saml/my-service",
"sp_acs_url": "https://auth.example.com/saml/my-service/acs",
"subject_attribute": "email",
"normalize_subject_case": true,
"role_attribute": "groups",
"attribute_claim_map": { "department": "dept" },
"allowed_return_origins": ["https://app.example.com"],
"default_return_url": "https://app.example.com/"
}
}| Field | Required | Description |
|---|---|---|
metadata_xml |
Yes | The IdP's SAML 2.0 metadata (one EntityDescriptor, at most 256 KiB). |
sp_entity_id |
Yes | This server's entity ID for the realm, given to the IdP. Conventionally https://<server>/saml/<realm_id>. |
sp_acs_url |
Yes | https://<server>/saml/<realm_id>/acs — the public URL browsers post the IdP's response to. |
subject_attribute |
No | Attribute whose single value becomes the user name. Unset: the NameID is used. Required when the IdP issues transient NameIDs. |
normalize_subject_case |
No | Lowercase the user name (default false). |
role_attribute |
No | Attribute whose values become the session roles. |
attribute_claim_map |
No | Attribute name → extra-claim name. Only listed attributes reach the session. |
allowed_return_origins |
Yes | https://host[:port] origins a login may return to. |
default_return_url |
Yes | Where a login returns when no return_to is given; must be under an allowed origin. |
The IdP fields (idp_entity_id, idp_sso_url, idp_signing_certificates,
idp_nameid_format) are derived by the server from metadata_xml on every save, and any
values sent for them are ignored. The metadata is refused if it isn't a single SAML 2.0
EntityDescriptor, has expired (validUntil), has no HTTPS HTTP-Redirect
SingleSignOnService, or has no signing certificate. Every refusal is an HTTP 400 naming the
field (saml_params.<field>: <reason>); the admin UI shows it next to that field.
The admin UI previews the parsed metadata — IdP entity ID, sign-in URL, signing certificates with their expiry — before you save.
The IdP administrator needs this server's SP details. Either give them the SP metadata:
https://auth.example.com/saml/my-service/metadata
(also available from the realm drawer's Download SP metadata button, or
AuthClient::get_saml_metadata), or enter by hand:
| IdP setting | Value |
|---|---|
| Entity ID / Identifier / Audience | sp_entity_id |
| ACS URL / Reply URL (HTTP-POST) | sp_acs_url |
| Request signing certificate | The [saml_sp_params] certificate |
| Sign | Assertion, response, or both |
| Attributes to release | Those named in subject_attribute, role_attribute and attribute_claim_map |
Then paste the IdP's metadata into the realm (Step 2).
Send the browser to the realm's login endpoint, with the page to come back to:
https://auth.example.com/saml/my-service/login?return_to=https://app.example.com/dashboard
After signing in at the IdP, the browser returns to return_to (or default_return_url)
holding the _ea_ session cookie. return_to must be an https URL under one of
allowed_return_origins; anything else is refused with HTTP 400 rather than silently
replaced, so a misconfigured link is noticed.
Your API validates the session exactly as for any other method — see Session management.
sequenceDiagram
autonumber
participant B as Browser
participant EA as Authentication Verifier
participant DB as Auth database
participant RS as SAML request store
participant SS as Session store
participant IdP as Identity Provider
participant APP as Application and API
B->>EA: GET /saml/{realm_id}/login?return_to=URL
note over EA: per-IP rate limit (same as /login)
EA->>DB: get realm
DB-->>EA: realm and saml_params
note over EA: 400 if the realm does not use SAML<br/>return_to must be https under allowed_return_origins (else 400)<br/>absent return_to uses default_return_url
EA->>RS: store pending request (request ID, realm, return URL, expires in 10 min)
note over EA: AuthnRequest ID = request ID<br/>signed RSA-SHA256 with the SP key (HTTP-Redirect)
EA-->>B: 302 Location: IdP SSO URL with SAMLRequest, RelayState = request ID, SigAlg, Signature<br/>Set-Cookie: _ea_saml = request ID (Secure, HttpOnly, SameSite=None, Path=/)
rect rgba(128, 128, 128, 0.1)
note over B,IdP: at the IdP, outside the Authentication Verifier
B->>IdP: GET SSO URL
note over IdP: checks the AuthnRequest signature<br/>authenticates the user (password, MFA)
IdP-->>B: auto-submitting HTML form
end
B->>EA: POST /saml/{realm_id}/acs with SAMLResponse and RelayState<br/>Cookie: _ea_saml = request ID
note over EA: _ea_saml cookie must equal RelayState (else 401)
EA->>DB: get realm
DB-->>EA: realm and saml_params
EA->>RS: take pending request (RelayState, realm), single use
RS-->>EA: pending request with return URL (none: 401)
note over EA: base64-decode SAMLResponse<br/>verify signature against the IdP certificates (xmlsec)<br/>check algorithms, Issuer, InResponseTo, Audience,<br/>Recipient, Destination, validity window, AuthnStatement, Status
EA->>RS: record assertion ID in this realm (replay cache)
RS-->>EA: new (already seen: 401)
note over EA: map identity: subject, roles, extra claims<br/>issue session token (as_as = sa, as_rid = realm)
EA->>SS: upsert session
EA-->>B: 200 HTML page continuing to the return URL<br/>Set-Cookie: _ea_ session (SameSite=Strict), _ea_saml cleared
B->>APP: GET return URL with the _ea_ cookie
APP->>EA: validate the session (/sessions)
EA-->>APP: session data
The pending requests and the replay cache live in the session store's database (SQLite,
PostgreSQL, MySQL or Redis) and are purged on the stale-session collector's interval. The
/saml routes share the per-IP rate limit of /login.
On the SQL backends SAML opens its own connection pool with the session store's
max_connections / min_connections, so plan for twice that many connections to that
database. With auto_init_schema = false, create the two SAML tables yourself:
saml_pending_request (request_id primary key, realm_id, return_url, created_at,
expires_at) and saml_seen_assertion (primary key realm_id + assertion_id, or
assertion_id_sha256 BINARY(32) on MySQL, and expires_at), each indexed on
expires_at. The CREATE TABLE statements in server/src/saml/impls/ are the reference.
Until they exist, SAML logins fail and the periodic cleanup logs errors.
The ACS answers with a small page instead of a redirect: the SameSite=Strict session cookie
may be withheld on a navigation that directly follows the IdP's cross-site POST, and
continuing from a page of this server makes it a same-site navigation.
SAML sessions don't go through this server's TOTP step: multi-factor authentication is the IdP's responsibility.
| Session field | Source |
|---|---|
sub (user name) |
subject_attribute's value — exactly one non-empty value is required — or else the <NameID>. A transient NameID is refused unless subject_attribute is set, as it changes on every login. Lowercased when normalize_subject_case is set. |
roles |
The deduplicated values of role_attribute. None when it is unset or not sent, which policies treat as no access. |
| extra claims | For each attribute_claim_map entry the IdP sent: a string, or an array for several values. At most 4 KiB in total; claim names this server sets itself (sub, roles, as_rid, …) are refused when saving. |
as_as |
"sa" |
as_rid |
The realm ID |
Roles come straight from the IdP: this server does not filter them against its role list.
A SAML user is a different identity from a user of another scheme with the same name: a
session's identity is the realm, the scheme (as_as) and sub together. Applications must
key users on all three, never on sub alone.
A response is accepted only if all of these hold; otherwise the ACS answers
401 SAML error: <reason> and logs an auth.login.failure event.
| Check | Rule |
|---|---|
| Browser binding | The _ea_saml cookie set by /login equals RelayState (prevents login CSRF: a response obtained elsewhere can't be posted into someone else's browser). |
| Solicited | InResponseTo names a pending request of this realm, which is consumed (single use, 10-minute lifetime). |
| Signature | The assertion, or the response containing it, is signed by one of the IdP certificates from the metadata. Unsigned content is ignored (XML signature-wrapping defence). |
| Algorithms | RSA or ECDSA with SHA-256/384/512 only, for both signature and digest. |
| Issuer | Equals the IdP entity ID. |
| Audience | An AudienceRestriction is present and every one of them names sp_entity_id. |
| Recipient / Destination | Equal sp_acs_url. |
| Time | NotBefore / NotOnOrAfter hold with 3 minutes of clock skew, and the assertion was issued at most 5 minutes ago. |
| Authentication | The assertion has an AuthnStatement and a bearer SubjectConfirmation. |
| Status | The response status is Success. |
| Replay | The assertion ID hasn't been used before (remembered for as long as it could be accepted). |
IdP signing certificate. When the IdP rolls its certificate, paste its new metadata into the realm. During a planned rollover, metadata listing both certificates keeps logins working across the switch. The admin UI shows each certificate's expiry in the preview and flags a realm card when a certificate expires within 30 days.
SP signing key. Replace the files named in [saml_sp_params] and restart the server, then
have every IdP import the new metadata. IdPs that verify request signatures reject requests
signed with the new key until they do.
Rejections are logged with the reason:
WARN event="auth.login.failure" realm=my-service auth_scheme="saml" SAML login rejected: …
| Symptom | Likely cause |
|---|---|
404 on /saml/… |
The server was built without saml, or [saml_sp_params] isn't set. |
400 realm '…' does not exist or does not use SAML |
Wrong realm in the URL, or SAML isn't enabled for it. |
400 return_to is not under an allowed return origin |
Add the origin to allowed_return_origins. |
401 this sign-in was not started from this browser |
The browser didn't send back the _ea_saml cookie: the login was started in another browser or profile, a newer sign-in was started in another tab of the same browser (only the latest one can complete), or the server isn't reached over HTTPS. Start again from /saml/{realm}/login. |
401 unknown or expired sign-in request |
More than 10 minutes at the IdP, the response was already used, or it answers another realm. |
401 … must be signed / signature errors |
The IdP signs with a certificate missing from the pasted metadata, or signs neither the assertion nor the response. Refresh the metadata. |
401 … AudienceRequirement / not addressed to this service provider |
The IdP's entity ID / audience setting differs from sp_entity_id. |
401 … Recipient / destination |
The IdP's ACS / reply URL differs from sp_acs_url (watch for http vs https and trailing slashes). |
401 … expired / not valid until |
Clock drift above 3 minutes between the IdP and this server — check NTP. |
401 the NameID is transient … |
Set subject_attribute, or configure a persistent NameID at the IdP. |
401 subject attribute '…' is missing |
The IdP doesn't release that attribute to this SP. |