Token policy
The policy block is the trust domain's token policy. It decides every request to the OAuth server, whatever the flow, and shapes the tokens that Defakto issues: their scopes, their lifetime, and any custom claims.
The services that receive tokens still decide what each caller may do. The token policy adds a central control in front of them. A caller that the policy doesn't allow never receives a token, so it can't make the call at all.
Example
policy:
authorization:
unmatched: deny
deny:
- name: report-generator-never-acts-for-users
match:
caller:
spiffeID: spiffe://example.com/ns/batch/sa/report-generator
requestedTokenType: urn:defakto:security:token-type:jwt-svid-obo
allow:
- name: agents-call-helpdesk
match:
caller:
spiffeID: spiffe://example.com/ns/agents/**
requestedTokenType: urn:ietf:params:oauth:token-type:access_token
audience: https://helpdesk.example.com/mcp
token:
allowedScopes: [tickets.read]
maxTTL: 2m
Fields
| Field | Required | Description | Example |
|---|---|---|---|
refuseAll | No. Default false. | true refuses every request to the OAuth server, whatever the rules say, including sign-ins and external service tokens. Use it as an emergency stop. | false |
authorization | No | The rules. | |
authorization.unmatched | Yes, when authorization is present | What happens to a request that no rule decides: deny or allow. | deny |
authorization.deny | No | Deny rules. | |
authorization.allow | No | Allow rules. | |
authorization.externalAuthorize | No | External authorizer rules. |
The three rule lists can hold up to 100 rules in total. Rule order doesn't matter, within a list or across lists.
When the configuration has no authorization block, every request that the configuration supports is allowed, except requests for external service tokens, described below: any caller, any audience, and any configured identity provider. To allow only what you choose, set unmatched: deny and add allow rules for every flow you use.
A request for an external service token is refused unless an allow or external authorizer rule matches it, and that rule's match.audience names the service's resource. This applies even without an authorization block, and with unmatched: allow. Every workload gets tokens through Defakto's one registration at the service, so the service can't tell your workloads apart. The rule is where you decide which workloads can use it.
Rules
Every rule has a name and a match block. Allow and external authorizer rules can also have a token block, and an external authorizer rule names its authorizer.
| Field | Applies to | Description | Example |
|---|---|---|---|
name | All rules | A DNS label, unique across all three rule lists. | agents-call-helpdesk |
match | All rules | Which requests the rule applies to. See Matching requests. | |
token | Allow and external authorizer rules | How the rule shapes the issued token. See Token settings. | |
authorizer | External authorizer rules | The name of the authorizer to call. | corp-pdp |
| Rule kind | Effect |
|---|---|
| Deny | Refuses matching requests. |
| Allow | Permits matching requests, and applies its token settings. |
| External authorizer | Sends matching requests to your external authorizer webhook, which makes the decision. Its token settings apply when the webhook allows the request. |
Matching requests
A rule matches a request when every field in its match block matches. A field that's absent matches any value, including a request with no value for it, such as a request with no user. Every field except requestedTokenType takes one value or a list, and a list matches any of its values.
- Allow and external authorizer rules must name a
requestedTokenType, so a kind of token added later never joins existing rules. - A deny rule must name at least one field. To refuse everything, use
refuseAll.
| Field | Matches | Rule kinds |
|---|---|---|
caller | Who is asking | All |
user | Who the caller acts for, if anyone | All |
requestedTokenType | The kind of token requested | All |
audience | The service the token is for | All |
delegatedBy | The workloads that acted for the user before the caller | Deny |
caller
The caller is whoever will use the issued token. A caller entry names exactly one of these keys. caller takes one entry or a list, and a list matches any of its entries.
| Key | Matches | Values | Example |
|---|---|---|---|
spiffeID | A workload in your trust domain, by its SPIFFE ID | One or a list of SPIFFE IDs in your trust domain, exact or as patterns. * matches one path segment, and ** matches any number of segments and can appear only at the end. | spiffe://example.com/ns/agents/** |
type | Every caller of a kind | spiffeID for workloads in your trust domain, or oauthClientID for OAuth clients that people sign in through | oauthClientID |
oauthClientID | An OAuth client, by its Client ID Metadata Document URL | One or a list of exact URLs | https://app.example.com/oauth/client-metadata |
The caller's SPIFFE ID comes from its X.509-SVID over mutual TLS, from a JWT-SVID client assertion, or from the JWT-SVID it presents in the JWT bearer grant. An OAuth client, such as Claude, is matched only when it requests a token for a person.
An OAuth client's client ID isn't verified, so another application can present the same value. Use oauthClientID to tailor settings for an application, not to restrict access.
To match several kinds of callers in one rule, list one entry for each:
caller:
- spiffeID: spiffe://example.com/ns/reporting/**
- type: oauthClientID
user
The user is the person the caller acts for, if anyone. Every user is vouched for by an identity provider, and reaches Defakto in one of three ways, which rules name with via:
via | How the user arrived |
|---|---|
idToken | A workload presented an ID token from the identity provider. |
accessToken | A workload presented an access token from the identity provider. |
login | The user signed in through Defakto, which sent them to the identity provider. |
Defakto records the identity provider and via in the tokens it issues for the user, so a rule several hops into a chain of services matches the user the same way as a rule at the first hop.
A user entry has these fields. user takes one entry or a list, and a list matches any of its entries.
| Field | Matches | Values |
|---|---|---|
none | Only requests with no user | true. Can't be combined with other fields in the same entry. |
identityProvider | The identity provider that vouched for the user | One or a list of identity provider names |
via | How the user arrived | One or a list of idToken, accessToken, and login. Without it, the rule matches users however they arrive, including through token types added to the identity provider later. |
sub | The user's identifier | One or a list of exact values. Requires exactly one identityProvider in the same entry. |
Common shapes:
user | Matches |
|---|---|
{ identityProvider: corp-idp } | Any user from corp-idp, however they arrive |
{ identityProvider: corp-idp, via: login } | Users who signed in through Defakto |
{ identityProvider: corp-idp, via: [idToken, accessToken] } | Users whose tokens a workload presented |
{ identityProvider: corp-idp, sub: alice-8f3k2 } | One user |
{ none: true } | Only requests where the caller acts as itself |
requestedTokenType
| Value | Kind of token |
|---|---|
urn:ietf:params:oauth:token-type:access_token | An OAuth access token, including an external service's token |
urn:defakto:security:token-type:jwt-svid-obo | An on-behalf-of token |
urn:ietf:params:oauth:token-type:txn_token | A Transaction Token |
A rule names exactly one value. Requests in flows that don't send a requested token type, such as the JWT bearer grant and user sign-in, request an access token.
audience
The service the token is for, compared exactly with the audience or resource the caller sent. One or a list of exact values. * isn't accepted.
- For an external service, the audience is the service's
resourcevalue. - For a Transaction Token, the audience is your trust domain name, such as
example.com. - A rule without
audiencematches any audience, including external services.
A deny rule on an audience is only as strong as the receiving service's own check of the token's aud claim.
delegatedBy
| Field | Matches | Values |
|---|---|---|
delegatedBy.contains | A re-exchanged on-behalf-of token whose chain includes a workload, not counting the current caller | One or a list of SPIFFE IDs or patterns, as for caller.spiffeID |
delegatedBy applies only to deny rules.
Token settings
The token block of an allow or external authorizer rule shapes the token that Defakto issues. A rule that omits a setting imposes nothing.
| Field | Description | Example |
|---|---|---|
maxTTL | The token's lifetime, as a duration such as 90s, 2m, or 24h, longer or shorter than the 5-minute default, up to 168h (7 days). A configuration with a longer maxTTL is rejected. The token's actual lifetime can be shorter: It's capped at the maximum for its kind of token, and never outlives the credential it was issued from. | 2m |
allowedScopes | The scopes the token may carry. A caller that requests more scopes receives only these. A caller that requests only scopes outside the list is refused with invalid_scope. Not allowed on rules for on-behalf-of tokens, which carry no scopes. | [tickets.read] |
customClaims | Claims to add to the token. See Custom claims. | dept: "{{custom.department}}" |
For external service tokens, only allowedScopes applies, within the service's grant.clientCredentials.scopes. The service's authorization server sets the token's lifetime and contents.
Custom claims
customClaims adds your own claims to the tokens a rule allows. Only on-behalf-of tokens carry custom claims. Each key is the name of a claim to add, and each value is a template that refers to a source:
token:
customClaims:
email: "{{jwt.claim.email}}"
caller: "{{caller.id}}"
dept: "{{custom.department}}"
| Source | Value | Available |
|---|---|---|
jwt.claim.<name> | A claim from the JWT the request presented. See below. | All rules |
caller.<name> | An attribute of the calling workload, such as caller.id, its SPIFFE ID | All rules |
custom.<name> | A value from your external authorizer's response | External authorizer rules |
jwt.claim.<name> reads the JWT that the request presented:
| Flow | The JWT that jwt.claim reads |
|---|---|
| On-behalf-of token, first exchange | The user's ID token or access token |
| On-behalf-of token, re-exchange | The on-behalf-of token being re-exchanged |
For example, if a user's ID token contains these claims, {{jwt.claim.email}} renders as alex.morgan@example.com:
{
"iss": "https://idp.example.com",
"sub": "user_987654321",
"aud": "web-app",
"name": "Alex Morgan",
"email": "alex.morgan@example.com",
"email_verified": true
}
jwt.claim can read only string claims. A template that is exactly one custom reference copies the value as it is, of any JSON type, including objects and lists. A template that combines text and references produces a string.
If a referenced value is missing, the request is refused, rather than issuing a token without the claim:
- A missing
jwt.claimvalue refuses the request withinvalid_request. - A
customvalue that the external authorizer's response doesn't include makes the response invalid, and the caller receivesserver_error.
Custom claims aren't carried forward automatically when an on-behalf-of token is re-exchanged. To carry a claim through a chain, add a rule for the re-exchange that copies it from the presented token, such as email: "{{jwt.claim.email}}".
Custom claims appear at the top level of the on-behalf-of token. These names are reserved, and can't be used for custom claims: iss, sub, aud, exp, nbf, iat, jti, cnf, obo, txn, auth_time, and nonce.
How rules are evaluated
Defakto checks every rule against each request, and the matching rules decide together:
- If any deny rule matches, the request is refused.
- Otherwise, if any external authorizer rule matches, Defakto calls the authorizer once, with the names of every matching external authorizer rule, and the authorizer's response decides. Matching allow rules don't bypass the authorizer, but their token settings still apply.
- Otherwise, if any allow rule matches, the request is allowed.
- Otherwise,
unmatcheddecides, and no token settings apply.
Token settings from every matching allow and external authorizer rule, and from the authorizer's response, combine:
| Setting | Combined as |
|---|---|
maxTTL | The lowest value |
allowedScopes | Only the scopes that every rule allows |
customClaims | Every claim that any matching rule sets is added. If two matching rules set the same claim with different templates, the request fails with server_error. |
Rules that don't set a setting take no part in combining it.
For example, take these two allow rules:
allow:
- name: workloads-act-as-themselves
match:
caller:
spiffeID: spiffe://example.com/**
requestedTokenType: urn:ietf:params:oauth:token-type:access_token
- name: reporting-reads-invoices
match:
caller:
spiffeID: spiffe://example.com/ns/reporting/**
requestedTokenType: urn:ietf:params:oauth:token-type:access_token
audience: https://invoices.example.com
token:
allowedScopes: [invoices.read]
A workload under spiffe://example.com/ns/reporting/ requests an access token for https://invoices.example.com, with the scopes invoices.read invoices.write. Both rules match, and no deny or external authorizer rule does, so the request is allowed. The token carries only invoices.read, from reporting-reads-invoices, and lives for 5 minutes, because no matching rule sets maxTTL.
Whatever the rules say, Defakto never:
- Issues a token with more scopes than the caller requested
- Accepts an identity provider's token as asserting a SPIFFE ID in your trust domain
- Turns a workload's own access token into an on-behalf-of token
- Issues a token whose audience is the trust domain's own OAuth server
Examples
Each example shows only the rules for its goal. Add them to the authorization block alongside your other rules.
Allow only specific workloads to call a service
allow:
- name: agents-call-helpdesk
match:
caller:
spiffeID: spiffe://example.com/ns/agents/**
requestedTokenType: urn:ietf:params:oauth:token-type:access_token
audience: https://helpdesk.example.com/mcp
Let people sign in to a service
allow:
- name: people-call-helpdesk
match:
caller:
type: oauthClientID
user:
identityProvider: corp-idp
via: login
requestedTokenType: urn:ietf:params:oauth:token-type:access_token
audience: https://helpdesk.example.com/mcp
token:
allowedScopes: [tickets.read]
Let a workload act for users
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
Let workloads start Transaction Tokens
allow:
- name: gateway-starts-transactions
match:
caller:
spiffeID: spiffe://example.com/ns/web/sa/gateway
requestedTokenType: urn:ietf:params:oauth:token-type:txn_token
audience: example.com
Block a workload from acting for users
deny:
- name: report-generator-never-acts-for-users
match:
caller:
spiffeID: spiffe://example.com/ns/batch/sa/report-generator
requestedTokenType: urn:defakto:security:token-type:jwt-svid-obo
Require agents to act for a user
deny:
- name: agents-never-call-payments-as-themselves
match:
caller:
spiffeID: spiffe://example.com/ns/agents/**
user:
none: true
audience: https://payments.example.com
Block a user
deny:
- name: offboarded-users
match:
user:
- identityProvider: corp-idp
sub: alice-8f3k2
- identityProvider: corp-idp
sub: bob-2m7q9
Block requests that passed through a workload
deny:
- name: nothing-through-ingest
match:
delegatedBy:
contains: spiffe://example.com/ns/edge/sa/ingest
Send decisions to your own policy engine
externalAuthorize:
- name: pdp-decides-delegations
match:
requestedTokenType: urn:defakto:security:token-type:jwt-svid-obo
authorizer: corp-pdp
token:
customClaims:
dept: "{{custom.department}}"
Refuse every request
policy:
refuseAll: true
authorization:
# your rules, unchanged
Validation
When you apply the configuration, Defakto also rejects:
- A rule that names an identity provider or authorizer that doesn't exist
- Duplicate rule names
unmatched: denywith no allow or external authorizer rules. To refuse everything, userefuseAll.- A caller entry that can never match under the rest of the rule. For example, an
oauthClientIDortype: oauthClientIDentry needs a rule for access tokens, and auserthat admits people who sign in. AspiffeIDentry in a rule for access tokens can't require a user, because a workload that acts for a user gets an on-behalf-of token or a Transaction Token. - A
viavalue that none of the entry's identity providers supports, orsubwithout exactly oneidentityProvider - A rule for Transaction Tokens whose
audienceisn't your trust domain name allowedScopeson a rule for on-behalf-of tokens- A rule for external services that names a
user, setsmaxTTLorcustomClaims, or requests anything other than an access token - A custom claim with a reserved name, or a template that refers to an unknown source or to a source the rule can't provide