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:
| Field | Value |
|---|---|
authorization_endpoint | https://idp.example.com/authorize |
token_endpoint | https://idp.example.com/token |
jwks_uri | https://idp.example.com/jwks |
pushed_authorization_request_endpoint | https://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
| Parameter | Required | Description |
|---|---|---|
response_type | yes | Must be code. |
client_id | yes | The client’s registered ID. |
redirect_uri | yes | Must exactly match a registered URI. |
scope | recommended | Space-separated scope list. Unrecognised scopes are silently dropped; if the intersection with the client’s registered scopes is empty, invalid_scope is returned. |
state | strongly recommended | Opaque value returned unchanged in the redirect; protects against CSRF. |
nonce | recommended when openid is in scope | Bound into the ID token; prevents replay of ID tokens. |
code_challenge | required | BASE64URL(SHA-256(code_verifier)). |
code_challenge_method | required | Must 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).
Step 4 — User authentication and consent
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 9068at+JWTtype), valid fortokens.access_token_ttlseconds (default 900).id_token— included whenopenidis in the granted scope. A signed JWT (JWTtype) with identity claims. Expires at the same time as the access token.refresh_token— included whenoffline_accessis 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:
- Fetch
GET https://idp.example.com/jwksand cache it (5-minute cache TTL). - Find the key matching the token’s
kidheader claim. - Verify the signature using the algorithm in
alg(ES256, EdDSA, ML-DSA-65, etc.). - Verify standard claims:
issequalshttps://idp.example.comaudcontains yourclient_idexpis in the futureiatis 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.