Skip to main content
Version: 1.0

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

  1. Check active: true — if false, reject with 401 Unauthorized.
  2. Check scope — confirm the granted scopes cover the requested action.
  3. 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 obligationWhat 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:

  1. Extract the DPoP header from the incoming API request. If token_type is "DPoP" and the header is absent, reject with 401 and WWW-Authenticate: DPoP error="invalid_dpop_proof".
  2. Decode the DPoP proof JWT (without signature verification yet). Confirm typ is "dpop+jwt" and the jwk header is present.
  3. Verify the proof signature using the public key embedded in the jwk header.
  4. Check proof freshness: iat must be within an acceptable clock window (e.g., 60 seconds). Reject stale proofs.
  5. Validate htm and htu: must match the current request's HTTP method and URL.
  6. Compute the JWK thumbprint of the jwk public key (SHA-256, RFC 7638 canonical form).
  7. Compare thumbprints: the computed thumbprint must equal cnf.jkt from 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 sub and the client's client_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) and act (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.


Next steps