# Agents through a company's identity provider

Let each customer company's identity provider approve the AI agents that act for its people, with no consent screen, starting with Okta.

A company's people already connect AI agents to your app, one consent screen at a time. Enterprise-managed authorization moves that decision to the company's identity provider: its IT admin approves an agent for the company's people once, there, and the agent then gets a short-lived token for any of them with no consent screen. Access ends when the identity provider stops approving the agent, when the person leaves the company's directory, or when the company turns it off in your app.

This builds on Enterprise SSO for the company: [set that up](/sign-in-setup/enterprise-sso) first, if you have not.

The standard is the Identity Assertion JWT Authorization Grant (ID-JAG), an IETF OAuth working group draft (`draft-ietf-oauth-identity-assertion-authz-grant-04`) built on the JWT bearer grant of RFC 7523. Okta calls it Cross App Access; the MCP specification calls it the Enterprise-Managed Authorization extension. Your app stays the resource: BuildBase runs the grant on your app's OAuth server and your app mints its own token, as it does for every agent.

**This is built for Okta.** BuildBase checks what the draft fixes, not anything Okta-specific, so another identity provider that issues ID-JAGs to the same draft may work, but these pages name no other.

> **Note:**
  A plan that includes agent readiness, an **Agent (third-party)** client in the
  console (see [MCP and agents](/reference/mcp-agents)), and [Enterprise
  SSO](/authentication/enterprise-sso) set up for each company that wants it.
  The company needs Okta with Cross App Access turned on for its Okta org, which
  Okta Support does on request.


## How it fits together

| Piece                                                | Where it lives                                                                                                   | Who sets it up                       |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| The switch, and the longest token                    | Console, **Authentication**, **Agent readiness**                                                                 | You, once                            |
| Agents identified by a metadata document, for Claude | Same tab, a second switch                                                                                        | You, once                            |
| The agent's client in your app                       | A registered agent client, or one made from the agent's metadata document on first use                           | You, or the agent                    |
| The company's identity provider (its issuer)         | The company's **Single sign-on** screen in your app, or the enterprise's detail in your console                  | The company's IT admin, or your team |
| Cross App Access for your app                        | The company's Okta Admin Console                                                                                 | The company's IT admin               |
| Which agents may act for its people                  | The company's connected-apps policy ([connected apps](/authentication/enterprise-sso#connected-apps-and-agents)) | The company, or your team            |
| The token the agent uses                             | Your **Application token URL**                                                                                   | Your app, as for every grant         |

## The flow

```mermaid
sequenceDiagram
  participant P as Person
  participant A as Agent
  participant I as Company's Okta
  participant B as Your app's OAuth server on BuildBase
  participant Y as Your app
  P->>A: Signs in with the company's Okta
  A->>I: Exchanges its ID token for an ID-JAG for your app
  I-->>A: ID-JAG, if the company connected this agent to your app
  A->>B: Token request with the JWT bearer grant and the ID-JAG
  B->>B: Checks the issuer, keys, claims, single use, person and policy
  B->>Y: Application token URL, grantType id_jag and expiresInMax
  Y-->>B: Your token, bound to the resource
  B-->>A: access_token and expires_in, no refresh token
  A->>Y: MCP or API requests with your token
```

1. The person signs in to the agent with their company's identity provider.
2. The agent asks the identity provider for an ID-JAG for your app: an RFC 8693 token exchange whose `audience` is your app's issuer, with the `resource` (your MCP server's URL) and scopes it wants. Okta answers only when the company has connected that agent to your app.
3. The agent presents the ID-JAG at your app's token endpoint.
4. BuildBase checks it, finds the person among the company's own people, makes a BuildBase session exactly as long as the token, and calls your **Application token URL** with `grantType: "id_jag"`.
5. Your app mints its own token, bound to the resource. There is no refresh token: when it expires, the agent asks the identity provider for a fresh ID-JAG.

## 1. Turn it on

In the console, under **Authentication**, **Agent readiness**:

