Menu

Developer documentation

Agent auth — short-lived, audience-bound tokens

/docs/agent-auth

Give an agent a token that expires in minutes, names the API it may be presented to, and dies the moment you revoke it — instead of a bearer good everywhere for an hour.

Built 2026-08-15..21. The fleet deployment from 2026-08-19 predates client adoption and the CIMD authorization-code flow; those capabilities were not deployed as of 2026-08-21. Read §7 What is not built yet first — what remains open there is narrower than it was.

The code flow itself, client registration and the endpoint list live in Signet as an OAuth provider. Nothing here repeats them.


1. Three roads

Road one, any build. Your agent runs an ordinary OAuth flow and adds RFC 8707 resource, naming the API the token is for. Signet records that on the token, enforces it at introspection and at the MCP session doors, and lets you cut the token's life down to one agent turn.

Road two, external-jwt builds. The customer's own identity provider decides that this agent may reach this resource on this user's behalf, and issues an ID-JAG (Identity Assertion JWT Authorization Grant — the MCP Enterprise-Managed Agents profile). The agent presents it at /oauth2/token and gets a Signet token back. Nobody provisions a per-tenant credential out of band, because the assertion carries the decision. The ID-JAG's resource claim lands as the same binding road one writes, through the same canonicalizer and the same token writer.

Road three, external-jwt builds. The agent has no registration here at all. It identifies itself by an https URL it publishes a metadata document at — a Client ID Metadata Document, the mechanism MCP 2026-07-28 adopted in place of Dynamic Client Registration — sends your user through /oauth2/authorize, and redeems the resulting code at /oauth2/token. This is how an interactive MCP client arrives: nobody registers it, nobody issues it a secret, and the URL is its identity. §5 is the whole road.

2. Which build you are running

cargo build -p signet --release --features external-jwt

external-jwt is pure Rust (jsonwebtoken) — no C library, no change to your build host, and not a default feature. ⚠ enterprise-sso depends on external-jwt, never the reverse: the enterprise road pulls in no SAML, no samael, no system xmlsec1 link profile, while a SAML build gets the enterprise road too.

The two builds publish different documents, and the condition is cfg rather than config — no setting talks a default build into advertising any of it:

discovery memberdefault buildexternal-jwt build
grant_types_supportedno jwt-bearer URNadds urn:ietf:params:oauth:grant-type:jwt-bearer
authorization_grant_profiles_supportedabsent["urn:ietf:params:oauth:grant-profile:id-jag"]
token_endpoint_auth_methods_supportedclient_secret_basic, client_secret_postthose plus private_key_jwt
client_id_metadata_document_supportedabsenttrue
the jwt-bearer URN at /oauth2/tokenunsupported_grant_typeserves the grant
registering token_endpoint_auth_method: private_key_jwtrefusedaccepted, with one key source

Both documents — /.well-known/oauth-authorization-server and /.well-known/openid-configuration — answer alike in both build configurations.

3. Road one — audience-bound tokens on the code flow

Send RFC 8707 resource at /oauth2/authorize or /mcp/authorize, once per target:

GET {base_path}/oauth2/authorize?response_type=code&client_id=…&redirect_uri=…
    &code_challenge=…&code_challenge_method=S256
    &resource=https%3A%2F%2Fmcp.example.com%2F
    &resource=https%3A%2F%2Fcrm.example.com%2F
  • Eight canonical absolute URIs per request at most; the ninth and any duplicate refuse (more_than_eight_resources_are_refused_at_authorize).
  • resource is the only parameter that may repeat. A repeated state, scope, client_id or redirect_uri draws a 400 naming the field (only_resource_may_repeat_on_an_authorization_request).
  • An uncanonical value refuses at authorize with invalid_target, delivered to the registered redirect_uri, minting no code (authorize_refuses_an_uncanonical_resource_and_mints_no_code).
  • The whole set survives the login detour and the consent round-trip (a_parked_authorization_keeps_all_of_its_resources, resources_survive_the_consent_round_trip), and a refresh copies it from the row it rotates rather than the request, so it can neither widen nor invent a target (a_refreshed_access_token_keeps_the_resource_it_was_authorized_for).

