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

Authorization Code Flow

The authorization code flow (RFC 6749 §4.1) is the standard mechanism for web applications and native apps that act on behalf of a user. It separates the authorization step (user consent in the browser) from the token step (server-to-server exchange), so access tokens never appear in the browser’s address bar or history.

PKCE (RFC 7636) is required for all clients — confidential and public alike — in line with RFC 9700 best practices.


Step 1 — Discover endpoints (one-time setup)

Fetch the server metadata to locate all endpoint URLs:

curl -s https://idp.example.com/.well-known/openid-configuration | python3 -m json.tool

Key fields:

FieldValue
authorization_endpointhttps://idp.example.com/authorize
token_endpointhttps://idp.example.com/token
jwks_urihttps://idp.example.com/jwks
pushed_authorization_request_endpointhttps://idp.example.com/par

The discovery document is cached for 24 hours (Cache-Control: public, max-age=86400).


Step 2 — Generate PKCE values

PKCE binds the authorization request to the token request, preventing authorization code interception attacks.

import secrets, hashlib, base64

# Generate a cryptographically random verifier (43-128 chars)
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode()

# Derive the challenge: S256 = BASE64URL(SHA-256(ASCII(verifier)))
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()

Store code_verifier in the server-side session; include code_challenge and code_challenge_method=S256 in the authorization request.

ahdapa only accepts S256. The plain method is rejected with invalid_request.


Step 3 — Send the authorization request

Redirect the user’s browser to the authorization endpoint:

GET https://idp.example.com/authorize
  ?response_type=code
  &client_id=3f8a2c1e-7d4b-4e9f-a0c1-2b3d4e5f6a7b
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
  &scope=openid%20profile%20email%20offline_access
  &state=OPAQUE_RANDOM_STATE_VALUE
  &nonce=OPAQUE_RANDOM_NONCE_VALUE
  &code_challenge=BASE64URL_SHA256_OF_VERIFIER
  &code_challenge_method=S256
ParameterRequiredDescription
response_typeyesMust be code.
client_idyesThe client’s registered ID.
redirect_uriyesMust exactly match a registered URI.
scoperecommendedSpace-separated scope list. Unrecognised scopes are silently dropped; if the intersection with the client’s registered scopes is empty, invalid_scope is returned.
statestrongly recommendedOpaque value returned unchanged in the redirect; protects against CSRF.
noncerecommended when openid is in scopeBound into the ID token; prevents replay of ID tokens.
code_challengerequiredBASE64URL(SHA-256(code_verifier)).
code_challenge_methodrequiredMust be S256.

The authorization endpoint also emits Referrer-Policy: no-referrer on all responses (RFC 9700 §4.2.4) to prevent OAuth parameters from leaking to third-party resources loaded by the consent page.

Pushed Authorization Requests (PAR, RFC 9126)

Rather than putting all parameters in the browser’s URL, POST them directly to the server first and receive a request_uri to use in the redirect. This keeps parameters out of browser history and validates them before the user ever sees the consent screen.

curl -s -X POST https://idp.example.com/par \
  -u "CLIENT_ID:CLIENT_SECRET" \
  -d "response_type=code" \
  -d "redirect_uri=https://app.example.com/callback" \
  -d "scope=openid profile email offline_access" \
  -d "state=OPAQUE_STATE" \
  -d "nonce=OPAQUE_NONCE" \
  -d "code_challenge=BASE64URL_CHALLENGE" \
  -d "code_challenge_method=S256"

Response — 201 Created:

{
  "request_uri": "urn:ietf:params:oauth2:request_uri:BASE64URL_TOKEN",
  "expires_in": 90
}

Then redirect the browser with only client_id and request_uri:

GET https://idp.example.com/authorize
  ?client_id=3f8a2c1e-7d4b-4e9f-a0c1-2b3d4e5f6a7b
  &request_uri=urn%3Aietf%3Aparams%3Aoauth2%3Arequest_uri%3ABASE64URL_TOKEN

The request_uri expires after 90 seconds and can be used only once. PAR accepts all token endpoint authentication methods except kerberos_client_auth (which is not a browser-facing flow).


If the user has no valid session, ahdapa redirects them to the login page (/ui/auth/login?return_to=...). When GSSAPI is configured, the authorization endpoint first attempts SPNEGO negotiation, enabling single-round-trip authentication for Kerberos-capable browsers.

After authentication, the user is shown a consent page listing the requested scopes. On approval the browser is redirected to redirect_uri.

Scopes are intersected with the client’s registered scope set before being offered for consent. If you request openid email profile but the client is only registered for openid email, only those two scopes are offered and stored in the authorization code.


Step 5 — Authorization response

On success, the user’s browser is redirected to:

