Using and Validating Tokens
This chapter explains how to present ahdapa-issued tokens to resource servers and how to validate them.
Bearer tokens
The standard way to present an access token is as a Bearer credential in the
Authorization header (RFC 6750):
GET /api/resource HTTP/1.1
Host: resource.example.com
Authorization: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K0pXVCIsImtpZCI6IkFCQ0QxMjM0In0...
If the token is missing or invalid, the resource server should respond with
401 Unauthorized and a WWW-Authenticate: Bearer header. A token with
insufficient scope should return 403 Forbidden.
DPoP (RFC 9449)
DPoP (Demonstrating Proof-of-Possession) binds an access token to a specific public key controlled by the client. Even if the access token is stolen, it cannot be used without the corresponding private key.
Generating a DPoP key pair
Generate a key pair and keep the private key in memory. Common choices: ES256 (ECDSA P-256), RS256, EdDSA.
Constructing a DPoP proof
For each request, construct a compact JWS with:
Header:
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "BASE64URL_X",
"y": "BASE64URL_Y"
}
}
Payload:
{
"jti": "UNIQUE_UUID_PER_PROOF",
"htm": "POST",
"htu": "https://idp.example.com/token",
"iat": 1716000000
}
jti— unique identifier for this proof. The server rejects replays within a 5-minute window (±300 seconds ofiat).htm— HTTP method (uppercase) of the request this proof accompanies.htu— Full URI of the endpoint, without fragment. Must exactly match the URL the server sees.iat— Current Unix timestamp. The server rejects proofs whoseiatis more than 300 seconds from the current time.
Sign the compact JWS <header_b64>.<payload_b64> with your private key and
append the signature.
Requesting a DPoP-bound token
Include the DPoP header in the token request:
curl -s -X POST https://idp.example.com/token \
-u "CLIENT_ID:CLIENT_SECRET" \
-H "DPoP: DPOP_PROOF_JWT_FOR_TOKEN_ENDPOINT" \
-d "grant_type=client_credentials" \
-d "scope=openid"
When the proof is valid, the response includes "token_type": "DPoP" and the
access token’s cnf claim contains the JWK thumbprint:
{
"cnf": { "jkt": "SHA256_JWK_THUMBPRINT" }
}
Using a DPoP-bound access token
For every subsequent use of the token, construct a fresh DPoP proof with:
htm= the HTTP method of the resource requesthtu= the URL of the resource endpointath=BASE64URL(SHA-256(ASCII(access_token)))(RFC 9449 §4.3)
Present both headers:
GET /api/resource HTTP/1.1
Host: resource.example.com
Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K0pXVCIsImtpZCI6IkFCQ0QxMjM0In0...
DPoP: DPOP_PROOF_JWT_FOR_THIS_RESOURCE_REQUEST
Note Authorization: DPoP (not Bearer) when the token is DPoP-bound.
Resource servers that support DPoP must verify both the token signature and
the DPoP proof, and check that cnf.jkt matches the thumbprint of the key
in the DPoP proof header.
DPoP nonces
RFC 9449 §5 allows servers to issue a DPoP-Nonce response header and require
clients to embed the nonce in the nonce claim of subsequent proofs. This prevents
replay within the iat window at the cost of a round-trip to obtain the nonce.
ahdapa does not currently issue DPoP-Nonce headers. Clients should not include
a nonce claim in DPoP proofs sent to ahdapa. A future version may enforce nonces
as a stronger replay-prevention measure; the use_dpop_nonce error code will be
returned when that change is deployed.
DPoP and mTLS combined
When a client uses both mTLS and DPoP simultaneously, the access token’s cnf
object contains both x5t#S256 (mTLS binding) and jkt (DPoP binding):
{
"cnf": {
"x5t#S256": "BASE64URL_CERT_SHA256",
"jkt": "SHA256_JWK_THUMBPRINT"
}
}
mTLS-bound tokens (RFC 8705)
When a client uses tls_client_auth or self_signed_tls_client_auth, every
access token it receives contains a cnf.x5t#S256 claim:
{
"cnf": {
"x5t#S256": "BASE64URL_SHA256_OF_LEAF_CERT_DER"
}
}
Resource servers that enforce certificate binding must verify that the
SHA-256 thumbprint of the client certificate presented in the TLS connection
matches cnf.x5t#S256 in the token.
ahdapa advertises "tls_client_certificate_bound_access_tokens": true in
both discovery documents, indicating that all tokens issued to mTLS clients
carry this binding claim.
JWKS and token verification
Fetching the JWKS
curl -s https://idp.example.com/jwks
Response:
{
"keys": [
{
"kid": "ABCD1234",
"use": "sig",
"kty": "EC",
"crv": "P-256",
"x": "BASE64URL_X",
"y": "BASE64URL_Y"
}
]
}
The JWKS is cached for 5 minutes (Cache-Control: public, max-age=300). Cache
it in your application and refresh when a token presents an unknown kid.
Supported algorithms
ahdapa supports the following signing algorithms for access tokens and ID tokens:
| Algorithm | JWS alg value | Key type |
|---|---|---|
| ECDSA P-256 | ES256 | EC |
| ECDSA P-384 | ES384 | EC |
| ECDSA P-521 | ES512 | EC |
| Ed25519 | EdDSA | OKP |
| RSA PKCS#1 v1.5 | RS256, RS384, RS512 | RSA |
| RSA-PSS | PS256, PS384, PS512 | RSA |
| ML-DSA (post-quantum) | ML-DSA-44, ML-DSA-65, ML-DSA-87 | ML-DSA |
The configured algorithm is set via server.jwt_signing_algorithm in
ahdapa.toml (default: ES256).
ID tokens support a subset: RS256, ES256, EdDSA, ML-DSA-65.
Validating an access token
- Decode the JWT header to find
kidandalg. - Fetch the JWKS and locate the key with matching
kid. - Verify the signature.
- Validate the standard claims:
issmust equal the configured issuer (e.g.https://idp.example.com).audmust contain your client’sclient_id(or your resource server’s identifier).expmust be in the future.iatshould be in the recent past (not more than a few minutes ago).nbfmust not be in the future.nbfis always present in ahdapa-issued access tokens and ID tokens (RFC 9068 §2.2).
- For DPoP tokens: also verify
cnf.jktagainst the DPoP proof. - For mTLS tokens: also verify
cnf.x5t#S256against the presented certificate.
The access token type header is "typ": "at+JWT". ID tokens use "typ": "JWT".
Token introspection (POST /introspect, RFC 7662)
Resource servers that cannot validate JWTs locally (e.g. because they do not have a JWT library, or because they need to check revocation state for refresh tokens) can call the introspection endpoint.
Authentication is required — same methods as the token endpoint.
curl -s -X POST https://idp.example.com/introspect \
-u "RESOURCE_SERVER_CLIENT_ID:RESOURCE_SERVER_SECRET" \
-d "token=TOKEN_TO_INSPECT" \
-d "token_type_hint=access_token"
Active token response:
{
"active": true,
"sub": "alice@EXAMPLE.COM",
"scope": "openid profile email",
"client_id": "3f8a2c1e-7d4b-4e9f-a0c1-2b3d4e5f6a7b",
"username": "alice@EXAMPLE.COM",
"token_type": "Bearer",
"exp": 1716000900,
"iat": 1716000000,
"iss": "https://idp.example.com",
"jti": "node1/550e8400-e29b-41d4-a716-446655440000"
}
Inactive or unknown token response:
{"active": false}
The token_type_hint parameter (access_token or refresh_token) influences
which format is tried first. The server tries both regardless.
For refresh tokens, the introspection response also checks the CRDT revocation
state — a token whose family has been revoked returns "active": false even
if it has not yet expired.
The introspecting client’s client_id is checked against the token’s aud
claim. A resource server can only introspect tokens addressed to it.
UserInfo endpoint
For OIDC flows, fetch the user’s profile claims from the UserInfo endpoint (OIDC Core §5.3) using the access token:
curl -s https://idp.example.com/userinfo \
-H "Authorization: Bearer ACCESS_TOKEN"
The response includes the claims associated with the scopes granted:
{
"sub": "alice@EXAMPLE.COM",
"iss": "https://idp.example.com",
"name": "Alice Smith",
"given_name": "Alice",
"family_name": "Smith",
"preferred_username": "alice",
"email": "alice@example.com",
"email_verified": true,
"groups": ["admins", "developers"]
}
preferred_username is mapped from the LDAP uid attribute (OIDC Core §5.1).
It is present when the profile scope is granted and the user’s uid attribute
is available from the directory.
The openid scope is required. Requesting the endpoint without openid in
the token’s scope returns 403 Forbidden. The claims returned depend on which
scopes were granted and what scope definitions are configured in the admin panel.