Security No. 283

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.

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

~/.claude/skills/saml-sso/SKILL.md

--- name: saml-sso 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… license: MIT ---

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 validateInResponseTo to "never", acceptedClockSkewMs to 0, and signatureAlgorithm to "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 KeyInfo unless 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?

SituationChoose
Customer’s IdP supports both and you have no SAML yetOIDC: JSON, simpler libraries, fewer XML attack classes (use oauth-oidc)
Enterprise procurement demands SAML, or IdP is ADFS / legacy on-premSAML
Many tenants with mixed IdPs, small teamA 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 providerIts 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:

StackLibrary
Node@node-saml/node-saml (or @node-saml/passport-saml)
Pythonpython3-saml (OneLogin/SAML-Toolkits) or pysaml2
Java / SpringSpring Security saml2Login() (OpenSAML underneath)
Rubyruby-saml >= 1.18.0, omniauth-saml pinned to a version that requires it
.NETSustainsys.Saml2 or ITfoxtec.Identity.Saml2
PHPonelogin/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:

EndpointPath exampleNotes
SP metadataGET /saml/{connectionId}/metadataEntity ID, ACS URL, NameID format, SP signing cert if you sign requests
LoginGET /saml/{connectionId}/loginBuilds AuthnRequest, stores its ID, redirects
ACSPOST /saml/{connectionId}/acsHTTP-POST binding only; CSRF middleware must exempt it, so validation carries the load
SLO/saml/{connectionId}/sloOptional; 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):

CheckRule
SignatureResponse 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
StructureExactly one Assertion; reject DTDs and external entities; cap body size (for example 256 KB)
IssuerEquals the connection’s idp_entity_id
AudienceAudienceRestriction contains your SP entity ID for this connection
Recipient / DestinationSubjectConfirmationData/@Recipient and Response Destination equal this ACS URL exactly
TimeNotBefore <= now < NotOnOrAfter on Conditions and SubjectConfirmationData, skew allowance at most 3 minutes
InResponseToFor SP-initiated logins, equals a pending request ID from this browser session; delete it on use
ReplayAssertion ID not seen before; store it until NotOnOrAfter
StatusTop-level StatusCode is Success; surface others as a readable error
SubjectSubjectConfirmation 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

FlowStartRiskPolicy
SP-initiatedUser enters email or org slug in your appLow; InResponseTo binds response to the sessionDefault for every tenant
IdP-initiatedUser clicks a tile in Okta/Entra dashboardUnsolicited response, no InResponseTo: login CSRF and injected-assertion riskOff 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_sso is true, block password and social login for users of that tenant, except one or two named break-glass admins with MFA.

6. Provisioning

ApproachCreates usersUpdatesDeprovisionsUse when
JIT (attributes in assertion)On first loginOn each loginNever, by itselfDefault; every tenant
SCIM 2.0 (RFC 7643/7644)Before first loginPushed by IdPYes, on removal in IdPCustomers 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

  1. Tenant admin opens SSO settings; you show SP entity ID, ACS URL, and the metadata URL.
  2. 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.
  3. Admin runs “Test connection”: a login that does not create a session but shows the parsed attributes and any validation error in plain language.
  4. 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=Lax or Strict session 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 with SameSite=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

  1. 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).
  2. 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.
  3. Confirm a tenant A identity cannot log into tenant B through B’s ACS URL.
  4. 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 118 lines · 6.1 KB
# 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.

esc