Cut the token down to an agent turn. One key moves the OAuth and MCP doors together:

[oauth]
access_ttl_seconds = 900     # default 3600, accepted range 300–3600

Outside that range the instance refuses at boot and names the range. 3001800 — five to thirty minutes — is the agent profile: a leaked bearer stays useful for minutes rather than an hour, and it costs one refresh round-trip per interval for clients holding offline_access (a_configured_access_ttl_shortens_the_issued_token, the_mcp_access_ttl_follows_the_configured_value).

The binding is enforced, not merely recorded.

doortoken bound elsewhere
POST /oauth2/introspect with resource=…400 {"error":"invalid_target"} (certified_introspection_enforces_the_expected_resource)
POST /tokens/introspect with an expected resource403 TOKEN_RESOURCE_MISMATCH
GET /mcp/get-session, GET /mcp/userinfo200 with null — no session resolves

An unbound token stays unrestricted, so a client that never sent a resource sees no change. But a caller naming an expected resource gets TOKEN_RESOURCE_UNBOUND rather than the token as unconfined (certified_introspection_refuses_an_unbound_token_at_an_expected_resource).

⚠ No resource_indicators_supported appears in the discovery documents, deliberately: RFC 8707 defines no such metadata parameter. An MCP client learns the identifier to send from the resource member of /.well-known/oauth-protected-resource — this server's origin. The sibling authorization_servers entry carries the base path, and that is what an enterprise IdP wants.

Revocation reaches the whole access family. POST /oauth2/revoke once hashed the presented token into the oauth2-access: namespace alone, so an MCP access token drew a 200 while nothing moved. The search now covers mcp-access: too, and a revoked MCP token is gone (revoking_an_mcp_access_token_kills_it). The ownership fence did not widen, and the wire stays uninformative (another_client_cannot_revoke_an_mcp_access_token_and_is_told_nothing). /oauth2/introspect and /oauth2/userinfo walk the same family, so one truth about a token holds at every door.

4. Road two — the enterprise road, step by step

4.1 Register the identity provider

Trust is configured, never discovered. The grant takes its key source from an ssoProvider row under the client's organization. It never reads jku, x5u or x5c from the assertion header, and never fetches what they name (a_jku_header_is_never_fetched). The row carries providerId, issuer, organizationId, and an oidcConfig — a JSON string — naming jwksEndpoint:

{"jwksEndpoint": "https://acme.okta.com/oauth2/v1/keys"}

Leave jwksEndpoint out and it hydrates from the issuer's /.well-known/openid-configuration at redemption time, behind the same SSRF fence registration applies to a typed URL (a_provider_with_no_jwks_endpoint_hydrates_from_discovery). issuer matches by RFC 3986 Simple String Comparison — equality, not normalisation. The IdP's key may be RSA (RS256/RS384/RS512), EC (P-256ES256, P-384ES384) or OKP (EdDSA); the JWK's key type decides the accepted algorithm, never the assertion's own header, so an HMAC alg pointed at a public JWKS dies before verification.

POST /sso/register is an enterprise-sso route. An external-jwt-only build does not mount it, so that build has no HTTP surface that writes the row — build enterprise-sso if you need one. Both EMA suites seed the row directly for exactly this reason.

4.2 Register the client

A confidential client, inside the organization whose IdP issues the assertion, registered for the grant. /oauth2/create-client binds the client to the organization selected on the session cookie:

curl -X POST "$BASE/api/auth/oauth2/create-client" \
  -H 'content-type: application/json' \
  -H "cookie: $SESSION_COOKIE" \
  -d '{
        "redirect_uris": ["https://agent.acme.example/callback"],
        "grant_types": ["authorization_code",
                        "urn:ietf:params:oauth:grant-type:jwt-bearer"],
        "token_endpoint_auth_method": "private_key_jwt",
        "jwks": {"keys": [{"kty":"OKP","crv":"Ed25519","kid":"client-key-1","use":"sig","x":"…"}]}
      }'
  • The grant must sit on the row. client_allows_grant defaults closed, so a client registered before you added the URN draws unauthorized_client — "register it for urn:ietf:params:oauth:grant-type:jwt-bearer before redeeming an assertion" (a_client_not_registered_for_the_grant_is_refused). /oauth2/update-client takes the same field.
  • private_key_jwt or a client secret. A private_key_jwt client gets no client_secret: its credential is a signature, and a shared secret beside the key would be a second, weaker way in that nothing rotates. Migration 0080 adds jwks (by value) and jwksUri (by reference) — send exactly one. Neither, both, or a non-object jwks draws a named 400, and the one-source rule holds over the merged row on update (private_key_jwt_registration_requires_exactly_one_key_source, swapping_one_key_source_for_the_other_is_judged_on_the_merged_row). An inline jwks costs no outbound request at token time (an_inline_client_key_set_costs_no_outbound_request).

