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
| 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) | The company, or your team |
| The token the agent uses | Your Application token URL | Your 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- The person signs in to the agent with their company's identity provider.
- The agent asks the identity provider for an ID-JAG for your app: an RFC 8693 token exchange whose
audienceis your app's issuer, with theresource(your MCP server's URL) and scopes it wants. Okta answers only when the company has connected that agent to your app. - The agent presents the ID-JAG at your app's token endpoint.
- 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". - 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 answersunsupported_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 asclaude.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:
| 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:
httpsand 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_idmust equal its URL exactly, itstoken_endpoint_auth_methodmust be absent ornone, and itsredirect_uris(at most 20) must be ones a client may register. Itsclient_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 asclient.identifiedBy. The document vouches only for itself, so the consent page also marks the agentunverified, 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 isinvalid_client. - Recorded. The first resolution is in your auth log as
client_registered, withmetadataDocument: { 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 (403reauth_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
activethe 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 itsjti, 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_removedwithreason: "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:
- Requires your switch on (409
agent_access_offotherwise) and an issuer that ishttpswith no user name, query or fragment, at most 512 characters. It is stored exactly as typed, since an ID-JAG'sissis compared character for character. - 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. - 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'sissuermust equal what was typed exactly. An address the guard refuses and one that does not answer are bothdiscovery_unreachable(jwks_unreachablefor 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. - 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. - Notes whether the identity provider says it issues agent grants (
identity_chaining_requested_token_types_supportednaming 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:
# 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:
{
"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:
- Your switch is on.
- The client authenticates, and is a registered agent or a metadata-document agent.
- The assertion is a JWT of at most 8 KB.
- Its unverified
iss(andtenant) names the one company that trusts it, before anything about its signature. - Its header:
typoauth-id-jag+jwt(with or withoutapplication/), analgof RS256, RS384, RS512, PS256, PS384, PS512, ES256 or ES384, akid, and no key or key URL of its own (jku,x5u,jwk,x5c,crit). - The company's issuer is turned on, the company is active, and your enterprise sign-in is on.
- The key, from that company's stored key set only, fitting the
alg. An unknownkidreads the key set again, at most once a minute. - The signature,
audexactly your issuer (an array of one at most),exp,iatno more than 60 seconds ahead, and a lifetime (exp - iat) of an hour at most, with 60 seconds of clock skew. - Its
client_idis the client that presented it. - No
cnfand noauthorization_details: refused rather than ignored. scopeandresourcewithin the assertion's.- Its
jtiis redeemed once per issuer until it expires, so a captured assertion cannot be used again. - The person, among the company's own people only (below), not blocked and not deprovisioned.
- 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.
| 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:
grantTypeis"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:
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 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:
| 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 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_idis not read:subandemailfind 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
- Enterprise SSO for IT admins - the page to send the company's IT admin
- MCP and agents - your MCP server, the token mint and how agents connect
- Company API - reading and switching it off with a company key