Skip to content
BuildBase

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

Before you start

A plan that includes agent readiness, an Agent (third-party) client in the console (see MCP and agents), and 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

PieceWhere it livesWho sets it up
The switch, and the longest tokenConsole, Authentication, Agent readinessYou, once
Agents identified by a metadata document, for ClaudeSame tab, a second switchYou, once
The agent's client in your appA registered agent client, or one made from the agent's metadata document on first useYou, 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 consoleThe company's IT admin, or your team
Cross App Access for your appThe company's Okta Admin ConsoleThe company's IT admin
Which agents may act for its peopleThe company's connected-apps policy (connected apps)The company, or your team
The token the agent usesYour Application token URLYour app, as for every grant

The flow

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

Over the API:

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

{
  "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:

ClientAuthenticates withTakes this grant
An agent client you register in the console (Agent (third-party))Its secret, as HTTP Basic or in the bodyYes. 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 DocumentNothing: a public client whose URL is its client IDYes, 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 (<EnterpriseAgentAccess />, 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, 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.
  • 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), never turn it on or change the issuer.

With several identity providers

A company with 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.

StatuscodeWhen
400agent_issuer_invalidNot 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
404agent_issuer_not_foundTurning off, changing, checking or removing while the company trusts no issuer
409agent_access_offSaving an issuer, or turning one back on, while your switch is off
409agent_issuer_takenAnother 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:

# 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"
RouteDoes
GET .../agent-accessThe 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/checkReads the discovery document and keys again; answers the view with checked: { discovery, keys }
POST .../agent-access/approveConsole only: approves a pending issuer, active at once. 409 agent_issuer_taken while another company holds it active
DELETE .../agent-access/issuerStops 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:

{
  "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

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

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:

{
  "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.

StatuserrorWhen
400unsupported_grant_typeYour switch is off, or your plan does not include agent readiness
401invalid_clientNo client, an unknown one, a wrong secret, or a metadata-document URL your app cannot resolve
400unauthorized_client"This client cannot use the JWT bearer grant.": an app client, or a dynamically registered one
400invalid_request"The assertion is required." or "The assertion is too large."
400invalid_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
400invalid_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
400invalid_grantThe company's connected-apps sentence: "Your company does not allow connecting this app to your account. Ask your IT team."
400invalid_scope"The requested scope is beyond the assertion's."
400invalid_target"The requested resource is beyond the assertion's."
503temporarily_unavailableThe 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:

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

reasonWhen
token_revokedThe client revoked a token (RFC 7009)
consent_revokedThe person withdrew their consent on the hosted consent page
client_deletedThe client deleted its own registration (RFC 7592)
user_disconnectedThe person disconnected the agent in their Connected agents
enterprise_revokedThe company disconnected the agent in Connected apps
user_deprovisionedThe company's directory deprovisioned the person
user_blockedThe person was blocked
enterprise_signoutThe company signed the person, or everyone, out
enterprise_agents_endedThe company turned its identity provider for agents off, removed it, or was deleted
enterprise_app_not_allowedThe company's connected-apps policy stopped allowing the agent
agent_access_offYour 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 happensWhat 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 directoryAt 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, outAt 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 itAt 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 agentAt 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 appsAs 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 agentsAs for any agent, user_disconnected. The identity provider may approve it again
The company's maximum session lengthThe agent's session never outlives it
You turn the switch offEvery 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 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:

EntryWritten when
enterprise_agent_issuer_added, enterprise_agent_issuer_updated, enterprise_agent_issuer_removedThe 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_grantedAn 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_refusedAn 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:

EventWhenPayload
enterprise.agent_issuer_updatedEvery change to a company's issuerenterpriseId, name, issuer, tenant, change, status (pending, active, disabled, or null once removed), sessionsEnded (null when none ended)
enterprise.agent_accessAn agent was approved for one of the company's people, or refused, throttled as the trail isenterpriseId, 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 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, 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, 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. A longer lifetime spends fewer.

Next steps