Skip to content

Identity chaining (ID-JAG)

When a user has already authenticated at an identity provider your organization trusts, that provider can assert them to us directly and your service can redeem the assertion for an access token. No redirect, no consent screen, no second sign-in — the authentication already happened upstream.

This is the Identity Assertion JWT Authorization Grant, redeemed under the RFC 7523 jwt-bearer grant.

Use it when the user is already authenticated somewhere else and you need a token here without interrupting them. If the user is present in a browser, use authorization code + PKCE instead — it is the stronger flow, because the user sees and approves what is happening.

Identity chainingAuthorization code
User presentNoYes
Consent screenNoYes
Who vouchesThe trusted IdPThe user, directly
Creates usersNeverOn first sign-in, per policy

Nothing is accepted from an issuer the organization has not named. Requires sso:write:

POST /v1/organizations/org_01J…/trusted-issuers
Authorization: Bearer sk_live_…
{
"issuer": "https://idp.acme.com",
"name": "Acme corporate IdP",
"jwks_uri": "https://idp.acme.com/.well-known/jwks.json",
"allowed_domains": ["acme.com"],
"max_auth_age": 3600
}
{
"id": "ti_8f3b2c…",
"issuer": "https://idp.acme.com",
"name": "Acme corporate IdP",
"jwks_uri": "https://idp.acme.com/.well-known/jwks.json",
"status": "active",
"allowed_domains": ["acme.com"],
"allowed_scopes": [],
"max_auth_age": 3600
}
  • issuer is matched against the assertion’s iss exactly, and must be https.
  • allowed_domains bounds which email domains this provider may assert. Empty means any domain — the subject still has to be an existing user in the org.
  • allowed_scopes caps what is reachable through this provider. Empty means the client’s own scopes are the only limit.
  • max_auth_age rejects an assertion whose auth_time is older than that many seconds. Omit it and freshness is bounded only by the assertion’s own exp.

GET the same path to list, DELETE /v1/organizations/:orgId/trusted-issuers/:id to remove one. Setting status to inactive stops verification without losing the configuration.

This is security-critical configuration. A compromised issuer here compromises every user it is allowed to assert. It is deliberately separate from SSO connections: an SSO connection lets an IdP authenticate a human in a visible browser redirect, while a row here lets it mint tokens silently. An org may reasonably want the first without the second.

POST /token
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&assertion=<the ID-JAG>
&resource=https://tools.acme.com/mcp
{
"access_token": "eyJ…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile"
}

The token’s sub is the local user id, not the IdP’s — the assertion names a person, and what comes back is that person as this platform knows them.

Accepted on a tenant issuer only, e.g. https://acme.oauth.work/token. The trust list and the user both belong to an organization, and the apex has neither.

The IdP mints it by token exchange, requesting urn:ietf:params:oauth:token-type:id-jag.

ClaimRequirement
typ headeroauth-id-jag+jwt. An ID token or access token presented here is refused on this alone.
issExactly the trusted issuer.
audThis tenant’s issuer.
client_idYour client id here. An assertion minted for another client is not redeemable by you.
emailA user who already exists in this org, in an allowed domain.
jtiFresh — assertions are single-use.
exp, iatRequired. 60s of clock skew is tolerated.
auth_timeRequired when the provider has a max_auth_age; absent then, it fails.

Optional scope and resource claims narrow the grant further.

Three limits are worth stating plainly, because each is a thing operators expect and do not get:

  1. It never creates users. An assertion naming someone the org does not have is refused. JIT provisioning would let one compromised provider populate a tenant with users of its choosing; use SCIM or the management API to create people deliberately.
  2. It never reaches a deprovisioned user. SCIM flipping scim_active is how an org offboards someone, and a federated assertion does not route around it.
  3. It never widens authority. The granted scope is the intersection of what you request, what the assertion carries, what your client is allowed, and what the org permits that provider.

Confidential clients only. A public client authenticates by presenting its client id, which is not a secret — and with a public client the client_id binding in the assertion proves nothing, so a leaked assertion would be redeemable by anyone who read it.

Everything wrong with an assertion is invalid_grant, and the description does not say which check failed. That is deliberate: distinguishable errors would let a caller probe which issuers a tenant trusts and which people it employs.

{ "error": "invalid_grant", "error_description": "assertion does not name a known user" }

invalid_client (401) means client authentication failed, or the client is public. invalid_scope means nothing survived the intersection above.

Every redemption writes a token_issued audit event carrying the asserting issuer and the assertion’s subject, so a chained token can be traced back to the provider that vouched for it.

{
"grant_types_supported": ["", "urn:ietf:params:oauth:grant-type:jwt-bearer"],
"authorization_grant_profiles_supported": ["urn:ietf:params:oauth:grant-profile:id-jag"]
}

The profile is advertised separately from the grant type on purpose: RFC 7523 assertions in general are not accepted here, only assertions carrying typ: oauth-id-jag+jwt from a trusted issuer.