Documentation
Documentation
Bring Your Own Resource (SAML)
Your app is the resource server in the Cross App Access flow. It receives access tokens from requesting apps, validates them against an Authorization Server's JWKS, and serves protected data, either as a REST API or an MCP server.
When the requesting app federates its users with SAML 2.0 instead of OIDC, your resource server's job is almost unchanged. You never see a SAML assertion, never parse XML, and never talk to the SAML IdP. What changes is how the token identifies the user.
The SAML assertion is consumed upstream by the Identity Provider, which converts it into an ID-JAG. By the time anything reaches you it is an ordinary OAuth access token. The only difference is the shape of its subject claim.
Where your app fits
The SAML path has five steps rather than four. The extra one is the second token exchange at the IdP (see SAML Overview for the requesting app's side).
Steps 1–3 always happen upstream at the customer's IdP; nothing of yours is involved. Step 4 belongs to whichever Authorization Server issues the access token: the playground's, or yours if you run your own (Own Auth Server). Your resource server participates only in Step 5: validate the access token, resolve the user, return data.
Two subject claims, not one
On the SAML path the subject is carried in an extra structured claim, sub_id. Which token you are looking at matters, because sub means something different in each:
| Token | Who issues it | sub | sub_id |
|---|---|---|---|
| ID-JAG | The customer's IdP | The bare SAML NameID | ✅ present |
| Access token | The Authorization Server | An opaque, AS-local user id. With the playground AS, {provider}:saml-user-<uuid> | ✅ forwarded unchanged |
sub is never missing, so nothing looks broken, but it is the wrong key either way. On the ID-JAG it is the raw NameID, unique only within its issuer (see below). On the access token it is opaque and AS-local: collision-free, but carrying no enterprise identity and not stable. The playground regenerates it per process, so users persisted against it are orphaned on restart.
sub_id means the same thing on both tokens, and is the only claim that identifies the actual enterprise user.
The sub_id object
The structured subject, per §3.2.1. Identical on the ID-JAG and on the access token that follows it:
Always present: format (the literal saml-nameid), issuer (the SAML IdP's entity ID), nameid.
Only if the assertion carried it: nameid_format, name_qualifier, sp_name_qualifier, sp_provided_id. The IdP omits what the assertion did not have, so never assume one of these exists, including nameid_format.
Why sub alone silently collides
A SAML NameID is unique only within its issuer, and when it is SP-scoped, only within a given sp_name_qualifier as well. Nothing stops two unrelated customer organizations from each federating a user whose NameID is alex.chen@example.com.
Resolve on the NameID alone and both organizations' requests land on the same record. There is no signature failure, no invalid_grant, no 403. Just one tenant reading another tenant's data.
A single issuer can also reuse a NameID under a different sp_name_qualifier, which is why the qualifier belongs in the key whenever it is present.
The correct resolution key
Resolve the user on the composite identity, per §3.2.2:
Model it as a composite unique constraint rather than a concatenated string, so the database enforces the invariant for you:
NULLS NOT DISTINCT needs PostgreSQL 15+. Elsewhere NULL is not equal to itself, so a unique index over a nullable sp_name_qualifier will not stop duplicate rows. Either store the absent qualifier as a consistent sentinel, or add a partial unique index for the IS NULL case. Apply the same rule on read (details).
email is conditional
The IdP sets email only when the NameID is an email address, meaning nameid_format is absent or names an email format. A persistent or transient NameID arrives with no email claim. Use it when present, fall back to nameid, and never key on it.
What you'll set up
Choose how the access token gets issued, then configure your server to validate it:
| Step | Playground Auth (recommended) | Own Auth Server |
|---|---|---|
| 1–3. SAML SSO + token exchanges | Playground IdP | Playground IdP |
| 4. JWT Bearer Grant | Playground Auth Server (https://auth.resource.xaa.dev) | Your Auth Server |
| 5. API Call | Your Resource Server | Your Resource Server |
Playground Auth Server (recommended)
Your resource server validates tokens issued by the playground's Authorization Server, which resolves the SAML identity for you and forwards sub_id into the access token.
Best for: verifying your token validation and user mapping without running your own auth server.
Own Auth Server
The ID-JAG is addressed to your authorization server, which must validate it, cross-check the SAML identity, and issue its own access token. This is where most of the SAML-specific work lives.
See Own Auth Server for the full validation order and the security reasoning behind it.
Where to go next
Your resource server needs everything the OIDC path requires (bearer tokens, JWKS signature validation, iss/aud/exp checks, scope enforcement, a /health endpoint, CORS), plus resolving the user from sub_id:
- REST API Setup: the resolution code, and the SQL pitfalls that duplicate users
- MCP Server Setup: the same, scoped per tool call
- Own Auth Server: if you issue the access tokens yourself
- Testing Guide: run it end to end against your own SAML IdP
For IdP-side configuration (creating the SAML app, assigning users, registering the agent), see Enabling Cross App Access for SAML-Based Resource Apps.
On this page