Getting tokens
All token requests go to:
POST https://pbac.example.com/token
Content-Type: application/x-www-form-urlencoded
Confidential clients authenticate via:
- Basic auth:
Authorization: Basic base64(client_id:client_secret) - POST body:
client_id=...&client_secret=... - Client assertion (private_key_jwt):
client_id=...&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion=<signed-jwt>— for clients registered withtoken_endpoint_auth_method=private_key_jwt. The JWT must includeiss,sub(= client_id),aud(issuer or token endpoint URL), andexp.
Client credentials grant
Machine-to-machine. Confidential clients only. No user context.
curl -X POST https://pbac.example.com/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=client_credentials" \
-d "scope=read" \
-d "resource=https://api.example.com/fhir"
With resource_type hint (when the resource indicator isn't registered in the DB):
curl -X POST https://pbac.example.com/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=client_credentials" \
-d "scope=read" \
-d "resource=https://api.example.com/fhir" \
-d "resource_type=urn:example:Patient"
With RAR (RFC 9396 Rich Authorization Requests) — mutually exclusive with resource+scope:
curl -X POST https://pbac.example.com/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=client_credentials" \
-d 'authorization_details=[{"type":"urn:example:Patient","actions":["read"],"locations":["https://api.example.com/fhir"]}]'
Successful response:
{
"access_token": "eyJhb...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read"
}
Authorization code grant
User-interactive. Requires a registered IdP.
Step 1: Redirect user to authorization endpoint
GET https://pbac.example.com/authorize?
response_type=code
&client_id=<client_id>
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid+profile+read
&resource=https%3A%2F%2Fapi.example.com%2Ffhir
&state=<random-state>
&code_challenge=<S256-challenge>
&code_challenge_method=S256
The AS selects an IdP based on user.idp_selection policy data and redirects the user to it.
Step 2: Exchange the authorization code
curl -X POST https://pbac.example.com/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=authorization_code" \
-d "code=$AUTH_CODE" \
-d "redirect_uri=https://app.example.com/callback" \
-d "code_verifier=$PKCE_VERIFIER"
Successful response:
{
"access_token": "eyJhb...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "def50200...",
"id_token": "eyJhb...",
"scope": "openid profile read"
}
Refresh token grant
Exchange a refresh token for a new access token. Requires the client to have refresh_token in its grant_types. Confidential clients only.
curl -X POST https://pbac.example.com/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=refresh_token" \
-d "refresh_token=$REFRESH_TOKEN" \
-d "scope=openid+read" \
-d "resource=https://api.example.com/fhir"
The refresh request also runs OPA policy. If the subject's entitlements have changed since the original token, the new token reflects the updated policy.
Token exchange (RFC 8693)
Exchange a token (e.g. for a different audience, delegation, or impersonation). Useful for service-to-service calls on behalf of a user.
curl -X POST https://pbac.example.com/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=$ACCESS_TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "resource=https://api.downstream.example.com" \
-d "scope=read"
OPA policy evaluates the exchange with the original token's subject and the requesting client. Policy can allow, reduce scopes, or deny the exchange.
DPoP (sender-constrained tokens)
DPoP (Demonstrating Proof of Possession, RFC 9449) sender-constrains access tokens so that a stolen token is useless without the private key that produced the proof. When a client requests a token with a DPoP proof, the AS binds the issued token to the client's public key via a cnf.jkt claim. Every subsequent use of the token must be accompanied by a fresh proof signed with the same key.
DPoP proof structure
A DPoP proof is a compact JWT with a specific header and claims:
Header:
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "<base64url-x>",
"y": "<base64url-y>"
}
}
Claims:
{
"jti": "<unique-identifier>",
"htm": "POST",
"htu": "https://pbac.example.com/token",
"iat": 1700000000
}
| Claim | Required | Description |
|---|---|---|
jti | Yes | Unique proof identifier. Prevents replays. |
htm | Yes | HTTP method of the request (uppercase). |
htu | Yes | URL of the request, no query string. |
iat | Yes | Issued-at timestamp. Must be within PBAC_DPOP_IAT_WINDOW_SECONDS of server time. |
nonce | Conditional | Required when the AS issues a nonce challenge (see below). |
Token request with DPoP
curl -X POST https://pbac.example.com/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-H "DPoP: $DPOP_PROOF" \
-d "grant_type=client_credentials" \
-d "scope=read" \
-d "resource=https://api.example.com/fhir"
Successful response:
{
"access_token": "eyJhb...",
"token_type": "DPoP",
"expires_in": 3600,
"scope": "read"
}
The token_type: "DPoP" signals that the token is key-bound. Clients must present a DPoP proof on every API call — the token cannot be used as a Bearer token.
Introspecting a DPoP-bound token
curl -X POST https://pbac.example.com/introspect \
-H "Authorization: Bearer $RS_PAT" \
-d "token=$ACCESS_TOKEN"
The response includes the cnf claim with the public key thumbprint:
{
"active": true,
"sub": "user-123",
"client_id": "app-001",
"scope": "read",
"token_type": "DPoP",
"cnf": {
"jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
},
"exp": 1700003600
}
The RS uses cnf.jkt to verify that the DPoP proof on the incoming API request was signed by the same key that was used to obtain the token.
Nonce handling
When PBAC_DPOP_NONCE_ENABLED=true, the AS requires a server-issued nonce in each DPoP proof. If the proof omits or presents a stale nonce, the AS responds with:
HTTP/1.1 400 Bad Request
DPoP-Nonce: <new-nonce>
{
"error": "use_dpop_nonce",
"error_description": "Authorization server requires nonce in DPoP proof"
}
Retry the request with the nonce added to the proof:
{
"jti": "<new-jti>",
"htm": "POST",
"htu": "https://pbac.example.com/token",
"iat": 1700000001,
"nonce": "<new-nonce>"
}
Nonces are valid for PBAC_DPOP_NONCE_TTL_SECONDS (default: 300 seconds). By default, nonces are disabled.
Resource, resource type, and scope parameters
| Parameter | Usage |
|---|---|
resource | RFC 8707 resource indicator URI. The AS resolves this to a resource_type via DB lookup. Passed to OPA as input.request.resource + input.request.resolved_resource_type. |
resource_type | Hint when resource cannot be resolved from DB. First value used as resolved_resource_type. |
scope | Space-separated requested scopes. OPA decides the granted_scopes (may be a subset). Required for resource+scope flow. |
authorization_details | JSON array for RAR flow (RFC 9396). Mutually exclusive with resource+scope. |
Grant types reference
| Grant Type | Description | Notes |
|---|---|---|
authorization_code | User-interactive, code exchange | Requires IdP setup; supports PKCE |
refresh_token | Exchange refresh token for new access token | Confidential clients only; requires offline_access in scopes |
client_credentials | Machine-to-machine | No user; confidential clients only |
urn:ietf:params:oauth:grant-type:token-exchange | RFC 8693 token exchange | For delegation and impersonation |
Next steps
- Introspection — Validate tokens and enforce policy decisions at the resource server
- Concepts: Enforcement — Obligations, JIT single-use tokens, DPoP validation, and AuthZEN
- Token exchange — Scope narrowing and cross-domain identity via RFC 8693