Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 of iat).
  • 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 whose iat is 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 request
  • htu = the URL of the resource endpoint
  • ath = 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:

AlgorithmJWS alg valueKey type
ECDSA P-256ES256EC
ECDSA P-384ES384EC
ECDSA P-521ES512EC
Ed25519EdDSAOKP
RSA PKCS#1 v1.5RS256, RS384, RS512RSA
RSA-PSSPS256, PS384, PS512RSA
ML-DSA (post-quantum)ML-DSA-44, ML-DSA-65, ML-DSA-87ML-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

  1. Decode the JWT header to find kid and alg.
  2. Fetch the JWKS and locate the key with matching kid.
  3. Verify the signature.
  4. Validate the standard claims:
    • iss must equal the configured issuer (e.g. https://idp.example.com).
    • aud must contain your client’s client_id (or your resource server’s identifier).
    • exp must be in the future.
    • iat should be in the recent past (not more than a few minutes ago).
    • nbf must not be in the future. nbf is always present in ahdapa-issued access tokens and ID tokens (RFC 9068 §2.2).
  5. For DPoP tokens: also verify cnf.jkt against the DPoP proof.
  6. For mTLS tokens: also verify cnf.x5t#S256 against 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.