> ## Documentation Index
> Fetch the complete documentation index at: https://docs.serval.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Cross App Access

> Let your users access Serval from another app they're already signed into, without a second login

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)](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/) 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](https://datatracker.ietf.org/doc/html/rfc8693).
* 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](/api-reference/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](/api-reference/auth-api/create-token) or the [MCP Server](/api-reference/mcp-server) on behalf of the user.

<Note>
  **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.
</Note>

## Setup checklist

<Steps>
  <Step title="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.
  </Step>

  <Step title="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 claim** — `sub` (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.

    <Tip>
      You can also use the API directly:

      ```bash theme={null}
      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"
        }'
      ```
    </Tip>
  </Step>

  <Step title="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.

    <Tip>
      You can also use the API directly:

      ```bash theme={null}
      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"
        }'
      ```
    </Tip>
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

## 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:

```bash theme={null}
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

```bash theme={null}
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](https://datatracker.ietf.org/doc/html/rfc7523#section-2.1)).
* `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:

```json theme={null}
{
  "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.

### 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 Serval `access_token` via XAA, then hands it to the MCP client as a Bearer credential instead of using the browser-based OAuth flow:

```json theme={null}
{
  "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

<AccordionGroup>
  <Accordion title="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 `iss` appears on the assertion.
    * If you rotated credentials by creating a new client, ensure your BFF picked up the new secret.
  </Accordion>

  <Accordion title="invalid_grant — 'ID-JAG validation failed'">
    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`.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="'The client is not authorized to use the provided grant type'">
    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.
  </Accordion>
</AccordionGroup>

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