4.3 What the ID-JAG must carry

header   {"alg":"EdDSA","typ":"oauth-id-jag+jwt","kid":"idp-key"}
claims   {"iss":"https://acme.okta.com",          ← the registered provider's issuer, exactly
          "aud":"https://auth.acme.com/api/auth", ← this server's ISSUER IDENTIFIER, and only it
          "sub":"idp-subject-1",                  ← a user that already exists here
          "client_id":"…",                        ← the client authenticating this request
          "jti":"…", "iat":…, "exp":…,
          "scope":"openid profile",
          "resource":"https://mcp.example.com/"}  ← REQUIRED here, though ID-JAG marks it optional
  • typ must read oauth-id-jag+jwt. An ordinary IdP ID token is not a grant, and it fails on typ and on aud independently (an_ordinary_id_token_is_refused_on_aud_and_on_typ_independently).
  • aud must be exactly this server's issuer identifier — one string, or an array of one. An array of two with one element correct refuses, where the JWT library alone would accept it (an_aud_array_of_two_is_refused_even_when_one_element_is_correct).
  • exp carries 60 s of skew and must fall within an hour; nbf binds when present; jti and iat are required (an_assertion_that_never_practically_expires_is_refused, a_missing_jti_or_iat_is_refused).
  • client_id must name the authenticated client, so one client's decision is not redeemable by another that holds the assertion (an_assertion_naming_another_client_is_refused).
  • scope narrows to the client's registered scopes, always minus offline_access. An absent scope claim grants nothing — never the client's defaults (scope_is_the_intersection_and_an_absent_claim_grants_none).

resource is required here where the ID-JAG draft marks it optional, and that divergence has never been observed to matter. A real ID-JAG, minted 2026-08-20 by Okta's public Cross-App Access sandbox (idp.xaa.dev) against this server, carried "resource":"https://mcp.example.com/" without being asked to — the emitter sends it by default. So the refusal below guards against a hypothetical emitter rather than a known interoperability gap. It stays required: a token this server cannot bind to a target is a token it cannot confine, and no emitter has yet asked us to accept one.

Redeem it:

curl -X POST "$BASE/api/auth/oauth2/token" \
  -d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \
  -d assertion="$ID_JAG" \
  -d client_id="$CLIENT_ID" \
  -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
  -d client_assertion="$CLIENT_ASSERTION"

The client assertion (RFC 7523 §2.2) carries iss and sub both equal to the client_id, a jti, an exp inside an hour, and an aud naming either this server's issuer identifier or its token endpoint URL. The RFC admits both spellings and both authenticate (both_audience_spellings_the_rfc_admits_authenticate). A client holding a secret uses HTTP Basic instead. Sending a secret and an assertion in one request refuses.

4.4 What comes back

{"access_token":"sig_at_…","token_type":"Bearer","expires_in":300,
 "expires_at":…,"scope":"openid profile","resource":["https://mcp.example.com/"]}
  • No refresh token, ever. The response body carries no such key — not a flag that happens to read false. Re-submitting the same assertion is the refresh mechanism, and it mints a fresh token every time (the_same_assertion_can_be_redeemed_again_for_a_fresh_token).
  • The token never outlives the assertion. Its life is min(access_ttl_seconds, exp − now), measured against the stored row rather than the response (the_token_never_outlives_the_assertion_that_authorized_it).
  • It is an ordinary oauth2-access credential. It introspects live at the resource the enterprise named and answers invalid_target anywhere else. ⚠ It does not authenticate at /mcp/get-session or /mcp/userinfo: those doors read the mcp-access family, the lookup misses, and they answer their documented null (an_ema_token_does_not_authenticate_at_the_mcp_session_doors).

