SAML SSO skill for Claude Code
Enterprise single sign-on without the XML bypasses. Claude adds SAML as a careful service provider: a patched library in strict mode, per-tenant connections with pinned IdP certs, audience, recipient, time, InResponseTo and replay checks, and users keyed by tenant plus IdP subject instead of email. IdP-initiated login stays off by default, and an 18-case suite of forged, replayed and wrapped responses must fail before launch.
- name
saml-sso - size268 lines · ≈3,550 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Adds SAML 2.0 single sign-on to a multi-tenant app as a service provider - SP-initiated and IdP-initiated flows, metadata exchange with Okta, Entra ID, Google Workspace and ADFS, assertion validation (signature, audience, recipient, NotOnOrAfter, InResponseTo, replay), XML signature wrapping defenses, per-tenant connection config, JIT provisioning vs SCIM, certificate rollover, and negative tests against a test IdP. Use when an enterprise customer asks for SAML SSO, when configuring an ACS URL or entity ID, debugging a rejected SAMLResponse, or reviewing SAML code. Try asking
- Acme wants to log in with Okta over SAML; add per-tenant SSO to our Node app
- Our ACS endpoint rejects Entra ID responses with an InResponseTo error, figure out why
- Review our python3-saml setup for signature wrapping and replay problems
Use it when
- An enterprise customer requires SAML 2.0 SSO with their IdP
- Configuring ACS URLs, entity IDs, metadata or certificate rollover
- A SAMLResponse is rejected and you need to find which check failed
- Auditing an existing SAML integration or library upgrade
Skip it when
- The IdP supports OpenID Connect and you can choose it - use oauth-oidc instead
- Enterprise SSO comes from a hosted provider such as Clerk or Auth0 - use auth-providers instead
How to check the result
Every case in the negative suite (tampered, unsigned, wrong audience, expired, replayed, wrapped, cross-tenant) is rejected with no session created, while the happy path works against Keycloak and a real IdP test tenant.
The common mistake it prevents
Leaving library defaults in place, such as node-saml's validateInResponseTo of never and SHA-1 signing, or matching IdP users to existing accounts by email across tenants.
Done well means: a maintained library validates every assertion with strict settings, each tenant has its own connection (IdP entity ID, SSO URL, signing certs, attribute map), users are keyed by tenant plus a stable IdP identifier rather than by email, and a suite of forged and replayed responses is rejected in CI. SAML bugs are authentication bypasses, so the validation rules below are not optional.
Hard rules
- Never parse or verify SAML XML by hand. Use a maintained library pinned to a patched version, and subscribe to its security advisories. Recent signature-bypass CVEs hit ruby-saml (CVE-2025-25291/25292, follow-ups CVE-2025-66567/66568: run 1.18.0 or later) and xml-crypto, used by node-saml (CVE-2025-29775: 6.0.1, 3.2.1 or 2.1.6 and later).
- Turn on the library’s strict mode and set every security option explicitly. Defaults are
not safe everywhere: node-saml defaults
validateInResponseToto"never",acceptedClockSkewMsto0, andsignatureAlgorithmto"sha1". - Trust only the signed element the verifier returns. Never re-query the original document by XPath for “the assertion” after verification; that is how signature wrapping works.
- Pin IdP signing certificates per tenant. Never trust a certificate embedded in the
response’s
KeyInfounless it matches a configured one. - Users are identified by
(tenant_id, idp_subject). An email claim from tenant A must never log into an account owned by tenant B, and must never merge into an existing password account without a verified-domain rule. - Security work only for systems you own or are authorized to test.
SAML or OIDC?
| Situation | Choose |
|---|---|
| Customer’s IdP supports both and you have no SAML yet | OIDC: JSON, simpler libraries, fewer XML attack classes (use oauth-oidc) |
| Enterprise procurement demands SAML, or IdP is ADFS / legacy on-prem | SAML |
| Many tenants with mixed IdPs, small team | A broker (WorkOS, Auth0/Okta enterprise connections, Keycloak, Ory Polis / SAML Jackson) that turns SAML into OIDC for your app |
| You already use a hosted auth provider | Its enterprise connection feature (see auth-providers) before writing SAML code |
If a broker is chosen, the rest of this skill becomes the review checklist for its config.
Workflow
- [ ] 1. Inventory: existing auth, tenant model, library choice, where sessions start
- [ ] 2. Data model: sso_connections, verified domains, user identities, replay cache
- [ ] 3. SP endpoints: metadata, login (AuthnRequest), ACS, optional SLO
- [ ] 4. Strict validation config and replay protection
- [ ] 5. Login routing: email domain or org slug -> connection
- [ ] 6. Provisioning: JIT attributes, optional SCIM, deprovisioning
- [ ] 7. Customer onboarding: metadata exchange, test, enforce SSO
- [ ] 8. Negative test suite against a test IdP; certificate rollover plan
1. Inventory
Read before changing anything: how sessions are created today, how tenants are modeled, whether an auth provider or broker already exists, and the language’s SAML library:
| Stack | Library |
|---|---|
| Node | @node-saml/node-saml (or @node-saml/passport-saml) |
| Python | python3-saml (OneLogin/SAML-Toolkits) or pysaml2 |
| Java / Spring | Spring Security saml2Login() (OpenSAML underneath) |
| Ruby | ruby-saml >= 1.18.0, omniauth-saml pinned to a version that requires it |
| .NET | Sustainsys.Saml2 or ITfoxtec.Identity.Saml2 |
| PHP | onelogin/php-saml |
2. Data model
create table sso_connections (
id uuid primary key,
tenant_id uuid not null references tenants(id),
idp_entity_id text not null,
idp_sso_url text not null, -- HTTP-Redirect or HTTP-POST binding
idp_signing_certs text[] not null, -- PEM; more than one during rollover
idp_metadata_url text, -- refresh on a schedule if provided
attribute_map jsonb not null default '{}', -- email, given_name, family_name, groups
allow_idp_initiated boolean not null default false,
enforce_sso boolean not null default false,
default_role text not null default 'member',
unique (tenant_id, idp_entity_id)
);
create table user_identities ( -- one row per (connection, subject)
connection_id uuid references sso_connections(id),
idp_subject text not null, -- persistent NameID or immutable attribute
user_id uuid not null references users(id),
primary key (connection_id, idp_subject)
);
create table tenant_domains (tenant_id uuid, domain text unique, verified_at timestamptz);
Replay cache: assertion IDs with expiry at NotOnOrAfter, in Redis or a table with a TTL
sweep. Pending AuthnRequest IDs: same store, keyed to the browser session, expiry about
10 minutes.
3. SP endpoints
Use per-tenant SP endpoints so the connection is known before the response is parsed:
| Endpoint | Path example | Notes |
|---|---|---|
| SP metadata | GET /saml/{connectionId}/metadata | Entity ID, ACS URL, NameID format, SP signing cert if you sign requests |
| Login | GET /saml/{connectionId}/login | Builds AuthnRequest, stores its ID, redirects |
| ACS | POST /saml/{connectionId}/acs | HTTP-POST binding only; CSRF middleware must exempt it, so validation carries the load |
| SLO | /saml/{connectionId}/slo | Optional; many customers never configure it |
SP entity ID: a stable HTTPS URL you control, e.g. https://app.example.com/saml/{connectionId}.
Changing it later breaks every customer’s IdP config.
4. Validation rules
The library must enforce all of these; confirm each with a test (step 8):
| Check | Rule |
|---|---|
| Signature | Response or Assertion signed by a pinned cert; require the Assertion signed (or the whole Response signed and the Assertion inside it). RSA-SHA256 or stronger; reject SHA-1 where the IdP allows |
| Structure | Exactly one Assertion; reject DTDs and external entities; cap body size (for example 256 KB) |
| Issuer | Equals the connection’s idp_entity_id |
| Audience | AudienceRestriction contains your SP entity ID for this connection |
| Recipient / Destination | SubjectConfirmationData/@Recipient and Response Destination equal this ACS URL exactly |
| Time | NotBefore <= now < NotOnOrAfter on Conditions and SubjectConfirmationData, skew allowance at most 3 minutes |
| InResponseTo | For SP-initiated logins, equals a pending request ID from this browser session; delete it on use |
| Replay | Assertion ID not seen before; store it until NotOnOrAfter |
| Status | Top-level StatusCode is Success; surface others as a readable error |
| Subject | SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"; NameID present |
Example with node-saml, all security options explicit:
import { SAML } from "@node-saml/node-saml";
function samlFor(conn: SsoConnection) {
return new SAML({
issuer: `https://app.example.com/saml/${conn.id}`, // SP entity ID
callbackUrl: `https://app.example.com/saml/${conn.id}/acs`,
entryPoint: conn.idpSsoUrl,
idpIssuer: conn.idpEntityId,
idpCert: conn.idpSigningCerts, // array allows rollover
audience: `https://app.example.com/saml/${conn.id}`,
wantAssertionsSigned: true,
wantAuthnResponseSigned: true, // relax per-connection only if the IdP cannot sign the Response
signatureAlgorithm: "sha256",
acceptedClockSkewMs: 180_000,
validateInResponseTo: conn.allowIdpInitiated ? "ifPresent" : "always",
requestIdExpirationPeriodMs: 600_000,
cacheProvider: redisCacheProvider, // shared across instances, not in-memory
maxAssertionAgeMs: 300_000,
});
}
// ACS handler
const { profile } = await samlFor(conn).validatePostResponseAsync(req.body);
if (!profile) throw new AuthError("no profile");
// Replay: read the Assertion ID and NotOnOrAfter from the validated assertion
// (node-saml exposes it via profile.getAssertion()); confirm field paths on your version.
await rejectReplay(assertionIdOf(profile), notOnOrAfterOf(profile));
For python3-saml set "strict": true, wantAssertionsSigned, wantMessagesSigned,
rejectDeprecatedAlgorithm: true, compare get_last_request_id() (stored at login) with
get_last_response_in_response_to(), and store get_last_assertion_id() for replay checks.
Library-by-library settings: references/validation-and-testing.md.
SP-initiated vs IdP-initiated
| Flow | Start | Risk | Policy |
|---|---|---|---|
| SP-initiated | User enters email or org slug in your app | Low; InResponseTo binds response to the session | Default for every tenant |
| IdP-initiated | User clicks a tile in Okta/Entra dashboard | Unsolicited response, no InResponseTo: login CSRF and injected-assertion risk | Off by default; per-connection opt-in with replay cache and short max assertion age |
Safer IdP-initiated pattern: on an unsolicited response, validate it, then immediately
start a fresh SP-initiated login to the same connection; the user sees one extra redirect
and the session is bound properly. RelayState is attacker-controllable: treat it as a
relative path validated against an allowlist, never as a redirect URL.
5. Login routing
- Email-first login: look up the domain in
tenant_domains(verified only) and redirect to that tenant’s connection. Verify domains by DNS TXT record before routing on them. - Org-specific URL (
/login/acme) for shared domains like gmail.com or for contractors. - When
enforce_ssois true, block password and social login for users of that tenant, except one or two named break-glass admins with MFA.
6. Provisioning
| Approach | Creates users | Updates | Deprovisions | Use when |
|---|---|---|---|---|
| JIT (attributes in assertion) | On first login | On each login | Never, by itself | Default; every tenant |
| SCIM 2.0 (RFC 7643/7644) | Before first login | Pushed by IdP | Yes, on removal in IdP | Customers who require offboarding guarantees or pre-assigned seats |
JIT rules: map attributes per connection (attribute_map), update name and email on each
login, assign default_role on creation, and map IdP groups to roles only through an
explicit allowlist the tenant admin configured. Without SCIM, deactivated IdP users keep
any existing session: cap session lifetime (for example 8 to 24 hours) for SSO tenants so
removal in the IdP takes effect at next login. SCIM endpoints need their own bearer token
per tenant, and must handle active: false by revoking sessions.
Identity choice: prefer a persistent NameID or an immutable attribute (Entra object ID,
Okta user ID). Email changes and is reused; never use it as idp_subject unless the
customer’s IdP offers nothing else, and document that limitation.
7. Customer onboarding
- Tenant admin opens SSO settings; you show SP entity ID, ACS URL, and the metadata URL.
- Admin creates the app in their IdP and gives you IdP metadata (URL preferred, XML file accepted). Parse entity ID, SSO URL and signing certs from it; do not ask for them by hand unless metadata is unavailable.
- Admin runs “Test connection”: a login that does not create a session but shows the parsed attributes and any validation error in plain language.
- Only after a successful test can the admin turn on
enforce_sso.
8. Certificate rollover
- Store several signing certs per connection; accept any configured one.
- If a metadata URL exists, refresh it daily and alert on changes and on certs expiring within 30 days.
- Show cert expiry in the tenant admin UI; email the admin before it lapses.
Gotchas
- Load-balanced app with in-memory request ID cache: InResponseTo fails randomly.
SameSite=LaxorStrictsession cookie set before the IdP POSTs back: the cross-site POST to the ACS arrives without it, so InResponseTo lookup fails. Store the pending request in a dedicated short-lived cookie withSameSite=None; Secure, or key it server-side.- ACS behind a proxy: the library computes the URL as
http://internal:8080/...and the Recipient check fails. Configure the public callback URL explicitly. - Assertion encryption is not a substitute for signature checks; decrypted but unsigned assertions must still be rejected.
- XML comments in NameID (
[email protected]<!---->.example.com) truncated by some parsers: covered by current libraries; include it in the negative tests anyway. - Entra ID sends claim names as full URIs
(
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress); map them per connection rather than hard-coding Okta’s short names. - SLO is fragile across IdPs; logging out locally and revoking the app session is the reliable part. Do not block launch on SLO.
Verification
- Happy path end to end with a local Keycloak container and a hosted test IdP (mocksaml.com), plus one real customer-like IdP (an Okta developer org or Entra test tenant).
- Run the negative suite from references/validation-and-testing.md: tampered attribute, unsigned assertion, wrong audience, wrong recipient, expired, replayed, wrong InResponseTo, wrapped signature, cert not pinned, cross-tenant connection. Every case must fail with no session created.
- Confirm a tenant A identity cannot log into tenant B through B’s ACS URL.
- Use the SAML-tracer browser extension to inspect real responses when an IdP rejects or a check fails; never log full assertions in production (they carry PII).
Output contract
## SAML SSO: <app>
- Library and version: <[email protected]> (advisories checked: <date>)
- Endpoints: metadata, login, ACS (per connection); SLO: <yes/no>
- Validation: strict mode, signed assertion required, SHA-256, skew 180 s, InResponseTo always
(IdP-initiated: <off | on for tenants X>), replay cache in <store>
- Identity key: (connection_id, <NameID persistent | attribute>)
- Provisioning: JIT with attribute map; SCIM: <no | yes, endpoints>
- Enforcement: enforce_sso per tenant, break-glass admins: <list>
- Negative tests: <n>/<n> rejected (list)
- Open items: cert expiry alerting, SLO, SCIM Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/validation-and-testing.md
# SAML validation settings and negative tests
Read this when configuring a specific library or building the negative test suite.
Confirm option names against the installed library version; they change between majors.
## Library settings that matter
### node-saml (`@node-saml/node-saml`)
| Option | Default | Set to |
|---|---|---|
| `idpCert` | required | Array of pinned PEM certs for this connection |
| `audience` | `issuer` | Your SP entity ID; never `false` |
| `wantAssertionsSigned` | `true` | `true` |
| `wantAuthnResponseSigned` | `true` | `true` unless the IdP cannot sign the Response |
| `acceptedClockSkewMs` | `0` | `60_000` to `180_000`; never `-1` (disables time checks) |
| `validateInResponseTo` | `"never"` | `"always"` (SP-initiated only) or `"ifPresent"` (IdP-initiated allowed) |
| `requestIdExpirationPeriodMs` | 8 hours | About 10 minutes |
| `cacheProvider` | in-memory | Shared store (Redis) when more than one process |
| `maxAssertionAgeMs` | `0` (no limit) | A few minutes, especially with IdP-initiated |
| `signatureAlgorithm` | `"sha1"` | `"sha256"` |
Keep `xml-crypto` (transitive) at a patched release: run `npm ls xml-crypto` and check
it is >= 6.0.1 (or 3.2.1 / 2.1.6 on older lines).
### python3-saml
`settings.json` / `advanced_settings.json`:
```json
{
"strict": true,
"security": {
"authnRequestsSigned": false,
"wantAssertionsSigned": true,
"wantMessagesSigned": true,
"wantNameId": true,
"rejectDeprecatedAlgorithm": true,
"signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
"digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256"
}
}
```
InResponseTo: store `auth.get_last_request_id()` after `auth.login()`; after
`auth.process_response()` compare with `auth.get_last_response_in_response_to()`.
Replay: persist `auth.get_last_assertion_id()` until
`auth.get_last_assertion_not_on_or_after()` and reject repeats. Check
`auth.get_errors()` and `auth.is_authenticated()` before trusting attributes.
If the IdP cannot sign the whole Response, set `wantMessagesSigned` to `false` for that
connection only; never relax `wantAssertionsSigned`.
### Spring Security (`saml2Login()`)
- Build `RelyingPartyRegistration` per tenant (a `RelyingPartyRegistrationRepository`
backed by your table); `RelyingPartyRegistrations.fromMetadataLocation(url)` parses IdP
metadata.
- Use `{registrationId}` in the ACS path so the tenant is known up front.
- Customize the authentication provider's response and assertion validators for extra
rules (max assertion age, replay cache) rather than replacing them.
### ruby-saml
- Version 1.18.0 or later. Check `bundle exec gem list ruby-saml` and that
`omniauth-saml` resolves to it.
- `settings.security[:want_assertions_signed] = true` and `settings.soft = false` so
validation errors raise. When constructing `OneLogin::RubySaml::Response`, pass
`matches_request_id:` for SP-initiated responses and a small `allowed_clock_drift:`
(confirm option names against the installed version's README).
## Negative test suite
Build fixtures from a real signed response captured from the test IdP (Keycloak in a
container gives you a private key to sign arbitrary variants). Each case posts to the ACS
and must return an error with no session cookie set.
| # | Case | How to build it | Expected |
|---|---|---|---|
| 1 | Tampered attribute | Edit email in a signed assertion, keep signature | Signature invalid |
| 2 | Unsigned assertion | Strip `ds:Signature` from Assertion and Response | Rejected: signature required |
| 3 | Wrong cert | Sign with a key not in `idp_signing_certs` | Rejected |
| 4 | Embedded cert trust | Sign with attacker key and include its cert in `KeyInfo` | Rejected (cert not pinned) |
| 5 | Wrong audience | Sign assertion with Audience of another SP | Rejected |
| 6 | Wrong recipient | Recipient/Destination set to another ACS | Rejected |
| 7 | Expired | `NotOnOrAfter` in the past beyond skew | Rejected |
| 8 | Not yet valid | `NotBefore` in the future beyond skew | Rejected |
| 9 | Replay | Post the same valid response twice | Second rejected |
| 10 | Unknown InResponseTo | Valid response answering a request ID never issued | Rejected |
| 11 | Unsolicited when disabled | Valid response with no InResponseTo, IdP-initiated off | Rejected |
| 12 | Signature wrapping | Keep the signed assertion, add a second unsigned Assertion (or move the signed one under `Extensions`/`StatusDetail`) | Rejected; the unsigned identity is never used |
| 13 | Comment in NameID | `[email protected]<!---->.evil.test` re-signed by attacker key | Rejected (bad signature), or the full text is used, never the truncated one |
| 14 | DTD / entity | Add `<!DOCTYPE>` with an entity | Rejected before parsing entities |
| 15 | Cross-tenant | Valid response from tenant A's IdP posted to tenant B's ACS | Rejected: issuer, audience and cert mismatch |
| 16 | SHA-1 signature | Signed with rsa-sha1 | Rejected where policy requires SHA-256 |
| 17 | Non-success status | `StatusCode` Responder | Error page, no session |
| 18 | Open redirect | `RelayState=https://evil.test` | Ignored; redirect to default path |
Run the suite in CI against the ACS handler with fixed clocks (inject a clock so
time-based fixtures stay valid).
## Test IdPs
| IdP | Use |
|---|---|
| Keycloak in Docker (`quay.io/keycloak/keycloak`, `start-dev`) | Local and CI; full control of keys, attributes, NameID formats |
| mocksaml.com (BoxyHQ / Ory) | Quick hosted check; download its metadata, any email logs in |
| Okta developer org, Entra ID test tenant, Google Workspace test domain | Pre-launch check of real attribute names, signing behavior and IdP-initiated tiles |
## Reading a failing response
1. Capture it with SAML-tracer, or base64-decode the `SAMLResponse` form field
(HTTP-Redirect binding also needs raw DEFLATE inflate).
2. Check, in order: StatusCode, Issuer, Destination, Audience, Recipient, time window vs
server clock, InResponseTo, which element carries the signature, signing cert
fingerprint vs configured.
3. Most "invalid signature" errors on new connections are a stale or wrong cert pasted by
the admin; compare SHA-256 fingerprints: `openssl x509 -in idp.pem -noout -fingerprint -sha256`.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.