Skip to main content
Cross App Access (XAA) lets a user who is already authenticated in one of your apps obtain a Serval session without a separate Serval login — driven by an ID-JAG (Identity Assertion JWT Authorization Grant) issued by your identity provider. This is how you integrate Serval with your own product’s SSO so that, for example, an MCP agent running inside your platform can call Serval’s public API as the current user, with no extra prompts.

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.
If you just want to let users sign into Serval with OAuth from an AI tool, use the MCP Server — it’s a turn-key OAuth flow. XAA is for the case where your own product is the OAuth client and you want the exchange to happen server-to-server.

How it works

┌─────────────┐
│   Browser   │
│    (User)   │
└──────┬──────┘
       │ 1. Sign in

┌─────────────┐
│   Your SPA  │
│  (public)   │
└──────┬──────┘
       │ 2. Pass user token

┌─────────────┐                ┌─────────────┐
│  Your BFF   │───────────────▶│    Okta     │
│(confidential)│  3. Token    │  (Org AS)   │
└──────┬──────┘    exchange    └──────┬──────┘
       │                              │
       │◀─────────────────────────────┘
       │   4. ID-JAG (signed JWT)

       │   5. POST /oauth/token with ID-JAG

┌─────────────┐
│   Serval    │  → access_token, scope
└─────────────┘
  1. The user authenticates in your SPA through your identity provider.
  2. Your SPA hands the user’s id_token (or access token) to a backend service you control — the BFF.
  3. 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.
  4. The IdP validates the user’s session, validates the cross-app-access grant, and mints an ID-JAG JWT.
  5. 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.
The BFF can then use that access token to call the Serval public API or the MCP Server on behalf of the user.
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

1

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_id becomes the audience on every ID-JAG and must match what you register with Serval.
Enable Cross App Access on your Okta tenant if it isn’t on already (this is a tenant-level feature flag; on trial orgs it may require opening a support case).On the source app:
  • 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, or private_key_jwt — SPAs (auth method None) cannot perform token exchange; register a confidential Web app for the BFF.
On both apps:
  • 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.
2

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 the iss claim on your ID-JAGs.
  • Audience (Client ID) — your resource app’s client ID (e.g. 0oa123t13csF7xmhC698). Must exactly match the aud claim.
  • Subject claimsub (recommended; works with opaque Okta 00u... identifiers) or email.
Serval discovers the JWKS URI from the issuer’s .well-known/openid-configuration, so the issuer must be publicly reachable over HTTPS.
You can also use the API directly:
curl -sv https://your-serval-host/svauth.user.SvUserAuthService/CreateTrustedIdP \
  -H "Authorization: Bearer <your-admin-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "issuer": "https://acme.okta.com",
    "audience": "0oa123t13csF7xmhC698",
    "subjectClaim": "sub"
  }'
3

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_id claim 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”).
The dialog returns a client secret in plaintext exactly once. Copy it immediately and store it somewhere only your BFF can read. Serval persists only a bcrypt hash; you cannot retrieve the secret later — only rotate by creating a new XAA client and revoking the old one.
You can also use the API directly:
curl -sv https://your-serval-host/svauth.user.SvUserAuthService/CreateXAAClient \
  -H "Authorization: Bearer <your-admin-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "trustedIdpId": "<the UUID from step 2>",
    "displayName": "Acme BFF",
    "clientId": "0oa123t9nd2dv0Uqs698"
  }'
4

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.
5

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:
curl -sv https://acme.okta.com/oauth2/v1/token \
  -u "$OKTA_SOURCE_CLIENT_ID:$OKTA_SOURCE_CLIENT_SECRET" \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d "subject_token=$USER_ID_TOKEN" \
  -d subject_token_type=urn:ietf:params:oauth:token-type:id_token \
  -d requested_token_type=urn:ietf:params:oauth:token-type:id-jag \
  -d "audience=$OKTA_RESOURCE_CLIENT_ID" \
  -d scope=serval:user
  • subject_token — the current user’s id_token (or access token) from your SPA.
  • audience — the client ID of the Serval resource app.
  • scope — must be exactly serval:user. Other values are rejected by Serval.
On success, the response’s access_token field contains the ID-JAG.

Step 2 — Exchange the ID-JAG for a Serval access token

curl -sv https://public.api.serval.com/oauth/token \
  -u "$SERVAL_XAA_CLIENT_ID:$SERVAL_XAA_CLIENT_SECRET" \
  -d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \
  --data-urlencode "assertion=$ID_JAG"
  • 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.
A successful response looks like:
{
  "access_token": "eyJhbGci...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "serval:user"
}
Use the 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 returns invalid_grant from /oauth/token and surfaces in the error_description field.
FieldRequired value
algAny asymmetric algorithm your IdP’s JWKS advertises. Must match the key.
kidKey ID matching an entry in the IdP’s JWKS.
typExactly oauth-id-jag+jwt.

Claims

ClaimRequirement
issMust match the issuer registered in your trusted IdP.
audMust match the audience registered in your trusted IdP (the resource app’s client ID).
subRequired. 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.
expRequired. Serval enforces a 60-second leeway.
iatRequired. Cannot be more than 60 seconds in the future.
jtiRequired. Used for replay protection — any (iss, jti) combination is accepted at most once.
client_idRequired. Must equal the client_id of the XAA client you registered with Serval.
scopeMust be exactly serval:user. Space-separated lists with additional scopes are rejected.
aud_subOptional. 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_detailsNot 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 Serval access_token via XAA, then hands it to the MCP client as a Bearer credential instead of using the browser-based OAuth flow:
{
  "mcpServers": {
    "serval": {
      "url": "https://public.api.serval.com/mcp/",
      "headers": {
        "Authorization": "Bearer ${SERVAL_ACCESS_TOKEN}"
      }
    }
  }
}
Repeat the XAA exchange before the token expires (check expires_in in the token response).

Troubleshooting

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 iss appears on the assertion.
  • If you rotated credentials by creating a new client, ensure your BFF picked up the new secret.
The assertion’s shape doesn’t match what the trusted IdP row expects.Common causes:
  • iss mismatch — 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’s issuer.
  • aud mismatch — 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+jwt header — your IdP isn’t minting an ID-JAG (it might be issuing a plain id_token or access_token). Confirm the token exchange requests requested_token_type=urn:ietf:params:oauth:token-type:id-jag.
  • Missing jti, client_id, or iat — required by Serval.
  • scope isn’t exactly serval:user.
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.
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.
This error comes from Okta, not Serval. Your source app doesn’t have the token-exchange grant enabled. Open the app in Okta Admin → General → Grant types and add urn:ietf:params:oauth:grant-type:token-exchange. If the checkbox is absent, Cross App Access isn’t enabled on the tenant.

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 CreateXAAClient via the API), update your BFF with the new secret, then revoke the old client from the same UI (or call RevokeXAAClient).
  • Tenant isolation. Each ID-JAG issuer is globally unique in Serval — you cannot register the same iss on 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 unique jti on 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 exp can mint a Serval session for that user. Keep the token inside your BFF.