4a. Adopting a self-published client

A client that identified itself by publishing a metadata document belongs to nobody, so it cannot speak for a tenant until an organization says it may. Client adoption has been available since 2026-08-20. It exists only in --features external-jwt builds, like the identity it governs.

An owner or admin adopts the client — an allow-list entry keyed on the exact URL, not a registration. The client stays self-described; the organization supplies only the tenant binding:

curl -X POST "https://auth.example.com/api/auth/oauth2/adopt-client" \
  -H "content-type: application/json" -b "$SESSION_COOKIE" \
  -d '{"organizationId":"org_123","clientId":"https://agent.example.com/client.json"}'

Signet fetches the document, refuses it if it declares no key set (a public client cannot redeem an enterprise assertion at all), and records the RFC 7638 thumbprints of the keys it publishes today. From then on that client resolves this organization's tenant.

If those keys ever change, every redemption refuses until an owner or admin calls the same route again. That is key-change detection, not trust-on-first-use: the server never silently accepts a key nobody approved. The cost is real and worth stating — a client that rotates its keys has to be re-confirmed by each organization that adopted it. Relabelling a key (kid, use, alg) is not a change: RFC 7638 digests only the required members.

POST /oauth2/adopt-clientadopt, or re-confirm after a key change. Owner or admin.
POST /oauth2/revoke-client-adoptionwithdraw. The row survives, marked revoked, so it still records who authorized the agent and when.
GET /oauth2/client-adoptions?organizationId=…what this organization holds, thumbprints included.

Several organizations may adopt the same public agent, which is the ordinary case rather than an edge one — so an adoption never denies the agent to anyone else. When more than one has adopted the client in play, the tenant is the organization that registered the assertion's issuer and in which the assertion's subject is already a member. If that is not exactly one organization, the redemption refuses rather than choosing.

4b. Requiring adoption for interactive sign-in

An organization can opt in to requiring adoption for document-identified agents on /oauth2/authorize, /mcp/authorize and device approval. Default off: an organization that does nothing keeps the existing consent flow. An owner or admin sets the policy with a session and matching Origin, in an external-jwt build:

curl -X POST "https://auth.example.com/api/auth/oauth2/agent-admission-policy" \
  -H "content-type: application/json" -H "Origin: https://auth.example.com" \
  -b "$SESSION_COOKIE" \
  -d '{"organizationId":"org_123","requireAdoption":true}'

GET /oauth2/agent-admission-policy?organizationId=org_123 reads it with the same session, Origin and owner/admin authority. An absent row reads requireAdoption: false and updatedAt: null. Set requireAdoption: false to turn it off. Every set writes a mandatory audit receipt in the same transaction as the policy.

Every organization the user is a member of gets a veto. Invitations do not count, and switching the session's active organization does not avoid the rule. Staff cannot approve an un-adopted agent: a user in two organizations, one strict, is bound by the strict one everywhere. The member's escape is leaving that organization. There are no per-user or per-client switches. Each strict organization must hold an active adoption with exactly the current key thumbprints; its owner or admin uses POST /oauth2/adopt-client to adopt or re-confirm changed keys. Revoked adoptions refuse too.

The check runs before consent, including previously stored consent, and again inside both token doors' code-redemption and refresh transactions, and device redemption. A code banked before the switch cannot redeem; a refused refresh tears down its family as reuse detection does. Concurrency contract: a policy takes effect for every mint that starts after the policy write commits; a mint already in flight at the instant it commits may complete, and that token is refused at its next refresh or code redemption. No mint waits for an in-flight policy write. Switching the policy on does not kill outstanding access tokens: they run to expiry unless separately revoked (including the family teardown when refresh is refused). Clients receive only access denied by organization policy; organization membership, the failed condition and the corrective action stay in server logs. A document client can distinguish access_denied from a consent page and thus learn that this user belongs to some strict organization; no organization is named.

