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

# Set up Okta Cross-App Access (XAA) with Serval

> Register an AI agent in Okta and trust it in Serval so it can exchange an Okta-issued ID-JAG for a Serval access token that acts on behalf of a specific user — no per-user OAuth consent.

Cross-App Access (XAA) lets an AI agent or application registered in your Okta org call the Serval API **on behalf of one of your users** without sending that user through an interactive OAuth consent. The agent asks Okta for a short-lived assertion — an **ID-JAG** ([OAuth Identity Assertion Authorization Grant](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/), built on [RFC 8693 token exchange](https://datatracker.ietf.org/doc/html/rfc8693)) — and your backend redeems that assertion at Serval for a normal Serval access token.

In this flow Serval is the **resource app**: Okta is the identity provider you already trust, and Serval accepts assertions it issues.

This guide covers the **Serval OIN app** path, where the ID-JAG audience is your Serval API URL. If you instead register your own Okta resource app for Serval, its audience is that app's client ID and the Okta-side values differ — follow the generic flow in the [API reference](/sections/api-reference/cross-app-access) for that model. Everything in Serval (Trusted IdP, API client, user resolution) and the redeem call is the same either way.

> [!NOTE]
>
> Cross-App Access is in **Beta** and must be enabled for your Serval organization before the **Settings → Security → Cross-App Access** controls appear. If you don't see that section, ask your Serval representative to turn it on. The admin UI and OIN listing may still change.

## Your Serval host

Every URL in this guide is **relative to your Serval deployment**. Substitute your own host wherever you see `https://<your-serval-api-host>`:

| Deployment | `<your-serval-api-host>` |
| - | - |
| **Serval Cloud — US region** | `public.api.serval.com` |
| **Serval Cloud — EU region** | `public.eu1.serval.com` |
| **Self-hosted / hybrid** | your own Serval public API host (ask your Serval representative if unsure) |

The audience, token endpoint, and the API you call all use this **one** host. The **issuer** is the exception — it's your Okta org URL (the `iss` claim on the ID-JAG), not a Serval host. Serval hardcodes no host in the exchange, so use the host for your org's region throughout.

> [!WARNING]
>
> Your region is fixed at organization creation. An EU organization must use `public.eu1.serval.com` everywhere — audience, token endpoint, and API calls. Requests to the US host will not reach EU data, and vice versa. See [Data residency](/sections/documentation/platform/data-residency).

## What you'll need

Have these on hand before you start. The last three are produced *during* setup.

| Value | Where it comes from | Example |
| - | - | - |
| **Token endpoint** | Your Serval host | `https://<your-serval-api-host>/oauth/token` |
| **Audience** | Your Serval host | `https://<your-serval-api-host>` |
| **Scope** | Fixed | `serval:user` |
| **Okta org (issuer) URL** | Your Okta org | `https://your-org.okta.com` |
| **Agent Client ID** | Okta, step 2 | `0oa1b2c3...` |
| **Serval API client secret** | Serval, step 5 | shown once |
| **A matching Serval user** | Your Serval org | same email as the end user |

## Prerequisites

* **An Okta org with Cross-App Access and AI Agents enabled.** Both features must be available on your org.
* **Okta admin permissions** to configure OIN applications, machine assignments, and AI agents.
* **A Serval administrator account**: the **Admin** organization role in the Serval org you're configuring.
* **Cross-App Access enabled on your Serval org**: this Beta feature is off by default. If **Settings → Security → Cross-App Access** isn't present, ask your Serval representative to enable it.
* **The Serval app added from the OIN**: add Serval to your org from the [Okta Integration Network](https://www.okta.com/integrations/) catalog.

## How the flow works

Setup is a one-time round trip: in Okta you register Serval as an XAA resource, register your agent, and grant the agent access; in Serval you trust your Okta org and register an API client for the agent. After that, every call is two steps — mint, then redeem.

```mermaid theme={null}
sequenceDiagram
    participant Agent as Your AI agent<br/>(Okta client)
    participant Okta
    participant Serval as Serval<br/>(resource app)
    participant API as Serval API / MCP
    Agent->>Okta: token-exchange → request ID-JAG<br/>(audience = your Serval host, scope = serval:user)
    Okta-->>Agent: ID-JAG (short-lived, carries user + client_id)
    Agent->>Serval: POST /oauth/token (jwt-bearer)<br/>Basic auth = Serval API client
    Serval->>Serval: validate ID-JAG vs Trusted IdP,<br/>resolve user by email
    Serval-->>Agent: Serval access token (scope serval:user)
    Agent->>API: call with Bearer token
```

## Set up Cross-App Access

**1. Enable Serval as an XAA resource in Okta**

In the Okta Admin Console, open the **Serval** app (add it from the OIN catalog first if needed). Go to **Machine Assignments** and find the **Cross-App Access (XAA)** card.

Set it to **Enabled**, select the `serval:user` scope, and save.

> [!NOTE]
>
> The **Cross-App Access (XAA)** card defaults to **Disabled**. If you leave it disabled, Okta won't mint ID-JAGs audienced to Serval and every exchange fails.

**2. Register your AI agent in Okta**

Go to **Directory → AI Agents** and register your agent (for example, the service behind Bifrost). Configure an Okta-hosted public/private key for it and **Activate** it.

Copy the agent's **Client ID**. Okta stamps this as the `client_id` on every ID-JAG, and you register the same value in Serval in step 5.

**3. Give the agent access to Serval**

On the agent, add a **Resource connection**:

* Resource type: **Application**
* **App configured for AI Agent access**
* Select the **Serval** app
* Scope: `serval:user`

> [!WARNING]
>
> Do **not** use the **Custom resource server** / **Resource Servers** path. It does not register Serval as a valid ID-JAG audience, and the exchange fails with an audience error even though every other value looks right. This is the most common setup mistake.

**4. Trust your Okta org in Serval**

In Serval, go to **Settings → Security → Cross-App Access** and **Add Identity Provider** (Trusted IdP):

* **Issuer URL**: your Okta org URL, e.g. `https://your-org.okta.com`. Must match the `iss` claim on the ID-JAG exactly.
* **Audience (Client ID)**: enter `https://<your-serval-api-host>`. **Despite this field's "Client ID" label and `0oabc123...` placeholder, the OIN app's audience is the URL above — not an Okta client ID.** It must equal the ID-JAG `aud` claim. (For the custom resource-app model, this is instead that app's client ID — see the [API reference](/sections/api-reference/cross-app-access).)
* **Subject Claim**: `email`.

Enable the identity provider.

**5. Register the agent as a Serval API client**

Still under **Cross-App Access**, go to **API Clients** and **Add Client**:

* **Client ID**: the **same value** as the Okta agent's Client ID from step 2. It must equal the `client_id` claim on the ID-JAG, or Serval rejects the exchange.
* **Display Name**: a human-readable label.

Save, then copy the generated **Client Secret** and store it securely. It is shown **once** and is the credential your backend uses to authenticate to the token endpoint.

**6. Make sure the end user exists in both places**

The user the agent acts as must exist on **both** sides with the **same email**:

* **Okta**: assign the user to the Serval app.
* **Serval**: an **active** user with that email, in the organization that owns the Trusted IdP.

Serval resolves the user by the ID-JAG's email, scoped to that org. If your Serval org is provisioned by SCIM or a directory sync, confirm the user has synced before testing.

**7. Verify the exchange end to end**

From your backend, mint an ID-JAG and redeem it (see [Under the hood](#under-the-hood) for the full calls). A successful redeem returns a Serval access token:

```json theme={null}
{ "access_token": "...", "token_type": "Bearer", "scope": "serval:user", "expires_in": 3600 }
```

Use that token as a `Bearer` credential against `https://<your-serval-api-host>` (including the Serval MCP endpoint). If the redeem fails, the `error` / `error_description` in the response maps directly to a fix in [Troubleshooting](#troubleshooting).

## Under the hood

Your integration code does two things on each call. Everything above was one-time setup.

**1. Mint an ID-JAG from Okta.** Your agent performs RFC 8693 token exchange against your Okta org's token endpoint, authenticating as the agent client and requesting an assertion **audienced to Serval** (`https://<your-serval-api-host>`) with **scope `serval:user`**. The exact request — including how the acting user is supplied (e.g. a `subject_token`) and the client authentication method (`private_key_jwt`) — is handled by your Okta agent configuration or SDK. See [Cross-App Access](/sections/api-reference/cross-app-access) in the API reference for the full token-exchange contract and a worked example.

> [!NOTE]
>
> The API reference's worked example shows the **custom resource-app** model, so adapt two things for the OIN AI-agent setup in this guide: set `audience` to `https://<your-serval-api-host>` (not the example's `$OKTA_RESOURCE_CLIENT_ID`), and authenticate as the agent with its Okta key via `private_key_jwt` rather than the example's HTTP Basic source-client secret. Your Okta agent SDK handles the `private_key_jwt` assertion.

**2. Redeem the ID-JAG at Serval.** `POST` to the token endpoint with HTTP Basic auth of your Serval API client (`client_id:client_secret` from step 5). This half is identical across all setups:

```bash theme={null}
curl -sS https://<your-serval-api-host>/oauth/token \
  -u "$SERVAL_CLIENT_ID:$SERVAL_CLIENT_SECRET" \
  -d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \
  --data-urlencode "assertion=$ID_JAG"
```

Serval validates the ID-JAG against the Trusted IdP, resolves the user by email, and returns a Serval access token with scope `serval:user`. Use it as a `Bearer` credential against `https://<your-serval-api-host>`.

> [!TIP]
>
> For the ID-JAG header/claim requirements and the exchange contract in full, see [Cross-App Access](/sections/api-reference/cross-app-access) in the API reference.

## Requirements checklist

> [!NOTE]
>
> * The **audience** is exactly `https://<your-serval-api-host>` (your deployment's host, not necessarily the cloud host).
> * The requested **scope** is exactly `serval:user`.
> * The Serval API client's **Client ID** equals the agent's Okta `client_id`.
> * The ID-JAG's **email** maps to an **active** Serval user in the Trusted IdP's org.
> * ID-JAGs are **short-lived and single-use**: mint one per exchange, redeem promptly, never replay.

## Troubleshooting

The redeem response is a standard OAuth error: `{ "error": "...", "error_description": "..." }`. Match on both.

| `error` | `error_description` | Meaning & fix |
| - | - | - |
| `invalid_client` | `client authentication failed` | The Serval API client ID or secret is wrong. Re-check both. Secrets can't be regenerated and the Client ID must be unique, so if the secret is lost, **revoke** the existing client first, then **add a new one with the same Client ID** and update your backend. The exchange is briefly unavailable between revoke and re-add. |
| `invalid_grant` | `issuer is not a trusted IdP` / `ID-JAG issuer does not match the trusted IdP` | The Okta org issuer isn't registered or enabled in Serval, or doesn't match `iss`. Add/enable it with the exact Okta org URL (step 4). |
| `invalid_grant` | `ID-JAG audience does not match this resource server` | The token-exchange `audience` (or the Trusted IdP audience) isn't `https://<your-serval-api-host>`. Usually caused by the **Custom resource server** path — use the AI-Agent resource connection (step 3). |
| `invalid_grant` | `ID-JAG client_id does not match the authenticated client` | The Serval API client's Client ID isn't the agent's Okta `client_id`. Make them equal (step 5). |
| `invalid_grant` | `user not found in organization` | The ID-JAG email has no active Serval user in the Trusted IdP's org, or the user isn't assigned in Okta. Assign the user and confirm the matching active Serval user (step 6). |
| `invalid_grant` | `ID-JAG has expired` | The assertion aged out. Mint and redeem promptly. |
| `invalid_grant` | `ID-JAG has already been used` | An ID-JAG was replayed. Mint a fresh one per exchange. |
| `invalid_grant` | `ID-JAG signature verification failed` | The signing key doesn't match. Re-check the agent's Okta key configuration (step 2). |
| `invalid_grant` | `ID-JAG signing key not found for the trusted IdP` | The ID-JAG's `kid` isn't in the IdP's published JWKS — usually a rotated-out or wrong signing key. Re-check the agent's Okta key (step 2). |
| `temporarily_unavailable` | — | Transient. Retry with backoff. |

> [!NOTE]
>
> Fix `invalid_client` before debugging the assertion: a wrong client ID or secret returns `invalid_client`, and you won't see a detailed `invalid_grant` description until your API client authenticates successfully.

If the exchange still fails after these checks, contact your Serval representative with the time of the attempt and the exact `error_description`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.