Enforce access with introspection
The AS issues tokens and makes policy decisions. The Resource Server enforces them. Enforcement happens at introspect time: the RS presents the token plus the context of the actual request, and the AS (via OPA) returns a decision with any obligations attached.
Token introspection
When a client presents a token, the RS calls /introspect to validate it:
curl -s -X POST $PBAC_URL/introspect \
-H "Authorization: Bearer $RS_PAT" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "token=$ACCESS_TOKEN" \
-d "resource=https://api.example.com/fhir/Patient/123" \
-d "resource_types=urn:example:Patient" \
-d "scopes=read"
The RS authenticates with either a Bearer token (carrying uma_protection or introspection scope) or Basic auth.
The optional parameters — resource, resource_types, scopes — are the transaction context. They tell OPA what the RS is actually being asked to do right now. This is what makes introspect a second policy evaluation, not just a token validation check.
Active response
{
"active": true,
"sub": "user-123",
"client_id": "app-001",
"scope": "read",
"token_type": "Bearer",
"exp": 1700000300,
"extensions": {
"granted_resource": "https://api.example.com/fhir",
"granted_resource_types": ["urn:example:Patient"]
}
}
Inactive response
{
"active": false
}
A token is inactive if it's expired, revoked, on the denylist, or if OPA denies the request given the RS-supplied context. The RS does not learn why — only that the token is not valid for this request.
What the RS must do
- Check
active: true— if false, reject with401 Unauthorized. - Check
scope— confirm the granted scopes cover the requested action. - Read
obligations— if present, enforce each one (see below).
This is the entire RS integration contract: one HTTP call, three checks. Everything else — policy evaluation, obligation generation, audit logging — happens on the AS side. Your RS stays simple.
Obligations
Obligations are custom requirements defined in your Rego policy extensions. OPA's evaluations_ext.rs_obligations() function returns whatever your policy logic produces — the obligation keys and values are entirely up to you.
Common patterns include:
| Example obligation | What the RS does |
|---|---|
audit_level: "full" | Log the full request and response |
data_masking: ["ssn", "dob"] | Redact specified fields from the response |
rate_limit: {"max": 100, "window": "1m"} | Throttle this client's requests |
notify: {"channel": "security"} | Send an alert |
You'll define obligations as your deployment matures. For initial evaluation, you can ignore obligations entirely — they're opt-in via custom Rego extensions. The quickstart works without any obligations configured.
You define obligations in your extension policies based on any combination of resource type, subject, client, time, or transaction context. For example, a policy that requires full audit logging for a specific resource type:
rs_obligations(resource, actions, subject, context) := {"audit_level": "full"} if {
resource.type == "urn:example:patient"
}
Obligations appear in the introspect response:
{
"active": true,
"scope": "read",
"obligations": {
"audit_level": "full",
"data_masking": ["ssn"]
}
}
Obligations are trust-based. The AS trusts the RS to enforce them. There is no in-band verification that the RS actually masked the SSN or logged the request. The RS is the only actor with access to the response data, so it is the only actor that can enforce.
Subject obligations
A second category — subject obligations — pauses the authorization flow before a token is issued. These redirect the user to satisfy a requirement (consent, step-up authentication) and are handled by the AS, not the RS. Subject obligations are returned by evaluations_ext.subject_obligations() and used internally by the authorize flow.
DPoP-bound token validation
When a client obtains a DPoP-bound access token, the introspection response includes token_type: "DPoP" and a cnf.jkt claim containing the JWK thumbprint of the client's public key:
{
"active": true,
"token_type": "DPoP",
"cnf": {
"jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
},
"scope": "read",
"exp": 1700003600
}
The RS must enforce the key binding on every request. The validation algorithm:
- Extract the
DPoPheader from the incoming API request. Iftoken_typeis"DPoP"and the header is absent, reject with401andWWW-Authenticate: DPoP error="invalid_dpop_proof". - Decode the DPoP proof JWT (without signature verification yet). Confirm
typis"dpop+jwt"and thejwkheader is present. - Verify the proof signature using the public key embedded in the
jwkheader. - Check proof freshness:
iatmust be within an acceptable clock window (e.g., 60 seconds). Reject stale proofs. - Validate
htmandhtu: must match the current request's HTTP method and URL. - Compute the JWK thumbprint of the
jwkpublic key (SHA-256, RFC 7638 canonical form). - Compare thumbprints: the computed thumbprint must equal
cnf.jktfrom the introspection response. If they differ, the token was issued to a different key — reject.
Bearer downgrade protection
A DPoP-bound token sent with Authorization: Bearer (without a DPoP header) must be rejected. The token_type: "DPoP" in the introspection response is the signal: if the RS receives a Bearer-scheme request but introspection returns token_type: "DPoP", treat the token as invalid.
This prevents a stolen DPoP token from being replayed as a conventional Bearer token by an attacker who cannot produce valid proofs.
AuthZEN
For applications that need a policy decision without going through an OAuth token flow, the AS exposes an AuthZEN-compatible evaluation endpoint:
curl -s -X POST $PBAC_URL/access/v1/evaluation \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject": {"type": "user", "id": "alice"},
"action": {"name": "read"},
"resource": {"type": "urn:example:Patient", "id": "patient/123"}
}'
Response:
{
"decision": true
}
This calls OPA directly with the subject/action/resource triple. No token, no introspect — just a policy decision. Useful for coarse-grained access checks, API gateways, or applications that manage their own session state.
Delegation
PBAC supports multiple delegation mechanisms — ways for one principal to grant another access on their behalf:
- Authorization code flow — a user delegates to a client by authenticating. The resulting token carries the user's
suband the client'sclient_id. - Token exchange (RFC 8693) — an agent exchanges a user's token for a new token scoped to a downstream resource. The resulting token carries
sub(the user) andact(the agent). Each exchange in a chain can narrow or maintain scope, but never expand it. - UMA delegation — a resource owner explicitly grants another user or client access to specific resources with specific scopes, stored in the AS's delegation store. At introspect time, OPA checks these grants and can allow access that the client's own entitlements wouldn't cover.
From the RS's perspective, all three produce the same thing: a token. The RS introspects it, reads the scopes and obligations, and enforces. The delegation mechanism is transparent to enforcement.
For initial evaluation, delegation is optional. The quickstart uses client_credentials (no delegation involved). Token exchange and UMA delegation matter when you're modeling agent flows or cross-organization access.
- Token exchange — Agent scope narrowing and cross-domain identity
- Consent guide — Consent-as-obligation for user-delegated flows
Next steps
- Introspection guide — Detailed introspect integration patterns
- Quickstart — See the full loop in action