How an agent's token is minted
When an agent calls a protected API, it presents a short-lived OAuth 2.0
access token. Most of the time that token is not produced by token exchange —
it is minted with the plain client_credentials grant, where the workload’s
JWT-SVID authenticates the client.
The mental model in one line:
The SVID proves which workload is asking (it becomes the token’s
act). The Agent Registry lookup supplies whose authority it acts under (it becomes the token’ssub = user:<id>).
This page traces that mint end to end. For the delegation path (a parent handing attenuated authority to a child via RFC 8693 token exchange), see Defining Policy → Delegation.
1. The trigger
The agent never talks to IdentityServer directly. It asks its sidecar for a
scoped token through the SDK’s TokenClient:
const accessToken = await tokens.getToken('sample-api-a:write');That call hits the sidecar’s local endpoint, GET /token?scope=sample-api-a:write.
In the handler (cmd/agent-sidecar/main.go),
a request with no subject_token and no audience falls through to the
default branch — a cached client_credentials mint (tc.get(scope)). The
other two branches are the delegation paths (exchange, and minting a
delegation token) and are not used here.
The scope itself comes from the agent’s contract. For global-worker it is the
SCOPE env default in its template
(agents/global-worker/template.json):
"envDefaults": { "SCOPE": "sample-api-a:write" }2. The request
tc.get does two things (cmd/agent-sidecar/main.go):
Fetch a fresh JWT-SVID from the SPIRE Workload API, with the Audience set
to the IdentityServer token URL. SPIRE shapes the SVID’s sub from the pod’s
labels using the ClusterSPIFFEID template
(deploy/spire/clusterspiffeid.yaml):
spiffe://cluster.local/agent/{tenant-id}/{user-id}/{agent-type}/{agent-id}So the SVID’s subject is, for example,
spiffe://cluster.local/agent/tenant-1/alice/global-worker/agent-22962c27.
POST to /connect/token with the SVID as the client credential (RFC 7523
“private_key_jwt”–style client authentication):
grant_type=client_credentialsclient_id=global-worker # the agentTypeclient_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearerclient_assertion=<the JWT-SVID>scope=sample-api-a:writeThe password-style ClientSecrets in
identityserver/Config.cs are deliberately
placeholders — the SVID is the real credential.
3. The mint (server side)
IdentityServer processes the request in two custom stages, then emits the token.
Authenticate the client.
SpireClientSecretValidator
sees the jwt-bearer client_assertion, verifies the SVID against SPIRE’s JWKS,
and resolves the client (global-worker), whose AllowedScopes in
Config.cs include sample-api-a:write.
(Requests that carry a normal secret instead — e.g. the dashboard’s human login —
are delegated to Duende’s built-in validator.)
Stamp the identity.
AgentRegistryValidator runs
only on the client_credentials path. It:
- Extracts the agent id from the SVID’s
sub(the last path segment,agent-22962c27) and looks the agent up in the registry, requiring it to beactivewith auserId. - Adds
sub = user:<userId>— the human principal the agent acts for. TheuserIdwas set at spawn (the dashboard injects the logged-in user → orchestrator pre-register → registry record). - Adds
act = { sub: <the SVID's spiffe id> }— the agent as the actor.
Emit the token. Duende fills the rest: iss from the configured issuer
(Program.cs — the in-cluster
http://identity-server:8080, which the resource servers validate against),
aud from the ApiResource that owns the granted scope (sample-api-a owns
sample-api-a:write, per Config.cs), a
default exp, and a random jti.
4. Worked example: the global-worker token
A token minted by this flow, decoded:
{ "iss": "http://identity-server:8080", "aud": "sample-api-a", "scope": ["sample-api-a:write"], "client_id": "global-worker", "sub": "user:alice", "act": { "sub": "spiffe://cluster.local/agent/tenant-1/alice/global-worker/agent-22962c27" }, "iat": 1781052922, "exp": 1781056522, "jti": "43246C4DC2CB46EBAC446647EFEA59AE"}Where each claim comes from:
| Claim | Source |
|---|---|
iss | IdentityServer’s configured issuer (Program.cs) |
client_id: global-worker | the client_id in the request — the agentType |
scope: sample-api-a:write | template SCOPE → sidecar /token?scope= → form scope |
aud: sample-api-a | the ApiResource owning that scope (Config.cs) |
sub: user:alice | AgentRegistryValidator, from the registry record’s userId (set at spawn) |
act.sub: spiffe://…/tenant-1/alice/global-worker/agent-22962c27 | the SVID’s own sub, shaped by the ClusterSPIFFEID template from pod labels |
exp − iat = 3600 | Duende’s default token lifetime (this client sets no override) |
The payoff: the resource server can see both who (sub = user:alice) and
what is acting on their behalf (act = the agent’s SPIFFE identity), from a
token the agent obtained with nothing but its cryptographic workload identity.
Where token exchange differs
The mint above is for an agent calling an API with its own authority. When a
parent delegates to a child, the child instead presents the delegation token
it was handed as the subject_token of an RFC 8693 token exchange, with its
own SVID as the actor_token — producing a token whose act chain is extended
(child on top of parent) and whose scope is attenuated to the parent’s grant.
That path, and every check it enforces, is covered in
Defining Policy → Delegation.
In all cases the SVID is the client credential; only the grant changes.