When to use XAA
Use XAA when all of the following are true:- You already run an OIDC identity provider (Okta, Entra ID, Auth0, etc.) that can mint ID-JAG tokens via RFC 8693 token exchange.
- You have a user-facing app (SPA, native, or web) where users are already signed in.
- You want a backend service you control to obtain a Serval access token on that user’s behalf, without bouncing the user through a second browser login.
How it works
- The user authenticates in your SPA through your identity provider.
- Your SPA hands the user’s
id_token(or access token) to a backend service you control — the BFF. - The BFF calls your IdP’s token endpoint with
grant_type=urn:ietf:params:oauth:grant-type:token-exchange, authenticating as the BFF’s confidential client. It asks for an ID-JAG audienced to Serval’s resource app. - The IdP validates the user’s session, validates the cross-app-access grant, and mints an ID-JAG JWT.
- The BFF forwards the ID-JAG to Serval’s token endpoint (
/oauth/token) along with Serval-issued XAA client credentials. Serval validates the JWT, resolves it to a user, and returns an access token.
Why do you need a BFF? Token exchange at Okta (and equivalent IdPs) requires a confidential client — one that can authenticate with a client secret. SPAs are public clients and cannot hold secrets, so a small server-side component must perform the exchange hop. Same reason the industry converged on the BFF pattern for browser-based OAuth.
Setup checklist
Register your apps in your identity provider
You need two Okta (or equivalent) apps:
- Source app — the app your users sign into. Usually an existing SPA or web app.
- Resource app — a separate Okta app that represents Serval. Its
client_idbecomes theaudienceon every ID-JAG and must match what you register with Serval.
- Add Token Exchange (
urn:ietf:params:oauth:grant-type:token-exchange) to the list of allowed grant types. - Under the Cross App Access tab, add the resource app as an allowed target.
- Ensure Client authentication is
client_secret_basic,client_secret_post, orprivate_key_jwt— SPAs (auth methodNone) cannot perform token exchange; register a confidential Web app for the BFF.
- Assign your test user (and eventually all users who should get Serval access).
- Ensure tokens are minted by Org AS (issuer
https://{your-tenant}.okta.com), not by a custom authorization server at/oauth2/default. Serval validates the issuer on the assertion against the registered trusted IdP — the two must match exactly.
Register the trusted IdP in Serval
As a Serval org admin, go to Admin Settings → Security → Cross-App Access and click Add Identity Provider. Fill in:
- Issuer URL — your IdP’s OIDC issuer URL (e.g.
https://acme.okta.com). Must exactly match theissclaim on your ID-JAGs. - Audience (Client ID) — your resource app’s client ID (e.g.
0oa123t13csF7xmhC698). Must exactly match theaudclaim. - Subject claim —
sub(recommended; works with opaque Okta00u...identifiers) oremail.
.well-known/openid-configuration, so the issuer must be publicly reachable over HTTPS.Create an XAA client in Serval
The XAA client is the OAuth client your BFF authenticates as when calling Serval’s token endpoint. Each one is scoped to a single trusted IdP.In the admin UI, expand the identity provider you just created and click Add Client. Fill in:
- Client ID — the client_id that your IdP will put on the
client_idclaim of ID-JAGs. For Okta, this is the client ID of the source app (not the resource). - Display Name — a human-readable label for this client (e.g. “Acme BFF”).
Wire up your BFF
With the Okta source-app credentials (used to call Okta’s token endpoint) and the Serval XAA client credentials (used to call Serval’s token endpoint), your BFF can now perform the full exchange.
Sync users (automatic)
Once you install and connect Okta in Serval, the user sync populates Serval’s internal
external_users table automatically — mapping each Okta user’s sub to a Serval user in the same org. ID-JAGs carrying only a sub (no email) then resolve correctly.No per-user action is required. If you configure XAA before the first user sync completes, ExchangeIDJAG will return PermissionDenied on any exchange until the sync runs once.Exchange flow in detail
Step 1 — Obtain an ID-JAG from your IdP
Your BFF performs RFC 8693 token exchange against your identity provider. For Okta:subject_token— the current user’sid_token(or access token) from your SPA.audience— the client ID of the Serval resource app.scope— must be exactlyserval:user. Other values are rejected by Serval.
access_token field contains the ID-JAG.
Step 2 — Exchange the ID-JAG for a Serval access token
grant_type— the OAuth 2.0 JWT Bearer grant type (RFC 7523).assertion— the ID-JAG from step 1.-u— HTTP Basic authentication with the XAA client credentials from setup step 3.
access_token as a Bearer credential when calling the Serval public API or the MCP server. The response intentionally omits a refresh token — XAA tokens are short-lived, and renewal is driven by repeating the exchange.
ID-JAG requirements
Serval validates every ID-JAG against the strict shape below. Any deviation returnsinvalid_grant from /oauth/token and surfaces in the error_description field.
Header
| Field | Required value |
|---|---|
alg | Any asymmetric algorithm your IdP’s JWKS advertises. Must match the key. |
kid | Key ID matching an entry in the IdP’s JWKS. |
typ | Exactly oauth-id-jag+jwt. |
Claims
| Claim | Requirement |
|---|---|
iss | Must match the issuer registered in your trusted IdP. |
aud | Must match the audience registered in your trusted IdP (the resource app’s client ID). |
sub | Required. The opaque user identifier from your IdP (e.g. Okta’s 00u...). Used to resolve a Serval user via the auto-populated external_users table. |
exp | Required. Serval enforces a 60-second leeway. |
iat | Required. Cannot be more than 60 seconds in the future. |
jti | Required. Used for replay protection — any (iss, jti) combination is accepted at most once. |
client_id | Required. Must equal the client_id of the XAA client you registered with Serval. |
scope | Must be exactly serval:user. Space-separated lists with additional scopes are rejected. |
aud_sub | Optional. When present and valid UUID format, Serval prefers resolving the user by this Serval user ID directly and falls back to sub-based resolution on mismatch. Useful if your IdP stores the Serval user ID alongside the Okta user. |
resource, authorization_details | Not supported. Send null or omit. Non-null values are rejected. |
The MCP use case
The most common reason to use XAA is giving an MCP agent running inside your product access to Serval on behalf of the current user. The flow is identical to the above — your product’s BFF obtains a Servalaccess_token via XAA, then hands it to the MCP client as a Bearer credential instead of using the browser-based OAuth flow:
expires_in in the token response).
Troubleshooting
invalid_client (401)
invalid_client (401)
Your BFF’s XAA client credentials are wrong, the XAA client has been revoked, or the authenticated client isn’t linked to the trusted IdP that issued the assertion.
- Confirm you’re using the Serval XAA client secret, not your Okta source app’s secret.
- Re-check that the XAA client appears under the correct identity provider in Admin Settings → Security → Cross-App Access. The client must be scoped to the trusted IdP whose
issappears on the assertion. - If you rotated credentials by creating a new client, ensure your BFF picked up the new secret.
invalid_grant — 'ID-JAG validation failed'
invalid_grant — 'ID-JAG validation failed'
The assertion’s shape doesn’t match what the trusted IdP row expects.Common causes:
issmismatch — your IdP issued the token through a custom Authorization Server (.../oauth2/default) instead of Org AS. Reconfigure to use Org AS or change the trusted IdP’sissuer.audmismatch — the assertion is audienced to a different client than what you registered.- Expired or not-yet-valid — check
exp/iat; Serval enforces 60s leeway. - Missing
typ: oauth-id-jag+jwtheader — your IdP isn’t minting an ID-JAG (it might be issuing a plain id_token or access_token). Confirm the token exchange requestsrequested_token_type=urn:ietf:params:oauth:token-type:id-jag. - Missing
jti,client_id, oriat— required by Serval. scopeisn’t exactlyserval:user.
invalid_grant — 'sub is not linked to a Serval user for this issuer'
invalid_grant — 'sub is not linked to a Serval user for this issuer'
The Okta user hasn’t been synced to Serval yet, or the sync hasn’t populated the mapping for this user.
- Verify the Okta integration is installed in your Serval workspace and a user sync has completed successfully.
- Confirm the user is assigned to both the source Okta app and the resource Okta app.
- The mapping is populated on the sync cycle after a user’s Serval account is materialized. For brand-new users, wait one sync interval and retry.
invalid_grant — 'ID-JAG has already been used'
invalid_grant — 'ID-JAG has already been used'
ID-JAGs are single-use per
(issuer, jti). If your BFF retried the exchange call or cached the same assertion, Serval will reject the replay.Get a fresh ID-JAG from Okta and try again. This error is not retriable with the same assertion.'The client is not authorized to use the provided grant type'
'The client is not authorized to use the provided grant type'
Security notes
- Rotate XAA client secrets the same way you rotate any OAuth credential. Create a new API client from Admin Settings → Security → Cross-App Access (or call
CreateXAAClientvia the API), update your BFF with the new secret, then revoke the old client from the same UI (or callRevokeXAAClient). - Tenant isolation. Each ID-JAG issuer is globally unique in Serval — you cannot register the same
isson two organizations. This prevents an assertion from organization A from resolving to a user in organization B. - Replay protection. Serval records every accepted
(iss, jti)tuple and rejects reuse. Your IdP must include a uniquejtion every assertion (Okta does by default). - Short-lived tokens. Serval access tokens obtained via XAA expire quickly. Design your BFF to repeat the exchange on demand rather than caching long-lived credentials.
- Do not forward raw ID-JAGs to untrusted clients. An ID-JAG is as sensitive as a password — anyone holding one before its
expcan mint a Serval session for that user. Keep the token inside your BFF.

