How OAuth 2.0 Authorization Code Flow Works
A technical walkthrough of the OAuth 2.0 Authorization Code flow with PKCE, JWT access tokens, refresh token lifecycle, and token introspection.
Introduction
OAuth 2.0’s Authorization Code flow is the most secure grant type for server-side applications and native mobile apps that need to access protected APIs on behalf of a user. Unlike the now-deprecated Implicit flow (which returned access tokens directly in URL fragments), the Authorization Code flow separates the authorization step (user grants consent) from the token exchange step (server-to-server code swap), keeping access tokens out of the browser’s URL bar, history, and referrer headers. The code itself is short-lived (typically 60-600 seconds) and single-use, limiting the blast radius if intercepted.
The introduction of PKCE (Proof Key for Code Exchange, RFC 7636) extended the Authorization Code flow to clients that cannot maintain a confidential secret — single-page apps and native mobile apps. PKCE replaces the client_secret with a cryptographically derived challenge-verifier pair generated fresh for every authorization request, preventing authorization code interception attacks where a malicious app on the same device could intercept the redirect URI callback and exchange the code first.
Step-by-Step: OAuth 2.0 Authorization Code Flow with PKCE
flowchart TD
A["1. Redirect to Authorization Endpoint"]
B["2. User Authentication and Consent"]
C["3. Authorization Code Returned via Redirect"]
D["4. Code Exchange at Token Endpoint"]
E["5. JWT Access Token Structure"]
F["6. Refresh Token Lifecycle"]
G["7. Token Introspection (RFC 7662)"]
A --> B
B --> C
C --> D
D --> E
E --> F
F --> G
Step 1: Redirect to Authorization Endpoint
The client (your application) redirects the user’s browser to the authorization endpoint of the authorization server (e.g., https://auth.example.com/oauth/authorize). The redirect URL includes these critical parameters:
https://auth.example.com/oauth/authorize?
response_type=code
&client_id=myapp_client_id
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid%20profile%20email%20api%3Aread
&state=xK3mVp9wQ2nR7sT4
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
response_type=code: Requests an authorization code (not a token)scope: Space-separated list of requested permissions; the user will see a consent screen describing eachstate: A random, opaque, unguessable value stored in the client’s session; returned unchanged in the callback to prevent CSRF attackscode_challenge: PKCE — the SHA-256 hash of thecode_verifier, Base64url-encoded (no padding):code_challenge = BASE64URL(SHA256(code_verifier))
import secrets, hashlib, base64
# Generate PKCE parameters
code_verifier = secrets.token_urlsafe(64) # 43-128 chars, URL-safe
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b'=').decode()
print(f"Verifier: {code_verifier}")
print(f"Challenge: {code_challenge}")
Step 2: User Authentication and Consent
The user is presented with the authorization server’s login page (if not already authenticated). After login, the server shows a consent screen listing the requested scopes. The user clicks “Allow”. This step is entirely between the user and the authorization server — your application’s server is not involved. The authorization server records the user’s consent decision.
Step 3: Authorization Code Returned via Redirect
The authorization server redirects the user’s browser back to your redirect_uri with the authorization code:
https://app.example.com/callback?
code=SplxlOBeZQQYbYS6WxSbIA
&state=xK3mVp9wQ2nR7sT4
Your server must validate the state parameter against the value stored in the session before proceeding — if they don’t match, abort immediately (CSRF attack). The authorization code is typically valid for 60-600 seconds and can only be used once.
Step 4: Code Exchange at Token Endpoint (Server-to-Server)
Your backend server (not the browser) makes a POST request to the token endpoint, exchanging the code for tokens. This is a direct server-to-server call that the user’s browser never sees:
POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&client_id=myapp_client_id
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk...
The authorization server:
- Validates that
codeexists and hasn’t expired or been used - Validates
redirect_uriexactly matches the registered URI - Computes
SHA256(code_verifier)and verifies it equals the storedcode_challenge - Returns the token response:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "8xLOxBtZp8",
"scope": "openid profile email api:read",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Step 5: JWT Access Token Structure
The access_token is typically a JWT (JSON Web Token) with three Base64url-encoded parts separated by dots:
# Decode a JWT access token (never paste real tokens into online tools)
echo "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImF1ZCI6ImFwaS5leGFtcGxlLmNvbSIsImV4cCI6MTcwMDAwMDAwMCwic2NvcGUiOiJhcGk6cmVhZCJ9.SIGNATURE" \
| cut -d. -f2 \
| base64 -d 2>/dev/null | python3 -m json.tool
A decoded payload looks like:
{
"sub": "user_123",
"aud": "api.example.com",
"iss": "https://auth.example.com",
"exp": 1700000000,
"iat": 1699996400,
"scope": "api:read",
"jti": "unique-token-id"
}
Resource servers validate the JWT by verifying its RS256 (or ES256) signature against the authorization server’s public key (fetched from the JWKS endpoint: https://auth.example.com/.well-known/jwks.json).
Step 6: Refresh Token Lifecycle
Access tokens are short-lived (typically 1 hour). The refresh token is a long-lived opaque credential (days to months) that your server stores securely. When the access token expires:
POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=8xLOxBtZp8
&client_id=myapp_client_id
The server responds with a new access token and optionally rotates the refresh token (refresh token rotation is a security best practice — each use invalidates the old token). If a rotated refresh token is used twice, it indicates theft and the authorization server should revoke the entire token family.
Step 7: Token Introspection (RFC 7662)
For opaque tokens (non-JWT), or when resource servers want to validate without implementing JWT verification themselves, token introspection allows a resource server to query the authorization server:
POST /oauth/introspect HTTP/1.1
Host: auth.example.com
Authorization: Basic <resource_server_credentials>
Content-Type: application/x-www-form-urlencoded
token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Response:
{
"active": true,
"sub": "user_123",
"scope": "api:read",
"exp": 1700000000,
"client_id": "myapp_client_id"
}
Key Takeaways
- PKCE eliminates the need for client secrets on public clients: The
code_verifier/code_challengepair provides a cryptographic proof that only the original requester can complete the flow, even without a shared secret. stateparameter is your CSRF defense: Always generate a cryptographically randomstatevalue, store it in the user’s session, and reject any callback where it doesn’t match exactly.- Authorization codes are single-use and short-lived: They should expire within 60-600 seconds and be invalidated immediately upon use — reuse of a code is a signal of a replay attack.
- JWT access tokens enable stateless validation at the resource server: By verifying the JWT signature against the authorization server’s JWKS public key, resource servers avoid a round-trip to the authorization server on every request.
- Refresh token rotation prevents indefinite token theft: Each refresh token use should invalidate the previous token; if a previously used refresh token is presented again, revoke the entire token family immediately.
Historical figures, architectures, and capabilities are for informational purposes only. Not technical, professional, legal, or financial advice. Sources: Research papers, developer documentation.