https://app.example.com/callback
  ?code=AUTHORIZATION_CODE
  &state=OPAQUE_RANDOM_STATE_VALUE
  &iss=https%3A%2F%2Fidp.example.com

The iss parameter is always included (RFC 9207 / authorization_response_iss_parameter_supported).

Verify that state matches what you stored in step 3 before proceeding.

On error, the redirect carries error and error_description instead:

https://app.example.com/callback
  ?error=access_denied
  &error_description=User+denied+access
  &state=OPAQUE_RANDOM_STATE_VALUE

Common error values: access_denied, invalid_scope, invalid_request.


Step 6 — Exchange the code for tokens

From your server (never from the browser), POST to the token endpoint:

curl -s -X POST https://idp.example.com/token \
  -u "3f8a2c1e-7d4b-4e9f-a0c1-2b3d4e5f6a7b:CLIENT_SECRET" \
  -d "grant_type=authorization_code" \
  -d "code=AUTHORIZATION_CODE" \
  -d "redirect_uri=https://app.example.com/callback" \
  -d "code_verifier=CODE_VERIFIER_FROM_STEP_2"

The redirect_uri must exactly match the one used in the authorization request. A missing redirect_uri returns invalid_request; a mismatched value returns invalid_grant.

Successful response — 200 OK:

{
  "access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K0pXVCIsImtpZCI6IkFCQ0QxMjM0In0...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "BASE64URL_ENCRYPTED_REFRESH_TOKEN",
  "id_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IkFCQ0QxMjM0In0...",
  "scope": "openid profile email offline_access"
}
  • access_token — a signed JWT (RFC 9068 at+JWT type), valid for tokens.access_token_ttl seconds (default 900).
  • id_token — included when openid is in the granted scope. A signed JWT (JWT type) with identity claims. Expires at the same time as the access token.
  • refresh_token — included when offline_access is in the granted scope. An AEAD-encrypted opaque blob; not a JWT.

Error responses use the RFC 6749 error format:

{"error": "invalid_grant"}

Common errors: invalid_grant (bad code, replayed code, PKCE failure, wrong redirect_uri), invalid_request (missing required parameter), invalid_client (authentication failure), invalid_scope.

Authorization codes are single-use; replaying one returns invalid_grant.


Step 7 — Validate tokens

Access token

The access token is a compact JWT. Validate it using the server’s JWKS:

  1. Fetch GET https://idp.example.com/jwks and cache it (5-minute cache TTL).
  2. Find the key matching the token’s kid header claim.
  3. Verify the signature using the algorithm in alg (ES256, EdDSA, ML-DSA-65, etc.).
  4. Verify standard claims:
    • iss equals https://idp.example.com
    • aud contains your client_id
    • exp is in the future
    • iat is reasonable (not too far in the past)

The access token payload looks like:

{
  "iss": "https://idp.example.com",
  "sub": "alice@EXAMPLE.COM",
  "aud": ["3f8a2c1e-7d4b-4e9f-a0c1-2b3d4e5f6a7b"],
  "exp": 1716000900,
  "iat": 1716000000,
  "nbf": 1716000000,
  "jti": "node1/550e8400-e29b-41d4-a716-446655440000",
  "scope": "openid profile email offline_access",
  "client_id": "3f8a2c1e-7d4b-4e9f-a0c1-2b3d4e5f6a7b"
}

ID token

The ID token contains identity claims for the authenticated user:

{
  "iss": "https://idp.example.com",
  "sub": "alice@EXAMPLE.COM",
  "aud": ["3f8a2c1e-7d4b-4e9f-a0c1-2b3d4e5f6a7b"],
  "exp": 1716000900,
  "iat": 1716000000,
  "nbf": 1716000000,
  "auth_time": 1715999990,
  "nonce": "OPAQUE_RANDOM_NONCE_VALUE",
  "acr": "urn:oasis:names:tc:SAML:2.0:ac:classes:Password",
  "amr": ["pwd"],
  "at_hash": "BASE64URL_LEFT_HALF_SHA256_OF_ACCESS_TOKEN"
}

aud is always a JSON array of strings. nbf is always present and equals iat. at_hash is the base64url encoding of the left half of the SHA-256 hash (or the hash corresponding to the ID token signing algorithm) of the ASCII representation of the access token (OIDC Core §3.3.2.11).

Verify that nonce matches what you sent in step 3.


Step 8 — Use the access token

Include the access token as a Bearer credential on protected resource requests:

GET /api/me HTTP/1.1
Host: resource.example.com
Authorization: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K0pXVCIsImtpZCI6IkFCQ0QxMjM0In0...

For stronger security, upgrade to DPoP sender-constrained tokens.