- **Let companies' identity providers approve agents** (`enterpriseManagedAuth.enabled`). Off by default. Turning it on needs a plan that includes agent readiness; on a plan without it the switch reads as off, and the grant answers `unsupported_grant_type`.
- **Longest agent token** (`enterpriseManagedAuth.tokenMinutes`): 5, 15, 30 or 60 minutes, 60 by default. A company may choose a shorter lifetime for its own people, never a longer one.
- **Let agents identify themselves by a metadata document** (`clientIdMetadataDocuments.enabled`), with the **Base client for agents identified by a metadata document** they borrow your token and revoke URLs from (`baseClientId`, else your dynamic registration's base client), **Most agents kept** (`maxClients`) and **Vetted document hosts** (`vettedHosts`, at most 50 host names such as `claude.ai`). Claude needs it ([below](#agents-identified-by-a-metadata-document)).

Over the API:

```bash
# Console route: an org API key with the org settings resource
curl -X PATCH -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"enterpriseManagedAuth":{"enabled":true,"tokenMinutes":60},"clientIdMetadataDocuments":{"enabled":true,"baseClientId":"<your agent client id>"}}' \
  "https://<your server>/api/organizations/settings/agent-readiness"
```

Each object is merged with what is stored, so a body that leaves out a field keeps its value. `maxClients` is the most metadata-document agents your app keeps, 1,000 when unset. A `baseClientId` must be one of your own registered clients: a metadata-document URL there is refused with a 400 on that field. Both switches are recorded in your auth log as `agent_readiness_updated`, from and to.

Turning **Let companies' identity providers approve agents** off (the console asks first) ends at once every live session any company's identity provider approved, tells your revoke URL `agent_access_off` for each, and writes `enterprise_sessions_revoked` (`scope: "agents"`, `reason: "switch_off"`) to each company's trail. Later requests answer `unsupported_grant_type`. Nothing else ends: sessions people approved on the consent screen stay.

### What your metadata advertises

While the switch is on, your org's RFC 8414 metadata (`https://<your server>/.well-known/oauth-authorization-server/org/<org id>`) adds the grant and its profile, and, while the second switch is on, metadata documents:

```json
{
  "issuer": "https://<your server>/org/<org id>",
  "token_endpoint": "https://<your server>/api/v1/auth/oauth2/token?org=<org id>",
  "grant_types_supported": [
    "authorization_code",
    "refresh_token",
    "urn:ietf:params:oauth:grant-type:jwt-bearer"
  ],
  "authorization_grant_profiles_supported": [
    "urn:ietf:params:oauth:grant-profile:id-jag"
  ],
  "client_id_metadata_document_supported": true,
  "token_endpoint_auth_methods_supported": [
    "none",
    "client_secret_basic",
    "client_secret_post"
  ]
}
```

It never lists the issuers any company trusts (draft section 9.4). Claude offers enterprise-managed authorization only when it finds the JWT bearer grant here, and identifies itself by a metadata document only when it finds `client_id_metadata_document_supported: true` with `none` among the authentication methods. Saving either switch clears the cached metadata.

The **issuer** is what every ID-JAG for your app must carry as its `aud`, and what Okta calls **Issuer URL**: the **Issuer** on your Agent readiness tab, `https://<your server>/org/<org id>`. Okta's Issuer URL cannot change without deleting and resetting the company's connection, so a self-hosted server that moves to another domain has to tell every company that set this up.

## 2. The agent's client

An ID-JAG names the agent by its client ID in your app (`client_id`), and the agent authenticates at the token endpoint as that client:

| Client                                                                | Authenticates with                                  | Takes this grant                                                                                                    |
| --------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| An agent client you register in the console (**Agent (third-party)**) | Its secret, as HTTP Basic or in the body            | Yes. For an agent registered once, such as a company's own agent or Okta's xaa.dev tester                           |
| An agent identified by its Client ID Metadata Document                | Nothing: a public client whose URL is its client ID | Yes, with the safeguards below. This is how Claude connects                                                         |
| A dynamically registered client                                       | -                                                   | No: `unauthorized_client`. Its ID is new on every registration, so no identity provider can name it in an assertion |
| An app client (your own sign-in)                                      | -                                                   | No: `unauthorized_client`                                                                                           |

### Agents identified by a metadata document

With **Let agents identify themselves by a metadata document** on, a `client_id` that is an `https` URL with a path (no fragment, no user name, at most 512 characters) is the address of a JSON document the agent publishes about itself (`draft-ietf-oauth-client-id-metadata-document`). The first time your app sees one, at the consent flow, the token endpoint or a company's policy, BuildBase fetches it and makes a client for it:

- **Fetched carefully.** Through the same guard as every outbound call, and stricter: `https` and a public address only, even on a self-hosted server that allows private ones elsewhere (no private, loopback, link-local, metadata, NAT64 or 6to4 addresses), no redirects, 5 seconds for the whole fetch, at most 5 KB, JSON only. The body is never logged.
- **It must name itself.** The document's `client_id` must equal its URL exactly, its `token_endpoint_auth_method` must be absent or `none`, and its `redirect_uris` (at most 20) must be ones a client may register. Its `client_name`, at most 100 characters, is the agent's name on the consent screen and in Connected apps; the consent request also carries the document's host as `client.identifiedBy`. The document vouches only for itself, so the consent page also marks the agent `unverified`, with a warning, unless your team listed that host under **Vetted document hosts**.
- **Always a public agent.** The client is an agent, authenticates with nothing, needs PKCE on the consent flow, and is never an app client, never confidential and never a base client. Your **Application token URL**, revoke URL and signing secret are the base client's, read live.
- **Kept fresh, briefly stale.** It is fetched again at most once an hour when used. A failing fetch keeps the last good copy for 24 hours, then refuses it.
- **Bounded, without locking a real agent out.** Anyone can name a URL, so an agent counts against `maxClients` (1,000 by default) only once it is used: a person consents to it, a company's identity provider approves it, or a company's policy names it. One nobody uses is removed a day after it was made. At most 20 are made from one host, and 50 from one registrable domain, an hour, counted only once a valid document has made one, so URLs that fail never use up a real agent's allowance. Attempts are limited too, before anything is fetched: 30 an hour from one address, and 120 an hour naming one registrable domain.
- **Refused like any client.** A document that fails any of these is `invalid_client` ("Invalid client") at the door that asked, with the reason in your server's log. While the switch is off, every such URL is `invalid_client`.
- **Recorded.** The first resolution is in your auth log as `client_registered`, with `metadataDocument: { url, host }`, as a dynamic registration is.

A public client cannot keep a secret, which is why the draft prefers confidential clients for this grant. What stands in for one: each assertion is redeemed once, it lives minutes and names this client, the company chose the identity provider that signs it, and the company's own connected-apps policy must approve the agent by name. A policy that allows any agent is not enough for one with no secret: the grant refuses it (`not_approved_by_name`), and the company's screen lists it among the agents to enter in Okta only once the policy names it. A registered agent with a secret still needs only to be allowed.

## 3. The company sets its identity provider

Each company tells your app which identity provider approves agents for it, by pasting that provider's **issuer**: for Okta, the company's Okta org address, such as `https://globex.okta.com`. Who may:

- In your app, on the company's **Single sign-on** screen, in the **Agents approved by your identity provider** card (``, from SDK 1.0.0), anyone whose company role manages **Setup** (the same people who may open the SSO setup links), or, until the company names its admins, a workspace admin who can edit settings. Saving an issuer and turning it back on ask them to [confirm it's them](/authentication/enterprise-sso#confirming-it-s-you), as an SSO setup link does (403 `reauth_required`, which the card turns into **Confirm it's you**); turning it off, a shorter token lifetime, **Check** and **Remove** never do. Send them [the IT admin page](/authentication/enterprise-sso-it-admin#agents-through-your-identity-provider).
- In your console, your team, in the enterprise's detail under **Agents approved by the identity provider**, with **Trust this identity provider**, **Approve**, **Check**, **Turn off**, **Turn on** and **Remove**.
- A company API key can only switch it off ([company API](/authentication/enterprise-company-api#agents-through-the-identity-provider)), never turn it on or change the issuer.

### With several identity providers

A company with [several identity providers](/authentication/enterprise-sso#several-identity-providers) trusts one of them for agents: the form asks which (the default unless chosen), and an agent it approves is matched only to people who sign in through that identity provider, by their link or by an email on a domain it signs in. Moving the issuer to another identity provider from the app puts it back to pending until a first sign-in proves it again; your team's move in the console keeps it active. The card shows which identity provider it is, by its name.

### Proven by first sign-in

Anyone who manages a company's Setup could paste an issuer that is not the company's own, so an issuer saved in your app is **pending** (`status: "pending"`) and approves no agent until it is proven:

- **The first sign-in proves it.** It turns `active` the first time an ID-JAG from it, with a valid signature from its keys, names one of the company's own people by their **single sign-on link**: the company's own WorkOS connection and the identity provider's subject, recorded when that person signed in by SSO. That is the company's identity provider vouching for its own person, so the issuer is the company's. That first assertion is honoured as the first grant. A match by the directory's id or by an email never proves it, and neither does an issuer whose **Keys URL** was typed by hand: its keys are not the issuer's own word.
- **Or your team approves it.** **Approve** in the console (`POST .../agent-access/approve`) turns it active at once, and an issuer your team saves in the console is active at once.
- **Until then nothing happens and nobody is told.** An assertion naming a pending issuer is refused with the generic `invalid_grant`, writes nothing to the company's trail, sends no webhook, and does not spend its `jti`, so a company that saved someone else's issuer learns nothing from that issuer's assertions and burns none of them.
- **A pending issuer blocks nobody.** One company per issuer and tenant counts active rows only: several companies may have the same issuer pending, and when one is proven or approved, every other company's pending row of it is removed (`enterprise_agent_issuer_removed` with `reason: "proven_elsewhere"` in that company's trail). Two active rows of one issuer and tenant stay impossible: `agent_issuer_taken`.
- **Turning it off and on.** A pending issuer can be turned off; turned back on, it is pending again until proven. A proven one turned back on is active again, unless another company holds the issuer active by then (`agent_issuer_taken`).

The proof is in the company's trail as `enterprise_agent_issuer_updated` with `change: "proven"` (`proof: { at, by: "sso_link", userId }`, the person as actor) or `change: "approved"` (`by: "app_team"`), and in the `enterprise.agent_issuer_updated` webhook.

Saving an issuer:

1. Requires your switch on (409 `agent_access_off` otherwise) and an issuer that is `https` with no user name, query or fragment, at most 512 characters. It is stored exactly as typed, since an ID-JAG's `iss` is compared character for character.
2. Refuses an issuer, and tenant, that another company of your app already trusts (409 `agent_issuer_taken`, never naming the other company), before fetching anything.
3. Reads `<issuer>/.well-known/openid-configuration`, then the OAuth server metadata, through the outbound guard (5 seconds for the whole fetch, 64 KB, no redirects). The document's `issuer` must equal what was typed exactly. An address the guard refuses and one that does not answer are both `discovery_unreachable` (`jwks_unreachable` for the keys), and a mismatch names the document's issuer only when it is on the origin typed, so saving an issuer cannot be used to map a network.
4. Reads its key set from the document's `jwks_uri`, or from a **Keys URL** the admin gives, and needs at least one signing key BuildBase accepts: RSA of 2,048 bits or more, or EC on P-256 or P-384, at most 20 keys.
5. Notes whether the identity provider says it issues agent grants (`identity_chaining_requested_token_types_supported` naming the ID-JAG type). Shown, never required.

One identity provider per company. Saving a different issuer replaces the old one, and every agent session the old one approved ends. An optional **Tenant** is only for an identity provider shared by several companies: the ID-JAG must then carry that `tenant`, and an issuer saved without one refuses any ID-JAG that carries a `tenant`. Okta issuers are one per org, so it stays empty for Okta. An issuer that signs for everyone with an account there, such as Google accounts (`https://accounts.google.com`), Microsoft's `common`, `organizations` and `consumers` endpoints, Apple or GitHub Actions, is never saved (`shared_issuer`). Two companies saving conflicting rows at the same moment (one tenant beside none) are settled after the write: the earlier row stands and the later save answers `agent_issuer_taken`.

| Status | `code`                   | When                                                                                                                                                                                                                                 |
| ------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 400    | `agent_issuer_invalid`   | Not saved; `detail` is `not_https`, `shared_issuer`, `discovery_unreachable`, `issuer_mismatch`, `no_signing_keys` or `jwks_unreachable`, and `message` says what to fix, naming the issuer the document gives for `issuer_mismatch` |
| 404    | `agent_issuer_not_found` | Turning off, changing, checking or removing while the company trusts no issuer                                                                                                                                                       |
| 409    | `agent_access_off`       | Saving an issuer, or turning one back on, while your switch is off                                                                                                                                                                   |
| 409    | `agent_issuer_taken`     | Another company in your app already trusts this issuer                                                                                                                                                                               |

The company's routes, with the member's session (`GET` for anyone who views Connected apps or manages Setup, the rest for whoever manages Setup), and the console's:

```bash
# Console routes: an org API key with the enterprises resource
curl -X PUT -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"issuer":"https://globex.okta.com"}' \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/agent-access/issuer"

curl -X PATCH -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"tokenMinutes":15}' \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/agent-access"
```

| Route                            | Does                                                                                                                    |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `GET .../agent-access`           | The view below                                                                                                          |
| `PUT .../agent-access/issuer`    | `{ issuer, tenant?, jwksUri? }`: trust it, or change it. Needs the `auth-sso-enterprise` plan feature                   |
| `PATCH .../agent-access`         | `{ status?: "active" \| "disabled", tokenMinutes?: 5 \| 15 \| 30 \| 60 }`. Turned off, it also answers `sessionsEnded`  |
| `POST .../agent-access/check`    | Reads the discovery document and keys again; answers the view with `checked: { discovery, keys }`                       |
| `POST .../agent-access/approve`  | Console only: approves a pending issuer, active at once. 409 `agent_issuer_taken` while another company holds it active |
| `DELETE .../agent-access/issuer` | Stops trusting it; answers `{ removed: true, sessionsEnded }`                                                           |

The console's routes sit under `/api/auth/enterprise/enterprises/<enterprise id>`, the app's under `/api/v1/public/workspaces/<workspace id>/enterprise`. A way out is never refused for the plan: only `PUT` checks it. The view:

```json
{
  "available": true,
  "audience": "https://<your server>/org/<org id>",
  "tokenEndpoint": "https://<your server>/api/v1/auth/oauth2/token?org=<org id>",
  "maxTokenMinutes": 60,
  "issuer": {
    "issuer": "https://globex.okta.com",
    "tenant": null,
    "jwksUri": "https://globex.okta.com/oauth2/v1/keys",
    "status": "active",
    "proof": { "at": "2026-10-18T09:30:02.000Z", "by": "sso_link" },
    "notice": null,
    "advertisesIdJag": true,
    "keys": {
      "count": 2,
      "checkedAt": "2026-10-18T09:12:44.000Z",
      "error": null
    },
    "tokenMinutes": 60,
    "addedAt": "2026-10-18T09:12:44.000Z",
    "addedBy": "Priya Shah",
    "lastUsedAt": null
  },
  "agents": {
    "mode": "approved",
    "allowed": [
      {
        "clientId": "<client id>",
        "title": "Globex assistant",
        "identifiedBy": "registered"
      }
    ]
  },
  "provider": "okta"
}
```

`audience` is what the IT admin pastes into Okta as **Issuer URL**, and `agents.allowed` the agents the company's policy allows with the client ID each is known by in your app, which Okta asks for. `status` is `pending`, `active` or `disabled`; while pending, `notice` is the sentence to show the company's admin (the first person who uses an agent through it and is matched by single sign-on proves it, or your team approves it), and `proof` is null. `addedBy` is "The app's team" when your console added it, never a name from your team. `provider` comes from the company's WorkOS connection type. `keys.error` is the last failed check's `detail`, while the last good keys stay in use.

## 4. What the token endpoint accepts

```bash
curl -X POST "https://<your server>/api/v1/auth/oauth2/token?org=<org id>" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
  -d "assertion=<the ID-JAG>" \
  -d "client_id=<the agent's client id>" \
  -d "resource=https://app.example.com/mcp"
```

A registered agent adds its secret, as HTTP Basic or `client_secret`. `scope` and `resource` are optional and may only narrow the assertion's. BuildBase checks, in this order, and nothing is fetched for an assertion whose issuer no company trusts:

1. Your switch is on.
2. The client authenticates, and is a registered agent or a metadata-document agent.
3. The assertion is a JWT of at most 8 KB.
4. Its unverified `iss` (and `tenant`) names the one company that trusts it, before anything about its signature.
5. Its header: `typ` `oauth-id-jag+jwt` (with or without `application/`), an `alg` of RS256, RS384, RS512, PS256, PS384, PS512, ES256 or ES384, a `kid`, and no key or key URL of its own (`jku`, `x5u`, `jwk`, `x5c`, `crit`).
6. The company's issuer is turned on, the company is active, and your enterprise sign-in is on.
7. The key, from that company's stored key set only, fitting the `alg`. An unknown `kid` reads the key set again, at most once a minute.
8. The signature, `aud` exactly your issuer (an array of one at most), `exp`, `iat` no more than 60 seconds ahead, and a lifetime (`exp - iat`) of an hour at most, with 60 seconds of clock skew.
9. Its `client_id` is the client that presented it.
10. No `cnf` and no `authorization_details`: refused rather than ignored.
11. `scope` and `resource` within the assertion's.
12. Its `jti` is redeemed once per issuer until it expires, so a captured assertion cannot be used again.
13. The person, among the company's own people only (below), not blocked and not deprovisioned.
14. The company's connected-apps policy allows the agent (an agent with no secret, known by its metadata document, only when the policy approves it by name), and the request's network does not name another company ([the company's network](/authentication/enterprise-sso#the-company-s-network)).

**The person.** The first that applies, matched against the ID-JAG's `sub`: the person's single sign-on link (the identity provider's user id that WorkOS recorded at their SSO sign-in), then their directory record (the identity provider's id the company's directory sent through WorkOS), then the `email` claim, in plain ASCII only, on one of the company's verified domains, exact host, of someone who is already one of its people and has verified that address. Nobody is ever created, and the email is never written to anyone: someone who has never signed in by SSO, been provisioned or joined the company is refused (`not_a_person`). The trail's `matchedBy` says which applied.

The answer, with `Cache-Control: no-store`:

```json
{
  "access_token": "<your app's token>",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "mcp",
  "resource": "https://app.example.com/mcp"
}
```

`expires_in` is the shorter of what your app reported and the company's lifetime, and never longer than the company's maximum session length. `scope` and `resource` are what was granted: the request's, else the assertion's, else left out. There is never a `refresh_token`. Every answer of the token endpoint, of every grant, now carries `Cache-Control: no-store` and `Pragma: no-cache`.

| Status | `error`                   | When                                                                                                                                                                                             |
| ------ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 400    | `unsupported_grant_type`  | Your switch is off, or your plan does not include agent readiness                                                                                                                                |
| 401    | `invalid_client`          | No client, an unknown one, a wrong secret, or a metadata-document URL your app cannot resolve                                                                                                    |
| 400    | `unauthorized_client`     | "This client cannot use the JWT bearer grant.": an app client, or a dynamically registered one                                                                                                   |
| 400    | `invalid_request`         | "The assertion is required." or "The assertion is too large."                                                                                                                                    |
| 400    | `invalid_grant`           | "The assertion could not be verified." for every check on the assertion, the person and the network; the company's trail keeps the reason                                                        |
| 400    | `invalid_grant`           | "The assertion was issued to another client.", "The assertion was already used." or "This assertion asks for something this app does not support yet.", which only the client's holder can cause |
| 400    | `invalid_grant`           | The company's connected-apps sentence: "Your company does not allow connecting this app to your account. Ask your IT team."                                                                      |
| 400    | `invalid_scope`           | "The requested scope is beyond the assertion's."                                                                                                                                                 |
| 400    | `invalid_target`          | "The requested resource is beyond the assertion's."                                                                                                                                              |
| 503    | `temporarily_unavailable` | The single-use check could not be made ("The assertion could not be checked. Try again later."), or your Application token URL did not mint a token                                              |

A network that names another company is answered with the generic `invalid_grant` here, not `tenant_restricted` as the other grants answer.

## 5. What your app receives

Nothing in your app sees an ID-JAG. BuildBase calls your **Application token URL** as for every grant, signed with the client's secret (the base client's, for a metadata-document agent), with two differences:

- `grantType` is `"id_jag"`.
- `expiresInMax`, in seconds, is the longest your token may live: the BuildBase session inside it (`sessionId`) ends then.

The rest is as for every grant: the person (`id`, `email`, `name`, `role`), `clientId`, `scope`, `resource` and `sessionId`. Bind the token to the resource and cap its life. With the current SDK, `expiresInMax` reaches your mint as an untyped claim:

```typescript
// app/api/agent/token/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { handleAppTokenRequest, mintAgentToken } from '@buildbase/sdk';

export async function POST(request: NextRequest) {
  const { status, body } = await handleAppTokenRequest({
    authorization: request.headers.get('authorization'),
    clientSecret: process.env.BUILDBASE_AGENT_CLIENT_SECRET!,
    mintToken: (claims) =>
      mintAgentToken({
        claims,
        secret: process.env.SYSTEM_SECRET!,
        expiresInSec:
          typeof claims.expiresInMax === 'number'
            ? Math.min(3600, claims.expiresInMax)
            : 3600,
      }),
  });
  return NextResponse.json(body, { status });
}
```

SDK 1.0.0 types `grantType: 'id_jag'` and `expiresInMax`, and `mintAgentToken` takes the shorter of `expiresInSec` and `expiresInMax` by itself. A mint that ignores `expiresInMax` still works: the session inside the token ends on time, so BuildBase's own tools answer 401 after it, and the agent asks again because `expires_in` says when.

On your MCP server, verify the audience with `buildbaseAuth({ resource, requireAudience: true })`, which the MCP extension requires. A token minted with no resource carries no `aud` and is then refused, so the company's identity provider, or the agent, has to name your MCP server's URL as the resource.

Your **Application revoke URL** receives `{ userId, clientId, reason }`, signed as ever, whenever a grant ends. End the token you minted for that person and client:

| `reason`                     | When                                                                                |
| ---------------------------- | ----------------------------------------------------------------------------------- |
| `token_revoked`              | The client revoked a token (RFC 7009)                                               |
| `consent_revoked`            | The person withdrew their consent on the hosted consent page                        |
| `client_deleted`             | The client deleted its own registration (RFC 7592)                                  |
| `user_disconnected`          | The person disconnected the agent in their Connected agents                         |
| `enterprise_revoked`         | The company disconnected the agent in Connected apps                                |
| `user_deprovisioned`         | The company's directory deprovisioned the person                                    |
| `user_blocked`               | The person was blocked                                                              |
| `enterprise_signout`         | The company signed the person, or everyone, out                                     |
| `enterprise_agents_ended`    | The company turned its identity provider for agents off, removed it, or was deleted |
| `enterprise_app_not_allowed` | The company's connected-apps policy stopped allowing the agent                      |
| `agent_access_off`           | Your team turned enterprise-managed authorization off                               |

The last six are new. All but `enterprise_agents_ended` and `agent_access_off` reach every agent's grant, a person's own consent included, not only those an identity provider approved.

## How access ends

| What happens                                                 | What ends, and how fast                                                                                                                                                                                                                                        |
| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The identity provider stops approving the agent (unassigned) | No new ID-JAG. The live token runs to its end, at most the company's lifetime. No refresh token outlives it                                                                                                                                                    |
| The person is deprovisioned by the company's directory       | At once: they are blocked, every session ends, agent sessions included, your revoke URL hears `user_deprovisioned`, and later assertions are refused (`person_blocked`)                                                                                        |
| The company signs one person, or everyone, out               | At once: sessions and grants, and your revoke URL hears `enterprise_signout`. The identity provider may approve the agent again, so the company unassigns it there to keep someone out                                                                         |
| The company turns its identity provider off, or removes it   | At once, on every server: every live session it approved, your revoke URL hears `enterprise_agents_ended`, and later assertions are refused (`issuer_disabled`). A grant being made at that moment reads the issuer again once its session exists, and ends it |
| The company's policy stops allowing an agent                 | At once: that agent's live sessions among the company's people, however they were approved, your revoke URL hears `enterprise_app_not_allowed`, and later assertions are refused (`app_not_allowed`). Its grants stay, so allowing it again lets it back       |
| The company disconnects an agent in Connected apps           | As for any agent: its grant, refresh tokens and sessions, and `enterprise_revoked`. The identity provider may approve it again                                                                                                                                 |
| The person disconnects it in their Connected agents          | As for any agent, `user_disconnected`. The identity provider may approve it again                                                                                                                                                                              |
| The company's maximum session length                         | The agent's session never outlives it                                                                                                                                                                                                                          |
| You turn the switch off                                      | Every live session any company's identity provider approved, `agent_access_off`; later requests `unsupported_grant_type`                                                                                                                                       |

An agent session never changes the company's settings. Inside a session an agent got this way, every write to the company's own settings in the app (its identity provider for agents, its access policy, admins, roles, groups, keys, log streams, sign-outs, joining and setup links) is refused with 403 `agent_session_refused`, whatever the person it acts for may do; reads are not. An agent approved for one of the company's Setup admins cannot change the identity provider that approved it. The refusal comes first, before the company role or a [confirmation](/authentication/enterprise-sso#confirming-it-s-you) is checked: an agent is never asked to confirm it's the person, only told it may not.

A grant the identity provider made is listed like a consent: in the company's Connected apps, as approved by the identity provider, and in the person's own Connected agents, with `source: "idp"`. It does not count as the person's own consent: an agent that sends them through the consent flow still asks them. It is always recorded, and never counts against your plan's connected agents, so a policy change, a sign-out and a disconnect always find it; it counts once the person approves the agent themselves. A policy change or a disconnect reads the company's grants by its own people, so it reaches every one of them however many grants your app holds elsewhere.

## The trail, webhooks and insights

Each company's trail records its setup and every grant:

| Entry                                                                                                 | Written when                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enterprise_agent_issuer_added`, `enterprise_agent_issuer_updated`, `enterprise_agent_issuer_removed` | The issuer was trusted, changed or no longer trusted: `issuer`, `tenant`, `jwksUri`, `keys`, `change` (`added`, `proven`, `approved`, `enabled`, `disabled`, `token_minutes`, `jwks_uri`, `removed`), `tokenMinutes`, `sessionsEnded`, `proof` for `proven` and `approved`, `reason: "proven_elsewhere"` for a pending row another company's proof removed, and `via` (`console`, `app`, `api`, or `idp` for what an assertion's proof did). Filtered as setup changes |
| `enterprise_agent_access_granted`                                                                     | An agent got a token for one of the company's people, at most once a day per issuer, person, agent and `matchedBy`, so the first grant through a new issuer or by a new kind of match is always written, and every time for a match by email: the person as actor, `clientId`, `app`, `scopes`, `resource`, `matchedBy` (`sso_link`, `directory` or `email`), `issuer`, and `assertionId`, the ID-JAG's `jti`                                                          |
| `enterprise_agent_access_refused`                                                                     | An assertion from the company's issuer was refused, at most once an hour per issuer and reason, and per subject and agent once its signature holds, so forgeries from any number of clients are one entry an hour: no actor, `reason`, and `sub` when no person matched, so the company can find who in its identity provider                                                                                                                                          |
| `enterprise_sessions_revoked` with `scope: "agents"`                                                  | Agent sessions ended together: `sessions`, `people`, `reason` (`issuer_disabled`, `issuer_removed`, `app_not_allowed` or `switch_off`), and the `issuer` or the agent                                                                                                                                                                                                                                                                                                  |

Refusal reasons are `issuer_disabled`, `bad_signature`, `wrong_audience`, `wrong_type`, `expired`, `client_mismatch`, `replayed`, `unsupported_claims`, `not_a_person`, `person_blocked`, `app_not_allowed`, `tenant_restricted` and `not_approved_by_name` (an agent with no secret that the policy does not name). An assertion from an issuer no company trusts has no company, so it is a line in your server's log only.

Two webhook events and workflow triggers, of the 199 in the [catalog](/webhooks/overview):

| Event                             | When                                                                                         | Payload                                                                                                                                                            |
| --------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `enterprise.agent_issuer_updated` | Every change to a company's issuer                                                           | `enterpriseId`, `name`, `issuer`, `tenant`, `change`, `status` (`pending`, `active`, `disabled`, or `null` once removed), `sessionsEnded` (`null` when none ended) |
| `enterprise.agent_access`         | An agent was approved for one of the company's people, or refused, throttled as the trail is | `enterpriseId`, `name`, `userId` (or `null`), `clientId`, `app`, `outcome` (`granted` or `refused`), `reason` (`null` when granted), `scopes`                      |

Endings reuse `enterprise.sessions_revoked`, with `scope: "agents"`. With [company audit logs](/authentication/enterprise-sso#company-audit-logs-in-workos) on, the five entries reach WorkOS as `company.agent_issuer_added`, `company.agent_issuer_updated`, `company.agent_issuer_removed`, `company.agent_access_granted` and `company.agent_access_refused`. The company's insights count the agents granted, the people they acted for and the refusals by reason (`agents`).

## Testing it

[xaa.dev](https://xaa.dev/), Okta's cross-app access playground, plays the requesting agent against your app's authorization server. You need an Okta org with Cross App Access turned on (an Okta Integrator Free Plan org works; Okta Developer Support turns the feature on), a test company whose single sign-on uses that org, and an agent client for the playground in your console, with a secret. Set the company up as on the [IT admin page](/authentication/enterprise-sso-it-admin#agents-through-your-identity-provider), give the playground that client's ID, and follow its steps. The company's trail shows each grant and refusal with its reason.

## Not supported yet

- It is built for Okta, and these pages cover no other identity provider; one company trusts one identity provider.
- People are never created from an assertion, and a SAML `sub_id` is not read: `sub` and `email` find the person.
- Proof of possession (`cnf`, DPoP) and rich authorization requests (`authorization_details`) are refused.
- A metadata-document agent cannot authenticate with `private_key_jwt`.
- Okta's revocation reaches your app through short tokens, not a signal: there is no Shared Signals (CAEP) feed.

Okta limits how many ID-JAGs it issues per person and app each month as part of SSO, and each token spends one; see [Okta's Cross App Access guide](https://developer.okta.com/docs/guides/xaa-agent-to-app/main/). A longer lifetime spends fewer.

## Next steps

- [Enterprise SSO for IT admins](/authentication/enterprise-sso-it-admin#agents-through-your-identity-provider) - the page to send the company's IT admin
- [MCP and agents](/reference/mcp-agents) - your MCP server, the token mint and how agents connect
- [Company API](/authentication/enterprise-company-api#agents-through-the-identity-provider) - reading and switching it off with a company key
