Skip to main content

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 externalAuthorize rule 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 externalAuthorize rule never reaches the webhook.

See how rules are evaluated.

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
HeaderValue
Content-Typeapplication/json
AuthorizationBearer <token>, when the authorizer's auth.method is bearer. See Authentication.
X-Defakto-Webhook-VersionThe 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-IDThe trust domain's ID
X-Defakto-Trust-Domain-NameThe trust domain's name
X-Defakto-Trust-Domain-Deployment-IDThe ID of the Trust Domain Server deployment making the call
X-Defakto-Trust-Domain-Deployment-NameThe 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​

FieldPresentDescription
trust_domainAlwaysThe trust domain's id and name
trust_domain_deploymentAlwaysThe Trust Domain Server deployment's id and name
matched_policy_rulesAlwaysEvery externalAuthorize rule that matched the request, sorted by name
callerAlwaysWho is asking, and the credential that proved it
userWhen the request acts for a userWho the request acts for
requested_token_typeAlwaysThe 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.
audienceAlwaysThe service the token is for: the audience or resource the caller sent. For a Transaction Token, the trust domain name.
requested_scopesWhen the caller requested scopesThe requested scopes, as a list
delegated_byWhen the request presents an on-behalf-of tokenThe workloads that acted for the user before the caller, most recent first, each with issuer and id
raw_requestAlwaysWhat the request carried, as sent

caller​

FieldPresentDescription
typeAlwaysspiffe_id for a workload in the trust domain, or oauth_client_id for an OAuth client that a person signs in through
idAlwaysThe workload's SPIFFE ID, or the OAuth client's client ID (its Client ID Metadata Document URL)
issuerWorkloadsThe trust domain, as a SPIFFE URI such as spiffe://example.com
auth_methodWorkloadsHow the workload authenticated: spiffe_x509 for an X.509-SVID over mutual TLS, or spiffe_jwt for a JWT-SVID
caller_credential_x509_contentsspiffe_x509The X.509-SVID's details, such as its serial number, issuer, validity, subject, key usages, SAN URIs, and fingerprints
caller_credential_jwt_contentsspiffe_jwtEvery 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​

FieldDescription
identity_provider_nameThe name of the identity provider entry that vouched for the user
issuerThat identity provider's issuer
viaHow the user arrived: idToken or accessToken (a workload presented the user's token), or login (the user signed in through Defakto)
subThe user's identifier

raw_request​

What the request carried, as sent. Tokens appear as their decoded claims, and nothing is filled in or derived.

FieldPresentDescription
grant_typeAlwaysurn:ietf:params:oauth:grant-type:jwt-bearer, urn:ietf:params:oauth:grant-type:token-exchange, or authorization_code
requested_token_typeWhen sentThe requested token type
audienceWhen sentThe requested audience
resourceWhen sentThe requested resource
scopeWhen sentThe requested scopes, as the space-separated string the caller sent
subject_token_typeToken exchange and user sign-inThe kind of token presented
subject_token_contentsToken exchange and user sign-inEvery 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 parametersWhen sentRequest 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.

ResponseResult
200 with "allow": trueThe request is allowed, narrowed by token
200 with "allow": falseThe request is denied
200 with a body that breaks this contractThe request is denied, with authorizer_response_invalid. Not retried.
429 or 5xxRetried once, then denied with upstream_unavailable if no usable response arrives
Any other statusDenied 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.

OutcomeRetried
HTTP 5xx or 429Yes
A transient network error, such as a refused or reset connectionYes
A DNS or TLS failureNo
Any other HTTP 4xxNo
An unparseable or invalid response bodyNo
The timeout elapsesNo

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"
}
FieldDescription
allowRequired. true allows the request, narrowed by token. false denies it.
token.max_ttlOptional, 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_scopesOptional, 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.customOptional, 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_codeOptional, on a deny. Your own code, at most 64 characters of letters, digits, _, ., :, and -. Recorded on the audit event. Default authorization_denied.
reasonOptional, 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_ttl and allowed_scopes combine with every matching rule's settings, and can only narrow them.
  • Values, not claims. token.custom supplies 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 as sub, aud, iss, or obo.
  • 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_scopes applies.

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.

SituationRecorded on the audit eventThe caller receives
The webhook denies the requestdenial_code (default authorization_denied) and reasoninvalid_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 narrowingscope_not_allowedinvalid_scope
No usable response: unreachable, timed out, or an error statusupstream_unavailableserver_error
A 200 response that breaks this contractauthorizer_response_invalidserver_error
bearer is configured, but no token is set, or the token is empty or can't be readwebhook_unusableserver_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.methodThe webhook receives
bearerAuthorization: Bearer <token>, with the token set in the Helm value trustDomainDeployment.deployment.oauthExternalAuthorizer.token. See Authorizers.
noneNo 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 caBundle for 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 custom appear 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​

tip

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"
}