Propagate a user's identity across services
This guide shows how a workload that acts for a person passes that person's identity to the services it calls, so each service knows both which workload is calling and on whose behalf. Use it when a workload, such as a web application's backend or an AI agent, calls other services for a signed-in user, and those services need to make decisions based on the user.
How it works
The workload starts with a token for the user, such as the ID token or access token your identity provider issued when the user signed in to your application. Using OAuth token exchange, the workload trades that token for a new one that it can pass to the services it calls. Defakto offers two kinds:
- On-behalf-of (OBO) tokens. An OBO token is a JWT-SVID for the calling workload, with an added
oboclaim that names the user. Any service that accepts JWT-SVIDs also accepts OBO tokens, and can find the user in theoboclaim. Each service that calls further exchanges the OBO token it received for a new one whose audience is the next service, adding itself to the record of workloads that acted for the user. - Transaction Tokens. A Transaction Token, defined in an IETF draft standard, records who started a transaction. A workload at the edge of your system gets one Transaction Token, and every service passes it along unchanged, in addition to the credential that service already uses to authenticate its calls.
Choosing between them
Use on-behalf-of tokens when each service should see the whole story in one token: who the user is, and every workload that has acted for them. Because an OBO token is a JWT-SVID, services and tools that already accept JWT-SVIDs, such as API gateways and JWT authorizers, receive both identities without any changes. Each hop exchanges the token again, so your token policy can check every hop, and the audit trail shows the full chain of calls.
For example, suppose a request from a user, Alice, passes through three services: a web backend, then an orders service, then a payments service. Here is the OBO token at the second hop. The obo claim names Alice, and the workloads that acted for her, most recent first (standard claims such as iss and exp are left out for brevity):
{
"sub": "spiffe://example.com/ns/orders/sa/api",
"aud": "spiffe://example.com/payments",
"txn": "b7814530c95811d288ac0390515c61af",
"obo": {
"iss": "https://idp.example.com",
"sub": "alice-8f3k2",
"sub_profile": "user",
"via": "idToken",
"act": {
"iss": "spiffe://example.com",
"sub": "spiffe://example.com/ns/orders/sa/api",
"act": {
"iss": "spiffe://example.com",
"sub": "spiffe://example.com/ns/web/sa/backend"
}
}
}
}
Use Transaction Tokens when the way your services authenticate each other can't carry the user. For example, mutual TLS with X.509-SVIDs identifies the calling workload, but has no place for information about the user. A Transaction Token travels in addition to the existing credential, so services keep authenticating each other the way they do today. Transaction Tokens also suit requests that fan out to many services: You get one token at the edge and pass it along unchanged, instead of exchanging a token at every hop. The trade-off is that a Transaction Token records who started the transaction, not which workloads it passed through.
For the same request, the web backend gets one Transaction Token at the start, and the orders and payments services each receive it unchanged, in addition to the caller's own credential (standard claims are left out for brevity):
{
"sub": "alice-8f3k2",
"aud": "example.com",
"txn": "97053963771d49cfa1bc8a2b4b4fd1ed",
"scope": "orders.write"
}
| On-behalf-of tokens | Transaction Tokens | |
|---|---|---|
| What the token records | The user, and every workload that acted for them | The user who started the transaction |
| The user's token it's exchanged from | An ID token or an access token | An access token |
| How a service receives it | As the calling workload's credential | As an additional token, sent with each call in addition to the workload's own credential |
| How often it's exchanged | At every hop, each checked by your token policy | Once, at the edge |
To get either kind, a workload authenticates to the OAuth server with its JWT-SVID or its X.509-SVID.
Set up the trust domain
Your Defakto administrators do this once for the trust domain. After that, the owners of each workload and service follow either Use on-behalf-of tokens or Use Transaction Tokens. If your trust domain is already set up, skip to one of those sections.
Step 1: Add your identity provider
Workloads exchange the token your application received when the user signed in with your identity provider. Depending on your identity provider, and on what your application keeps from the sign-in, that's an OpenID Connect ID token, an access token, or both. Configure only the kind your workloads will exchange. Transaction Tokens can only be exchanged from access tokens.
Add your identity provider to the trust domain's OAuthServer configuration, creating the configuration if the trust domain doesn't have one yet:
section: OAuthServer
schema: v1
spec:
endpoints:
publicBaseURL: https://td-server.defakto.example.com
identityProviders:
- name: corp-idp
issuer: https://idp.example.com
# If workloads exchange ID tokens:
idTokens:
audiences: [web-app]
# If workloads exchange access tokens:
accessTokens:
audiences: [https://api.example.com]
publicBaseURLis the address where you host your Trust Domain Servers. If Defakto hosts your Trust Domain Servers, leave it out.issueris your identity provider's issuer URL. Defakto reads the identity provider's discovery document from<issuer>/.well-known/openid-configuration, and verifies each token with the signing keys it lists.idTokens.audiencesis the audience of the ID tokens your application receives, usually your application's client ID.accessTokens.audiencesis the value of theaudclaim in the access tokens your application receives. It depends on your identity provider and on what your application requested when the user signed in, so check a decoded token. Access tokens must be JWTs with anaudclaim.
Defakto accepts a token only when its audience is in the list, so a token your identity provider issued for some other application can't be exchanged.
Depending on your identity provider, you might need additional token validation rules. For example, with Google as your identity provider, Defakto requires a rule that accepts only accounts from your company's Google Workspace domain. Otherwise, Defakto would accept tokens for anyone with a Google account who signs in to your application. You can also add rules of your own, such as requiring a verified email address. For details, see Issuer-specific validation rules.
Apply the configuration to your trust domain. You can apply Managed Configuration through the Defakto API, Terraform, or the Defakto CLI. See applying configuration. For example, with the CLI:
defakto config set trust-domain --id <trust-domain-id> oauth-server.yaml
Step 2 (optional): Restrict access
With the configuration from step 1, any workload in your trust domain can exchange a user's token for any service. Each receiving service still decides what to allow, based on the user and the workloads named in the token.
A token policy adds a central control in front of that. Workloads that the policy doesn't allow never receive a token for the user, so they can't act for the user at all. For example, this policy lets the web backend act for users only toward the orders service, lets the orders service act for them only toward the payments service, and refuses every other request:
spec:
# endpoints and identityProviders as in step 1
policy:
authorization:
unmatched: deny
allow:
- name: backend-acts-for-users
match:
caller:
spiffeID: spiffe://example.com/ns/web/sa/backend
user:
identityProvider: corp-idp
requestedTokenType: urn:defakto:security:token-type:jwt-svid-obo
audience: spiffe://example.com/orders
- name: orders-acts-for-users
match:
caller:
spiffeID: spiffe://example.com/ns/orders/sa/api
user:
identityProvider: corp-idp
requestedTokenType: urn:defakto:security:token-type:jwt-svid-obo
audience: spiffe://example.com/payments
See the token policy reference for every kind of rule.
Use on-behalf-of tokens
The owners of each workload and service in the chain do this.
You need:
- The trust domain set up
- Workloads that can get JWT-SVIDs or X.509-SVIDs, and can reach your Trust Domain Servers
Step 1: Exchange the user's token for an OBO token
The first workload in the chain exchanges the user's token for an OBO token whose audience is the service it calls. Send a token exchange request to <address of your Trust Domain Servers>/oauth/token:
- JWT-SVID
- X.509-SVID
The workload proves its identity with a JWT-SVID sent as a client assertion:
POST /oauth/token HTTP/1.1
Host: td-server.defakto.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<the user's ID token or access token>
&subject_token_type=urn:ietf:params:oauth:token-type:id_token
&requested_token_type=urn:defakto:security:token-type:jwt-svid-obo
&audience=spiffe://example.com/orders
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-spiffe
&client_assertion=<the workload's JWT-SVID>
The workload proves its identity with its X.509-SVID as the TLS client certificate, and sends client_id set to its SPIFFE ID:
POST /oauth/token HTTP/1.1
Host: td-server.defakto.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<the user's ID token or access token>
&subject_token_type=urn:ietf:params:oauth:token-type:id_token
&requested_token_type=urn:defakto:security:token-type:jwt-svid-obo
&audience=spiffe://example.com/orders
&client_id=<the workload's SPIFFE ID>
The body is shown on separate lines for readability. Send it as a single URL-encoded form body.
| Parameter | Value |
|---|---|
grant_type | Exactly urn:ietf:params:oauth:grant-type:token-exchange |
subject_token | The user's token: the ID token or access token your identity provider issued when the user signed in to your application |
subject_token_type | urn:ietf:params:oauth:token-type:id_token for an ID token, or urn:ietf:params:oauth:token-type:access_token for an access token |
requested_token_type | Exactly urn:defakto:security:token-type:jwt-svid-obo |
audience | The service you're calling |
client_assertion_type and client_assertion | With a JWT-SVID: urn:ietf:params:oauth:client-assertion-type:jwt-spiffe, and a JWT-SVID for the workload with its audience set to your trust domain's JWT issuer URL, not its OAuth issuer URL. Find the JWT Issuer in the Defakto console, under your trust domain's Settings > Trust domain URLs. |
client_id | With an X.509-SVID: the workload's SPIFFE ID, exactly as it appears in the X.509-SVID |
A successful response has HTTP status 200 and a JSON body:
{
"access_token": "<OBO token>",
"issued_token_type": "urn:defakto:security:token-type:jwt-svid-obo",
"token_type": "Bearer",
"expires_in": 300
}
Step 2: Call the next service with the OBO token
Send the OBO token to the service you're calling, the same way you send JWT-SVIDs to it today, usually in the Authorization header:
Authorization: Bearer <OBO token>
The OBO token expires no later than the user's token it was issued from. Exchange the user's token again for each new request, or reuse the OBO token until shortly before it expires.
Step 3: Exchange the OBO token at each further hop
A service that received an OBO token, and calls another service for the same user, exchanges the OBO token for a new one whose audience is the next service:
- JWT-SVID
- X.509-SVID
The workload proves its identity with a JWT-SVID sent as a client assertion:
POST /oauth/token HTTP/1.1
Host: td-server.defakto.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<the OBO token the service received>
&subject_token_type=urn:defakto:security:token-type:jwt-svid-obo
&requested_token_type=urn:defakto:security:token-type:jwt-svid-obo
&audience=spiffe://example.com/payments
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-spiffe
&client_assertion=<the workload's JWT-SVID>
The workload proves its identity with its X.509-SVID as the TLS client certificate, and sends client_id set to its SPIFFE ID:
POST /oauth/token HTTP/1.1
Host: td-server.defakto.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<the OBO token the service received>
&subject_token_type=urn:defakto:security:token-type:jwt-svid-obo
&requested_token_type=urn:defakto:security:token-type:jwt-svid-obo
&audience=spiffe://example.com/payments
&client_id=<the workload's SPIFFE ID>
The parameters are the same as in step 1, except that subject_token is the OBO token the service received, and subject_token_type is urn:defakto:security:token-type:jwt-svid-obo. The service making this exchange authenticates with its own SVID.
The new OBO token names the same user, and adds this service to the workloads that acted for the user.
Step 4: Read the user in each receiving service
A service that receives an OBO token validates it as a JWT-SVID, with a SPIFFE library or as the JWT-SVID specification describes. Then it reads these claims, shown in the example token above:
| Claim | Meaning |
|---|---|
sub | The workload calling you |
obo.sub | The user |
obo.iss | The identity provider that vouched for the user |
obo.act | The workloads that acted for the user, most recent first. The outermost act.sub is always the same as sub. |
txn | An identifier that stays the same across every exchange for this request, so you can correlate the hops in your logs |
A JWT-SVID without an obo claim is an ordinary JWT-SVID, for a workload acting as itself.
Use Transaction Tokens
The owners of each workload and service in the chain do this.
You need:
- The trust domain set up
- Workloads that can get JWT-SVIDs or X.509-SVIDs, and can reach your Trust Domain Servers
Step 1: Get a Transaction Token at the edge
The first workload in the chain, such as your API gateway, exchanges the user's access token for a Transaction Token. Transaction Tokens can't be exchanged from ID tokens. Send a token exchange request to <address of your Trust Domain Servers>/oauth/token:
- JWT-SVID
- X.509-SVID
The workload proves its identity with a JWT-SVID sent as a client assertion:
POST /oauth/token HTTP/1.1
Host: td-server.defakto.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<the user's access token>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&requested_token_type=urn:ietf:params:oauth:token-type:txn_token
&audience=example.com
&scope=<scopes, separated by spaces>
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-spiffe
&client_assertion=<the workload's JWT-SVID>
The workload proves its identity with its X.509-SVID as the TLS client certificate, and sends client_id set to its SPIFFE ID:
POST /oauth/token HTTP/1.1
Host: td-server.defakto.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<the user's access token>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&requested_token_type=urn:ietf:params:oauth:token-type:txn_token
&audience=example.com
&scope=<scopes, separated by spaces>
&client_id=<the workload's SPIFFE ID>
| Parameter | Value |
|---|---|
grant_type | Exactly urn:ietf:params:oauth:grant-type:token-exchange |
subject_token | The access token your identity provider issued when the user signed in to your application |
subject_token_type | Exactly urn:ietf:params:oauth:token-type:access_token |
requested_token_type | Exactly urn:ietf:params:oauth:token-type:txn_token |
audience | Your trust domain name, such as example.com |
scope | The scopes of the transaction, separated by spaces. Each scope must be one of the scopes in the user's access token. |
client_assertion_type and client_assertion, or client_id | As for OBO tokens |
A successful response has HTTP status 200 and a JSON body, as the Transaction Tokens draft defines:
{
"access_token": "<Transaction Token>",
"issued_token_type": "urn:ietf:params:oauth:token-type:txn_token",
"token_type": "N_A",
"expires_in": 300
}
The Transaction Token expires no later than the user's token it was exchanged from.
Step 2: Pass the Transaction Token along
Send the Transaction Token in the Txn-Token HTTP header of every call that's part of the transaction, in addition to the credential each service already uses to authenticate the call:
Authorization: Bearer <the calling workload's JWT-SVID>
Txn-Token: <Transaction Token>
Don't send the Transaction Token in the Authorization header, which stays free for each call's own credential, such as the calling workload's JWT-SVID. Each service forwards the same Transaction Token, unchanged, in the Txn-Token header of the calls it makes. This follows the Transaction Tokens draft standard.
Step 3: Read the user in each receiving service
A service that receives a Transaction Token validates it as the Transaction Tokens draft describes, using the keys at your trust domain's OAuth JWKS Endpoint, shown in the Defakto console under Settings > Trust domain URLs, with your trust domain name as the audience. Then it reads these claims:
| Claim | Meaning |
|---|---|
sub | The user who started the transaction |
scope | The scopes of the transaction |
txn | An identifier for the transaction, the same in every service |
Troubleshooting
To see why a request failed, open your trust domain in the Defakto console, and go to Issuance > Token Exchanges.
The token endpoint returns invalid_request with subject token rejected. Defakto didn't accept the user's token. Check that the token's issuer matches the identity provider's issuer, that its audience is in idTokens.audiences or accessTokens.audiences, that subject_token_type matches the kind of token you sent, that the token passes any claimValidationRules, and that it hasn't expired.
The token endpoint returns invalid_client. Defakto couldn't authenticate the workload making the exchange. Check that the client assertion's audience is the JWT issuer URL, not the OAuth issuer URL. With mutual TLS, check that client_id is exactly the SPIFFE ID in the workload's X.509-SVID.
The token endpoint refuses the request, but the tokens are correct. Your trust domain's token policy may not allow this workload to act for the user toward this service. Ask your Defakto administrators to check the policy. The OAuth server's audit event for the request shows why it was refused.