External authorizer webhook
An external authorizer is an HTTPS webhook that you run, such as your own policy engine, that the OAuth server calls before it issues a token. The webhook receives everything the OAuth server knows about the request: who is asking, on whose behalf, for which kind of token, and for which service. It returns one of two decisions:
- Allow, optionally narrowing the token and supplying values for custom claims
- Deny, with an optional reason
This page describes the request the webhook receives and the response it returns.
Enabling an external authorizer
Register the webhook in the authorizers block of the OAuthServer configuration, then write externalAuthorize rules in the token policy that decide which requests it sees:
spec:
authorizers:
- name: corp-pdp
webhook:
url: https://pdp.example.com/authorize
auth:
method: bearer
timeout: 2s
policy:
authorization:
unmatched: deny
externalAuthorize:
- name: pdp-decides-delegations
match:
requestedTokenType: urn:defakto:security:token-type:jwt-svid-obo
authorizer: corp-pdp
When the webhook is called
The OAuth server first validates the request: the caller's credential, and any token the request presents. Then it evaluates the token policy:
- If a deny rule matches, the request is refused, and the webhook isn't called.
- Otherwise, if an
externalAuthorizerule matches, the OAuth server calls the webhook once, and the webhook's decision is final. Matching allow rules can't bypass the webhook, but their token settings still apply, combined with any narrowing the webhook returns. - A request that matches no
externalAuthorizerule never reaches the webhook.
Request
The OAuth server sends an HTTPS POST to the webhook's URL, with a JSON body.
Headers
POST /authorize HTTP/1.1
Host: pdp.example.com
Content-Type: application/json
Authorization: Bearer <token>
X-Defakto-Webhook-Version: 1.0
X-Defakto-Trust-Domain-ID: td-6eyxu8lwol
X-Defakto-Trust-Domain-Name: example.com
X-Defakto-Trust-Domain-Deployment-ID: tdd-aklj23lk2j
X-Defakto-Trust-Domain-Deployment-Name: us-west-2
| Header | Value |
|---|---|
Content-Type | application/json |
Authorization | Bearer <token>, when the authorizer's auth.method is bearer. See Authentication. |
X-Defakto-Webhook-Version | The version of this contract, currently 1.0. It changes only when a response field is added, or an existing field changes meaning. New request fields can arrive without a version change. |
X-Defakto-Trust-Domain-ID | The trust domain's ID |
X-Defakto-Trust-Domain-Name | The trust domain's name |
X-Defakto-Trust-Domain-Deployment-ID | The ID of the Trust Domain Server deployment making the call |
X-Defakto-Trust-Domain-Deployment-Name | The name of the Trust Domain Server deployment making the call |
Body
The body has fields that describe the request in the same terms as the token policy, and a raw_request field with what the request carried, as sent. Fields with no value are left out.
For example, a workload exchanging its JWT-SVID for an access token to an MCP server:
{
"trust_domain": {
"id": "td-6eyxu8lwol",
"name": "example.com"
},
"trust_domain_deployment": {
"id": "tdd-aklj23lk2j",
"name": "us-west-2"
},
"matched_policy_rules": ["pdp-decides-workload-access"],
"caller": {
"type": "spiffe_id",
"id": "spiffe://example.com/ns/agents/sa/triage",
"issuer": "spiffe://example.com",
"auth_method": "spiffe_jwt",
"caller_credential_jwt_contents": {
"iss": "<JWT-SVID issuer>",
"sub": "spiffe://example.com/ns/agents/sa/triage",
"aud": "<OAuth issuer URL>",
"jti": "f44d712e89a71e9c9598cd7530109075",
"iat": 1792598475,
"exp": 1792602075
}
},
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"audience": "https://helpdesk.example.com/mcp",
"requested_scopes": ["tickets.read", "tickets.write"],
"raw_request": {
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"resource": "https://helpdesk.example.com/mcp",
"scope": "tickets.read tickets.write"
}
}
Examples shows a request for every flow.
Top-level fields
| Field | Present | Description |
|---|---|---|
trust_domain | Always | The trust domain's id and name |
trust_domain_deployment | Always | The Trust Domain Server deployment's id and name |
matched_policy_rules | Always | Every externalAuthorize rule that matched the request, sorted by name |
caller | Always | Who is asking, and the credential that proved it |
user | When the request acts for a user | Who the request acts for |
requested_token_type | Always | The kind of token requested. For flows that don't send one, such as the JWT bearer grant and user sign-in, urn:ietf:params:oauth:token-type:access_token. |
audience | Always | The service the token is for: the audience or resource the caller sent. For a Transaction Token, the trust domain name. |
requested_scopes | When the caller requested scopes | The requested scopes, as a list |
delegated_by | When the request presents an on-behalf-of token | The workloads that acted for the user before the caller, most recent first, each with issuer and id |
raw_request | Always | What the request carried, as sent |
caller
| Field | Present | Description |
|---|---|---|
type | Always | spiffe_id for a workload in the trust domain, or oauth_client_id for an OAuth client that a person signs in through |
id | Always | The workload's SPIFFE ID, or the OAuth client's client ID (its Client ID Metadata Document URL) |
issuer | Workloads | The trust domain, as a SPIFFE URI such as spiffe://example.com |
auth_method | Workloads | How the workload authenticated: spiffe_x509 for an X.509-SVID over mutual TLS, or spiffe_jwt for a JWT-SVID |
caller_credential_x509_contents | spiffe_x509 | The X.509-SVID's details, such as its serial number, issuer, validity, subject, key usages, SAN URIs, and fingerprints |
caller_credential_jwt_contents | spiffe_jwt | Every claim of the JWT-SVID |
An OAuth client doesn't authenticate, so auth_method and the credential fields are absent, and its client ID isn't verified. Base decisions on the user, not on the OAuth client's client ID.
user
| Field | Description |
|---|---|
identity_provider_name | The name of the identity provider entry that vouched for the user |
issuer | That identity provider's issuer |
via | How the user arrived: idToken or accessToken (a workload presented the user's token), or login (the user signed in through Defakto) |
sub | The user's identifier |
raw_request
What the request carried, as sent. Tokens appear as their decoded claims, and nothing is filled in or derived.
| Field | Present | Description |
|---|---|---|
grant_type | Always | urn:ietf:params:oauth:grant-type:jwt-bearer, urn:ietf:params:oauth:grant-type:token-exchange, or authorization_code |
requested_token_type | When sent | The requested token type |
audience | When sent | The requested audience |
resource | When sent | The requested resource |
scope | When sent | The requested scopes, as the space-separated string the caller sent |
subject_token_type | Token exchange and user sign-in | The kind of token presented |
subject_token_contents | Token exchange and user sign-in | Every claim of the verified token the request presented, including claims that aren't part of any standard. For a user sign-in, the identity provider's ID token. |
| Other parameters | When sent | Request parameters that aren't part of the standard, under their own names |
New fields
New request fields can be added without changing the contract version. Ignore fields you don't recognize. Deny a request when a field you base the decision on, such as caller.type, user.via, or requested_token_type, has a value you don't recognize.
Response
Status codes
Only HTTP 200 with a valid body is a decision, including a denial. Any other outcome denies the request.
| Response | Result |
|---|---|
200 with "allow": true | The request is allowed, narrowed by token |
200 with "allow": false | The request is denied |
200 with a body that breaks this contract | The request is denied, with authorizer_response_invalid. Not retried. |
429 or 5xx | Retried once, then denied with upstream_unavailable if no usable response arrives |
| Any other status | Denied with upstream_unavailable. Not retried. |
The OAuth server retries at most once, within the authorizer's timeout, so the same request can arrive twice. Make the webhook's handling idempotent.
| Outcome | Retried |
|---|---|
HTTP 5xx or 429 | Yes |
| A transient network error, such as a refused or reset connection | Yes |
| A DNS or TLS failure | No |
Any other HTTP 4xx | No |
| An unparseable or invalid response body | No |
The timeout elapses | No |
Body
To allow:
{
"allow": true,
"token": {
"max_ttl": "2m",
"allowed_scopes": ["invoices.read"],
"custom": { "department": "finance" }
}
}
To deny:
{
"allow": false,
"denial_code": "change_window_closed",
"reason": "write scope needs an approved change window"
}
| Field | Description |
|---|---|
allow | Required. true allows the request, narrowed by token. false denies it. |
token.max_ttl | Optional, on an allow. A duration greater than zero, written as numbers with units (ms, s, m, or h), such as 90s or 1m30s. It can only shorten the lifetime the token policy allows. |
token.allowed_scopes | Optional, on an allow. The issued scopes are narrowed to this set. If scopes were requested and none remain, the request is denied with scope_not_allowed. Only for kinds of tokens that carry scopes. On an on-behalf-of token request, the field must be absent. |
token.custom | Optional, on an allow. Values that matching rules' customClaims refer to as custom.<name>, of any JSON type, at most 16 KiB in total. Values no rule refers to are ignored. A value a rule refers to that's missing makes the response invalid. |
denial_code | Optional, on a deny. Your own code, at most 64 characters of letters, digits, _, ., :, and -. Recorded on the audit event. Default authorization_denied. |
reason | Optional, on a deny. Text recorded on the audit event. |
The OAuth server decodes the response strictly. A field that this contract doesn't define, at any level, makes the response invalid.
What the response can change
The webhook decides and narrows. The configuration decides what tokens can contain.
- Narrowing only.
max_ttlandallowed_scopescombine with every matching rule's settings, and can only narrow them. - Values, not claims.
token.customsupplies values. Which claims exist, and where each value goes, is set by the matching rules'customClaims. The webhook can't add a claim that no rule names, or change a claim that Defakto sets, such assub,aud,iss, orobo. - Per hop. Custom claims aren't carried forward automatically when an on-behalf-of token is re-exchanged.
- External service tokens. Defakto gets these from the external service rather than issuing them, so only
allowed_scopesapplies.
Failures
The caller receives only a standard OAuth error. The details, such as the denial code and reason, are recorded on the OAuth server's audit event for the request.
| Situation | Recorded on the audit event | The caller receives |
|---|---|---|
| The webhook denies the request | denial_code (default authorization_denied) and reason | invalid_request for a JWT bearer or token exchange request. invalid_grant when the client redeems an authorization code. access_denied at the OAuth client's redirect URI, during sign-in. |
| No requested scopes remain after narrowing | scope_not_allowed | invalid_scope |
| No usable response: unreachable, timed out, or an error status | upstream_unavailable | server_error |
A 200 response that breaks this contract | authorizer_response_invalid | server_error |
bearer is configured, but no token is set, or the token is empty or can't be read | webhook_unusable | server_error |
Denial codes, reasons, and rule names never reach the caller.
Authentication
The authorizer's auth.method decides how the Trust Domain Servers authenticate to the webhook:
auth.method | The webhook receives |
|---|---|
bearer | Authorization: Bearer <token>, with the token set in the Helm value trustDomainDeployment.deployment.oauthExternalAuthorizer.token. See Authorizers. |
none | No credentials |
Operating the webhook
Availability
- Treat the webhook as part of the token path. Every request its rules cover waits for it, and fails if it fails.
- Deploy it for high availability. Run several replicas behind a load balancer, and scale them with load.
- Expect up to two deliveries of the same request.
- Tune the timeout to the webhook's real latency. A generous timeout delays every covered request when the webhook slows down.
- Scope the rules. Send only the requests that need an external decision to the webhook, so an outage affects only those.
Security
- Use HTTPS with a valid certificate. Set
caBundlefor a private certificate authority. - Authenticate the Trust Domain Servers. Use
method: bearer, so the webhook can verify that requests come from your Trust Domain Servers, and rotate a configured token on a schedule. - Treat request contents as sensitive. Requests carry the full contents of tokens and credentials, including users' personal data. Log them with the same care as the tokens themselves.
- Review changes to the webhook as policy changes. Values it returns in
customappear in issued tokens.
Monitoring
Every decision appears on the OAuth server's audit event for the request, including which rules matched, the webhook's decision, its denial_code and reason, and any failure. Monitor the webhook's latency and error rate, together with upstream_unavailable and authorizer_response_invalid failures, and track allows and denies separately. A spike in denials is a policy event, not a performance one.
Troubleshooting
To see why a request failed, open your trust domain in the Defakto console, and go to Issuance > Token Exchanges.
The webhook is never called. Check that an externalAuthorize rule matches the request, and that no deny rule matches it first. The audit event shows which rules matched.
Every request fails with webhook_unusable. The authorizer uses method: bearer, but the Trust Domain Server has no token to send. Set the token in the Helm value trustDomainDeployment.deployment.oauthExternalAuthorizer.token.
Every request fails with authorizer_response_invalid. The response breaks the contract. For example, it has a misspelled or unknown field, is missing allow, has allowed_scopes for a kind of token without scopes, or is missing a custom value that a rule refers to.
Requests fail with upstream_unavailable. The webhook is unreachable, slower than timeout, or returning error statuses. Check TLS and caBundle, DNS, and the webhook's own logs.
Custom claims are missing from tokens. Check that the matching rule's customClaims refers to the names the response returns in token.custom, and that the claim name isn't one Defakto reserves.
A denial reason doesn't reach the caller. Reasons are recorded only on the audit event, by design.
Examples
Each example shows a request the webhook might receive and a response it might return. Fields such as trust_domain and trust_domain_deployment are the same as in the body example and are left out here for brevity.
Workload access token
A workload presents its JWT-SVID with the JWT bearer grant, asking for an access token to an MCP server.
{
"matched_policy_rules": ["pdp-decides-workload-access"],
"caller": {
"type": "spiffe_id",
"id": "spiffe://example.com/ns/agents/sa/triage",
"issuer": "spiffe://example.com",
"auth_method": "spiffe_jwt",
"caller_credential_jwt_contents": {
"iss": "<JWT-SVID issuer>",
"sub": "spiffe://example.com/ns/agents/sa/triage",
"aud": "<OAuth issuer URL>",
"exp": 1792602075
}
},
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"audience": "https://helpdesk.example.com/mcp",
"requested_scopes": ["tickets.read", "tickets.write"],
"raw_request": {
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"resource": "https://helpdesk.example.com/mcp",
"scope": "tickets.read tickets.write"
}
}
{
"allow": true,
"token": { "allowed_scopes": ["tickets.read"] }
}
User sign-in
A person signs in through an MCP client to reach an MCP server. The OAuth client doesn't authenticate, so auth_method is absent.
{
"matched_policy_rules": ["pdp-decides-sign-ins"],
"caller": {
"type": "oauth_client_id",
"id": "https://app.example.com/oauth/client-metadata"
},
"user": {
"identity_provider_name": "corp-idp",
"issuer": "https://idp.example.com",
"via": "login",
"sub": "alice-8f3k2"
},
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"audience": "https://helpdesk.example.com/mcp",
"requested_scopes": ["openid", "tickets.read"],
"raw_request": {
"grant_type": "authorization_code",
"resource": "https://helpdesk.example.com/mcp",
"scope": "openid tickets.read",
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
"subject_token_contents": {
"iss": "https://idp.example.com",
"sub": "alice-8f3k2",
"aud": "<Defakto's client ID at the identity provider>",
"email": "alice@example.com",
"email_verified": true,
"exp": 1792602100
}
}
}
{
"allow": false,
"reason": "alice has no helpdesk entitlement"
}
External service token
A workload asks for a token for an external service. Defakto gets the token from the service with its own registration.
{
"matched_policy_rules": ["pdp-decides-external-access"],
"caller": {
"type": "spiffe_id",
"id": "spiffe://example.com/ns/reporting/sa/batch",
"issuer": "spiffe://example.com",
"auth_method": "spiffe_jwt",
"caller_credential_jwt_contents": {
"iss": "<JWT-SVID issuer>",
"sub": "spiffe://example.com/ns/reporting/sa/batch",
"aud": "<JWT-SVID issuer>",
"exp": 1792602200
}
},
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"audience": "https://api.example.net",
"requested_scopes": ["records.read"],
"raw_request": {
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"audience": "https://api.example.net",
"scope": "records.read"
}
}
{ "allow": true }
On-behalf-of token, first hop
A workload, authenticated with its JWT-SVID, exchanges a user's ID token for an on-behalf-of token. Claims specific to the identity provider arrive in subject_token_contents, and a parameter that isn't part of the standard, x_ticket, arrives in raw_request, both unchanged.
{
"matched_policy_rules": ["pdp-decides-delegations"],
"caller": {
"type": "spiffe_id",
"id": "spiffe://example.com/ns/web/sa/backend",
"issuer": "spiffe://example.com",
"auth_method": "spiffe_jwt",
"caller_credential_jwt_contents": {
"iss": "<JWT-SVID issuer>",
"sub": "spiffe://example.com/ns/web/sa/backend",
"aud": "<JWT-SVID issuer>",
"exp": 1792602188
}
},
"user": {
"identity_provider_name": "corp-idp",
"issuer": "https://idp.example.com",
"via": "idToken",
"sub": "alice-8f3k2"
},
"requested_token_type": "urn:defakto:security:token-type:jwt-svid-obo",
"audience": "spiffe://example.com/orders",
"raw_request": {
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"requested_token_type": "urn:defakto:security:token-type:jwt-svid-obo",
"audience": "spiffe://example.com/orders",
"x_ticket": "INC-4211",
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
"subject_token_contents": {
"iss": "https://idp.example.com",
"sub": "alice-8f3k2",
"aud": "web-app",
"email": "alice@example.com",
"department": "finance",
"exp": 1792638036
}
}
}
{
"allow": true,
"token": {
"max_ttl": "2m",
"custom": { "department": "finance" }
}
}
On-behalf-of re-exchange
The next service, authenticated with its X.509-SVID, exchanges the on-behalf-of token it received for one whose audience is the service after it. The user and the workloads that acted for them are in the presented token's obo claim. Some X.509-SVID fields are left out for brevity.
{
"matched_policy_rules": ["pdp-decides-delegations"],
"caller": {
"type": "spiffe_id",
"id": "spiffe://example.com/ns/orders/sa/api",
"issuer": "spiffe://example.com",
"auth_method": "spiffe_x509",
"caller_credential_x509_contents": {
"serial_number": "b8:8a:37:c0:90:e7:ce:26:4d:87:f8:6e:96:4f:55:6d",
"not_before": "2026-10-21T14:32:07Z",
"not_after": "2026-10-22T14:32:07Z",
"san_uri": ["spiffe://example.com/ns/orders/sa/api"],
"fingerprint_sha256": "1c8374a8ce8acbcc531f3d2581c15d2170fea0d319e74f47d75298e7152e083e"
}
},
"user": {
"identity_provider_name": "corp-idp",
"issuer": "https://idp.example.com",
"via": "idToken",
"sub": "alice-8f3k2"
},
"requested_token_type": "urn:defakto:security:token-type:jwt-svid-obo",
"audience": "spiffe://example.com/payments",
"delegated_by": [
{
"issuer": "spiffe://example.com",
"id": "spiffe://example.com/ns/web/sa/backend"
}
],
"raw_request": {
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"requested_token_type": "urn:defakto:security:token-type:jwt-svid-obo",
"audience": "spiffe://example.com/payments",
"client_id": "spiffe://example.com/ns/orders/sa/api",
"subject_token_type": "urn:defakto:security:token-type:jwt-svid-obo",
"subject_token_contents": {
"sub": "spiffe://example.com/ns/web/sa/backend",
"aud": "spiffe://example.com/orders",
"txn": "b7814530c95811d288ac0390515c61af",
"dept": "finance",
"exp": 1792598887,
"obo": {
"iss": "https://idp.example.com",
"sub": "alice-8f3k2",
"sub_profile": "user",
"via": "idToken",
"act": {
"iss": "spiffe://example.com",
"sub": "spiffe://example.com/ns/web/sa/backend"
}
}
}
}
}
{
"allow": true,
"token": { "custom": { "department": "finance" } }
}
Transaction Token for a user
A workload starts a transaction for a user who presented an access token.
{
"matched_policy_rules": ["pdp-decides-trades"],
"caller": {
"type": "spiffe_id",
"id": "spiffe://example.com/ns/payments/sa/api",
"issuer": "spiffe://example.com",
"auth_method": "spiffe_jwt",
"caller_credential_jwt_contents": {
"iss": "<JWT-SVID issuer>",
"sub": "spiffe://example.com/ns/payments/sa/api",
"aud": "<JWT-SVID issuer>",
"exp": 1792602188
}
},
"user": {
"identity_provider_name": "corp-idp",
"issuer": "https://idp.example.com",
"via": "accessToken",
"sub": "alice-8f3k2"
},
"requested_token_type": "urn:ietf:params:oauth:token-type:txn_token",
"audience": "example.com",
"requested_scopes": ["trade.stocks"],
"raw_request": {
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"requested_token_type": "urn:ietf:params:oauth:token-type:txn_token",
"audience": "example.com",
"scope": "trade.stocks",
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"subject_token_contents": {
"iss": "https://idp.example.com",
"sub": "alice-8f3k2",
"aud": "https://api.example.com",
"scope": "trade.stocks openid",
"exp": 1792638036
}
}
}
{
"allow": false,
"reason": "the trade exceeds the user's daily limit"
}