Limitations: strict mode admits key-bearing agents only. A public client with no key set cannot be adopted today. Key-rotation detection lags by the CIMD cache TTL (one minute to one hour); remote jwks_uri keys additionally depend on the live JWKS cache. Human authorization doors fetch cold remote keys; fetch failures return server_error with a retry-later message. Mint transactions use cached keys only and refuse an unavailable key set in strict mode. An unregistered device client without a URL and key set cannot be adopted: register the client, or publish a metadata document and adopt it. Adoption pins keys, not redirect URIs or token_endpoint_auth_method; a host takeover that preserves the JWKS while changing those fields is not detected by this switch. Registered clients are exempt. A member who can register a client at the instance is therefore outside this policy; /oauth2/create-client requires a live session and matching Origin, not an administrator role. There is still no deny-list or instance-wide block. Public-client adoption, broader metadata pins and the registration exemption remain follow-up items.

5. Road three — a client that IS a URL

This flow is available only in external-jwt builds, like everything else CIMD.

Your client publishes one JSON document at an https URL and uses that URL as its client_id. There is no registration call, no client secret, and no operator step.

{
  "client_id": "https://client.example.com/metadata.json",
  "client_name": "Example MCP Client",
  "redirect_uris": ["https://client.example.com/callback"],
  "grant_types": ["authorization_code"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}

Four rules the document must satisfy, each refused by name in the server log:

  • It is served at exactly the URL it names. client_id inside the document must byte-match the URL it is fetched from (CIMD draft §4.1), and the URL must be the canonical spelling of itself — https, a path, no fragment, no userinfo, no query, no dot segments, no redirect.
  • The host is publicly routable. Loopback, private ranges, link-local and the cloud-metadata addresses are refused before any socket opens, and a public name that resolves to one of those is refused at connect time.
  • No shared secret, ever. client_secret and the client_secret_* auth methods are refused. Declare none (a public client) or private_key_jwt with one key source.
  • It is at most 5 KB, served as JSON, and cached for between one minute and one hour according to its own Cache-Control.

Then run the ordinary code flow:

GET /api/auth/oauth2/authorize
  ?response_type=code
  &client_id=https%3A%2F%2Fclient.example.com%2Fmetadata.json
  &redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
  &scope=openid
  &code_challenge=<S256 challenge>&code_challenge_method=S256

redirect_uri must byte-match one the document declares — the MCP specification makes that a MUST for authorization servers, and Signet applies the same fence to a document's list that it applies to a registered client's column. PKCE with code_challenge_method=S256 is required: a document client is public unless it declares private_key_jwt, so the challenge is the only thing binding the code to the caller that asked for it. plain is refused at both front doors.

Your user must then approve a consent screen, and no code is issued before they do. The screen shows both the name the document claims and the URL identity — because the name is chosen by whoever published the document and the URL is not. This holds on both doors, and for a document-identified client it cannot be skipped: skip_consent is a stored column that only a registered client can have, and a published document can never write one.

Redeem the code at /oauth2/token exactly as any public client does, sending code_verifier. A client whose document declares private_key_jwt authenticates there with a client_assertion instead, over a key its own document publishes.

/mcp/authorize/mcp/token serves the same road for a client that prefers the MCP-plugin pair, with the same document, the same redirect_uri fence, the same S256 requirement and the same consent step. Pick one pair and stay on it: the two doors mint codes into separate namespaces, so a code from one cannot be redeemed at the other. (The consent screen itself is shared — the approval posts to /oauth2/consent whichever door asked, and the request resumes at the door it came from.)

Refresh tokens follow the same rule as every other code-flow client: request and consent to offline_access, and the client's declared grant_types must include authorization_code (which refresh_token rides along with). No new policy applies to document clients.

What road three does not do by itself: give the client a tenant. A published document establishes an identity, never an organization, so a client on this road cannot redeem an ID-JAG until an organization adopts it — §4a is that step. Until then the enterprise grant refuses by name; the interactive road above works regardless, because it takes its tenant from the signed-in user rather than from the client.

6. What this refuses on purpose

Each refusal below is covered by a regression test. The wire answer stays coarse deliberately — a caller who can tell the checks apart holds a validator oracle, and ID-JAG §9.4 forbids disclosing which issuers a customer registered. The server log names the check that refused, and every one of those lines states the limit and the corrective action.

whatanswerwhat to do
a CIMD client (an https URL as client_id) no organization has adopted401 invalid_client, "no organization … has adopted the client identity"an owner or admin adopts it — see §4a
an adopted client whose published keys have changed401 invalid_client, "not the keys its adoption recorded"an owner or admin re-confirms the adoption at the same route
a public client, CIMD or registered401 invalid_client, "a public client cannot redeem a jwt-bearer assertion"register a confidential client (ID-JAG §9.1)
a subject with no local user, or no membership in that organization400 invalid_grantprovision the user first — this grant does no just-in-time provisioning
an assertion carrying no resource400 invalid_grantconfigure the IdP to send the RFC 8707 target the assertion authorizes
a plaintext JWKS endpoint outside trusted_origins400 invalid_grant, before the request leavesserve the JWKS over TLS, or list the internal origin in trusted_origins
aud naming more than one audience400 invalid_grantaudience the assertion at this server's issuer identifier alone

Four more refuse for reasons worth knowing: an issuer registered twice in one organization (no key source gets chosen — delete the duplicate), an assertion whose iss is this server, an unknown form member, and a CIMD URL that is http, loopback, private-network, cloud-metadata, path-less or non-canonical. That last one refuses before any socket opens and reads exactly like "no such client", because telling the two apart maps what this server will fetch. A registered client also always wins over a document published at the same URL — at the token endpoint and at /oauth2/authorize and /mcp/authorize too, where the same URL fence runs before any fetch (a_registered_row_wins_over_any_document_at_the_same_url, a_registered_row_wins_at_the_authorization_endpoint_too, a_fenced_url_client_id_is_refused_at_the_authorization_endpoints_before_any_fetch).

7. What is not built yet

  • Client adoption is built. A self-published client now reaches a token through an adoption — see §4a. All three authentication arms of the arc's done-when are green. Reading the organization from the assertion's iss stayed rejected for the reason it always was (ssoProvider has no uniqueness on issuer, so an iss-keyed lookup either picks a row or hands one tenant a denial of service against another); the organization is configured, not inferred.
  • A CIMD client cannot run the authorization-code flow on the deployed version described here. CIMD is honoured at the token endpoint's client-authentication ladder and nowhere else, so a URL client_id at /oauth2/authorize or /mcp/authorize is still an unknown client. That flow needs redirect_uri validated against the fetched document and a consent screen that names the document's client_name without rendering attacker-supplied metadata as if it were registered.
  • There is no UNIQUE (organizationId, issuer) constraint on ssoProvider. The code refuses the plural case today. The constraint waits on a read-only survey of live rows across every instance, because a migration that aborts a boot is an outage, and the code refusal stays after it lands.
  • There is no real-Okta tenant verification. One real ID-JAG from Okta's public Cross-App Access sandbox was redeemed against a live instance on 2026-08-20, and it found zero protocol mismatches: typ, single-string aud, client_id, scope and resource all arrived in the shape §4.3 describes, and the assertion passed every §5a check. What that capture cannot settle is what only a provisioned tenant emits — whether a production Okta org spells typ or the vendor URN differently, whether client_id is configurable rather than fixed by the sandbox, and what a real private_key_jwt JWKS document looks like. Those remain open on tenant access.
  • The capture's real finding is fixed but not yet deployed. The redemption above passed every check, minted its token, and then rolled back: the mandatory audit write named a ceremony no migration had added, so the database refused it on every Postgres instance. Migration 0082 adds it, and a schema gate now fails when the Rust and SQL ceremony vocabularies diverge. Until a fleet carrying 0082 is serving, no ID-JAG redemption completes.
  • Deployment is no longer the gap. This is on main, fleet-deployed 2026-08-19. All three known live instances advertise urn:ietf:params:oauth:grant-type:jwt-bearer in /api/auth/.well-known/oauth-authorization-server, read on the wire 2026-08-20. The gaps above, rather than deployment of the grant itself, are what remain. Client adoption and its schema migration were not on a live instance as of 2026-08-20.

Enter to open · Esc to close