Authenticate agents with your identity provider
Let an agent that already has an identity call the data plane with the token its platform or your directory issued, instead of a virtual key you minted and handed to it. Policy can then read who the agent is and who it is acting for.
A virtual key is the credential of whatever holds it. That is the right shape for a service that has nothing else. It is the wrong one for a workload that already carries a token its platform rotates for it, or for a person’s application that already signed in against your directory. Both of those have an identity a provider vouches for. Handing them a second, static credential to carry alongside it is the antipattern every workload-identity standard warns about. This page makes the token they already hold the credential the gateway accepts.
You will add the issuer to the node’s issuers list, bind it under
auth.issuers with a data-plane audience, and write rules over agent
and user.
Before you start
Section titled “Before you start”Have the issuer reachable and its audience settled. A token is accepted
only if it names the data plane in aud. Whatever mints the tokens has
to be told which audience to mint for, whether that is a Kubernetes API
server projecting a service account token or an identity provider
issuing an access token to an application. That is a setting at the
issuer, and it is the step most likely to send you back to its
console.
If the same directory already authenticates admin callers, the issuer
is already in issuers and this page adds one binding. If not, see
Connect your identity provider for
what the issuer entry needs, and in particular for a Kubernetes API
server’s ca_file and token_file.
Declare the issuer and bind it
Section titled “Declare the issuer and bind it”The issuers are the node’s: how this node reaches them and what it trusts, with paths a node can hold. In the node file:
issuers: - name: cluster url: https://kubernetes.default.svc.cluster.local ca_file: /etc/pistra/kube-ca/ca.crt token_file: /etc/pistra/kube-sa/token - name: corp url: https://login.example.comThe bindings are the deployment’s: which issuer admits whom, into which profile. In the deployment document:
auth: virtual_keys: true # keep accepting minted keys as well issuers: - issuer: cluster audience: https://ai.example.com subject: agent # sub is the workload profile: agents - issuer: corp audience: https://ai.example.com subject: user # sub is a person; the app is in client_id profile: staff
profiles: - name: agents budget: agents-monthly - name: staff allowed_models: ["gpt-4o-mini", "claude-haiku-*"]The top-level entry is the same one the admin API binds to, and it is
written once. The binding differs per plane. The admin API accepts
pistra-admin in aud and derives roles. The data plane accepts the
front door’s URL and derives a principal. A token minted to administer
the gateway is not a token to send traffic through it, and the two
audiences keep that true.
subject is required, and it is the one thing a token does not say
about itself. Under agent the subject is the workload and there is no
user. A service account acts for itself. Under user the subject is a
person, and client_id names the application acting for them, or azp
where the provider spells it the OIDC way. A token with neither has a
user and no agent.
profile puts callers from this issuer under a policy the way a key’s
profile does: allowed models, a budget, rules, which guardrails inspect
them. It is optional, and unlike a key’s it is checked when the
configuration loads, because both halves are configuration. A binding
naming a profile that does not exist is refused where the mistake was
made.
The gateway decides by shape which credential it was given. A JWT is offered to the bound issuers and to nothing else. Anything else is looked up as a key. Neither kind of failure tells the caller which issuer refused it or why. That goes to the log. A key presented at a deployment that accepts none is refused as the unknown credential it is.
Let a client find the authorization server
Section titled “Let a client find the authorization server”A client that holds no token yet has to find out where to get one, and
the OAuth answer is that the resource tells it. Every 401 the data
plane sends carries a WWW-Authenticate challenge, and once
public_url is set the challenge names a metadata document:
HTTP/1.1 401 UnauthorizedWWW-Authenticate: Bearer resource_metadata="https://ai.example.com/.well-known/oauth-protected-resource/mcp/github"The response looks like this:
{ "resource": "https://ai.example.com/mcp/github", "authorization_servers": ["https://kubernetes.default.svc.cluster.local", "https://login.example.com"], "bearer_methods_supported": ["header"], "resource_name": "github"}That is RFC 9728. An MCP client reads it before it starts an OAuth
flow: the resource it is talking to, and the authorization servers
that mint for it. Those servers are the issuers bound under
auth.issuers, because your identity provider is the authorization
server here. Pistra mints nothing. It says who does. The gateway
itself has a document at /.well-known/oauth-protected-resource, and
each MCP server has one under its own path, under either of its names
if it has a registry identity. Until public_url is set the endpoint
answers 501 and says to set it. The document’s first field is the
resource’s own URL, and guessing it from a Host header would
publish whichever address the asker used.
Tokens for one server. A client can ask its authorization server
for a token scoped to the resource it is about to call, with a resource
indicator (RFC 8707), and the server puts that URL in aud. The gateway
accepts such a token at that resource and nowhere else. A token for
https://ai.example.com/mcp/github does not work at /mcp/jira, nor
on the model routes. That sits alongside the binding’s own audience,
which is the umbrella. You configure the umbrella, and a client can
choose the narrowing per call.
Registering the client. MCP clients prefer to register themselves at the authorization server dynamically, and enterprise identity providers mostly refuse that. With your provider as the authorization server, each client application is registered there once by an administrator. The enterprise model chooses that friction on purpose. The provider decides which applications may obtain a token, and for which resource.
Choose the profile from a claim
Section titled “Choose the profile from a claim”One profile per issuer is the right shape for a platform whose subjects
are all the same kind of thing. A directory is not such a platform. A
person’s groups say what policy they should be under, the way an admin
caller’s groups say what roles they hold. profiles reads a claim and
chooses:
auth: issuers: - issuer: corp audience: https://ai.example.com subject: user profile: staff # the fallback profiles: claim: groups select: - value: "CN=ml-platform,OU=Groups" profile: ml - value: "CN=finance,OU=Groups" profile: financeselect is an ordered list rather than a map, because a person in two
listed groups can be under only one policy. The configuration decides
which, not the order the token happens to list their groups in. The
first entry whose value the claim carries wins. A token matching
nothing falls back to profile, and with no fallback is under no
profile at all.
The claim has to be one the issuer assigns. Choosing from email or
preferred_username would let a caller choose their own budget and
models, so it is refused when the configuration loads. The same
trust_claim escape the admin plane’s roles block has applies for a
directory that owns its mailboxes. A claim the issuer withheld is
refused as profile_unresolved, with the reason in the trail, rather
than silently putting the caller under the fallback. That covers a
directory that sends a pointer instead of the groups when there are
too many.
Write rules over who is calling
Section titled “Write rules over who is calling”Every rule environment, access rules, MCP rules and guardrail rules, carries three variables about the caller. Every one is always present, so a condition never has to guard for absence:
| variable | fields | a virtual key | an agent token |
a user token |
|---|---|---|---|---|
key |
name, metadata, profile |
the key | empty name, the binding’s profile | empty name, the binding’s profile |
agent |
name, issuer, subject |
the key’s name | the workload | the application in client_id |
user |
name, issuer, subject, email |
empty | empty | the person |
user.email is a display field, and an issuer may decline to vouch
for it: a token that carries email_verified: false leaves it empty,
the same check the profile claim gets. Match a person on subject.
So a rule that confines a workload reads the agent, and one that confines a person reads the user, whichever credential carried them:
access_rules: - name: only-the-ci-agent-may-use-the-frontier-model at: [llm] condition: >- model.startsWith("gpt-5") && agent.subject != "system:serviceaccount:platform:ci" action: deny - name: writes-need-a-person-behind-them at: [mcp] condition: >- server == "jira" && name.startsWith("create") && user.subject == "" action: deny message: "creating issues needs a signed-in person, not a bare workload" - name: contractors-act-nowhere condition: agent.name.startsWith("contractor-") action: denyThe third rule names no hop, so it runs at every one, the model
request, the tool call, the peer agent. It may read only who is
calling: key, agent and user. That is the shape for “this
identity, nowhere”. A rule that reads a hop’s own variables says which
hop with at.
user.subject == "" is how a rule says “nobody is acting for a person
here”. The variable is a map with empty strings rather than an absent
key, so the comparison is total and the checker in the console accepts
it. key.profile reads the same policy whichever credential put the
caller under it, so a rule written against a profile does not care.
What the trail records
Section titled “What the trail records”A request under a token is recorded with the kind oidc rather than
virtual_key, and carries the issuer and the subject beside the
readable name, the way an admin caller does:
{"event":"request.auth","outcome":"denied","actor":{"kind":"oidc","name":"ci","issuer":"cluster","subject":"system:serviceaccount:platform:ci"},"target":"chat_completions","rule":"only-the-ci-agent-may-use-the-frontier-model","reason":"policy_denied"}A budget under a profile is drawn per caller. The caller is the stable
spelling, the issuer and the subject, rather than the readable name,
so a bucket survives a rename. Logs and spans carry the readable one
as principal.
Stop a caller now
Section titled “Stop a caller now”A key can be revoked, because the gateway holds it. A token cannot be revoked, because it is verified, not held. When your provider deactivates the person or the workload it stops minting new tokens, while every token already out stays good until it expires, an hour at most providers, minutes at some. For that window the gateway refuses the caller instead of the credential, by the issuer-qualified name the trail records them under:
$ curl -sS -X PUT https://ai.example.com/admin/v1/suspensions/principal:corp%2F0oa1b2c3 \ -H "Authorization: Bearer $ADMIN" \ -d '{"reason":"laptop reported stolen","duration":"2h"}'The slash in the name is percent-encoded in the path, as a registry
name’s is. The console does that for you. It is a
suspension like any other:
lease-bounded, replicated, in force on every node within the raft
commit, and gone when the lease lapses with nothing to lift. Give it a
lease at least as long as the provider’s tokens live. Every token that
subject presents is refused with 403 and principal_suspended in the
trail. Another subject from the same issuer is untouched. A key is not
a principal, so revoke that instead. A suspension naming an issuer the
data plane does not bind is accepted with a warning that it subtracts
nothing.
Act as the caller at an MCP server
Section titled “Act as the caller at an MCP server”Everything above is about the gateway knowing who is calling. The
servers behind it do not know. At the MCP hop the gateway strips the
caller’s token and sends its own credential, a static key or a
client_credentials grant, so an upstream sees the gateway every
time, whoever asked. That is right for a server that should see one
service account. It is wrong for a Jira, a Graph or an internal API
that has its own idea of what each person may read. The gateway’s
credential can only ever be as wide as its widest caller.
The fix is the exchange your identity provider already offers. A
token_exchange credential presents the caller’s token back to the
issuer that minted it and asks for one minted for the upstream, with
the gateway named as the client acting for them. The gateway mints
nothing and holds nothing. The provider applies whatever policy it has
for that person, that application and that target, and the upstream
sees the person.
credentials: - name: jira-as-user token_exchange: issuer: corp # an entry in the node's issuers client_id: pistra client_secret: ${secret:jira-exchange} audience: https://jira.internal # what the issuer calls the target scopes: [read:issues]
mcp: - name: jira url: https://mcp.jira.internal/mcp credential: jira-as-userThe grant is RFC 8693 token exchange, which Okta, Keycloak, Auth0 and
most providers speak. The token endpoint comes from the issuer’s
discovery document, so nothing here names a URL. Some providers mint by
resource (the upstream’s URL) rather than by audience. Set
whichever yours documents. Microsoft Entra spells the same idea as its
on-behalf-of grant, and names the target through a scope:
token_exchange: issuer: entra client_id: ${secret:entra-client-id} client_secret: ${secret:entra-client-secret} grant: on_behalf_of scopes: ["api://graph-mcp/.default"]At the provider, register the gateway as a confidential client and allow it to exchange for the target, an “on-behalf-of” or “token exchange” policy in most consoles. The caller’s own token must be one the provider will exchange. An access token minted for the gateway’s audience is, for every provider named above.
Three things follow from acting as the caller, and the gateway says each of them out loud:
- Only that issuer’s tokens reach the server. A virtual key has no
identity at an identity provider, and a token from another issuer is
not one this issuer will exchange. Both are refused with 403 and
not_exchangeablein the trail, before the MCP rules run and without a request to the provider. A server everyone should reach as the gateway keeps a key or aclient_credentialscredential. - The exchanged token is cached for the subject token’s sake. One presented token is exchanged once and the result reused until it is about to expire. The next token the caller presents is a new exchange. The provider is not asked once per tool call.
- The provider failing is the gateway’s failure, not the caller’s.
An exchange the provider refuses or cannot answer is a 502 with
upstream_auth_failed, the same as a failingclient_credentialsgrant, so a client never sees a 401 for a token that was good.
The discovery probe on the admin API, which authenticates as the
gateway, cannot probe a server that only knows callers, and it says
so. The issuer named must be one in the node’s issuers list. A
credential that could send every caller’s token to a URL of a config
writer’s choosing would be the escalation the node-owned list exists
to prevent. The caller’s client_secret is a secret field like any
other, and a literal is refused where a reference is expected.
What this does not do yet
Section titled “What this does not do yet”The gateway verifies tokens and mints none for the data plane. An agent has to already hold a token an issuer here signed. The exchange above narrows that identity to one upstream at a time, but only at the provider’s hand and only for MCP servers. There is no way yet to bind a virtual key to a person, nor to carry a chain of agents that each acted for the one before across more than one hop. This page is the part those would build on: a principal that every rule and every record already spell one way, and a resource that already says who mints for it.