OAuth 2.0: Authorization Code Flow with PKCE
Explain the OAuth 2.0 roles (client, authorization server, resource server, resource owner), walk through the Authorization Code flow with PKCE step by step, distinguish access tokens from refresh tokens and understand scopes, and implement strict redirect URI validation.
OAuth 2.0 is a delegation protocol: it lets a user grant a third-party application limited access to their data on another service, WITHOUT ever handing that application the user's password. When you click 'Sign in with Google' or connect a calendar app to your email, OAuth 2.0 is the machinery underneath. This lesson works through the most widely used and most secure flow -- the Authorization Code flow with PKCE (Proof Key for Code Exchange) -- covering every actor involved, the exact sequence of redirects and token exchanges, the difference between access and refresh tokens, what scopes constrain, and why redirect URI validation has to be an exact-match allowlist, never a pattern or wildcard.
The Four Roles in OAuth 2.0
OAuth 2.0 defines four distinct roles, and confusing them is the single most common source of misunderstanding the protocol. The resource owner is the end user -- the person who owns the data and can grant or deny access to it. The client is the application requesting access (e.g. a photo-printing service that wants to read your Google Photos) -- critically, the client is NOT the user; it's the third-party app acting on the user's behalf. The authorization server is the service that authenticates the resource owner and issues tokens (Google's own login and consent screen, and the endpoint that hands back tokens). The resource server hosts the protected data and accepts access tokens to authorize API requests (the Google Photos API itself). In many real systems the authorization server and resource server are operated by the same company (as with Google), but the protocol treats them as logically separate roles, and some systems do run them as genuinely separate services.
The core insight OAuth 2.0 is built around: the client never sees the resource owner's password. Instead, the resource owner authenticates directly with the authorization server (on the authorization server's own login page, not inside the client's app), and the authorization server issues the client a token that represents a limited, revocable grant of access -- limited in scope (what it can access) and limited in time (when it expires).
• Resource owner: the end user who owns the data and grants/denies access. • Client: the third-party application requesting access on the user's behalf. • Authorization server: authenticates the user, obtains consent, issues tokens. • Resource server: hosts the protected API/data, validates access tokens on each request. • The client never sees the user's password -- the user authenticates directly with the authorization server.
The Authorization Code Flow, Step by Step
The Authorization Code flow is the flow used by any client that has a backend capable of making a confidential server-to-server request (or, with PKCE, even a public client like a mobile app or SPA). It never puts an access token directly in a browser redirect URL; instead it exchanges a short-lived, single-use code for tokens over a back-channel request. Step by step: (1) the client redirects the user's browser to the authorization server's /authorize endpoint, including its client_id, the redirect_uri it wants to be sent back to, the scope it's requesting, a random state value, and (for PKCE) a code_challenge. (2) The authorization server authenticates the resource owner (login form) and shows a consent screen ('PhotoPrinter wants to: view your photos -- Allow / Deny'). (3) On approval, the authorization server redirects the browser BACK to the client's redirect_uri, with a short-lived, single-use code and the same state value appended as query parameters.
(4) The client verifies the returned state matches the value it originally sent (this defends against CSRF on the callback -- an attacker cannot forge a valid callback without also knowing the state the client generated). (5) The client then makes a direct, server-to-server POST request to the authorization server's /token endpoint, exchanging the code for tokens -- this request includes (for PKCE) the original code_verifier. (6) The authorization server validates the code (checking it hasn't expired, hasn't already been used, and that the code_verifier matches the code_challenge sent in step 1) and responds with an access_token (and usually a refresh_token). (7) The client uses the access_token on requests to the resource server to actually fetch the user's data.
Notice the authorization code itself is never usable on its own to access anything -- it only proves 'this specific browser, which the user just authenticated in, was redirected back with this exact code', and it must be exchanged, over a back-channel the browser never sees, for the actual access token. This is what makes the flow resistant to a whole class of attacks where an intercepted redirect URL alone isn't enough to steal access.
• 1. Client redirects browser to /authorize with client_id, redirect_uri, scope, state, code_challenge. • 2. Authorization server authenticates the user and shows a consent screen. • 3. Authorization server redirects back to redirect_uri with a short-lived, single-use code + the same state. • 4. Client verifies state matches what it originally generated (CSRF protection on the callback). • 5-6. Client POSTs the code (+ code_verifier) to /token; authorization server validates and returns tokens. • 7. Client calls the resource server's API using the access_token.
PKCE: Protecting the Code Exchange
PKCE (Proof Key for Code Exchange, pronounced 'pixy') was designed originally for mobile/native apps that cannot keep a client secret confidential (anyone can decompile the app binary and extract a hardcoded secret), and is now recommended for ALL clients, including confidential server-side ones, as defense-in-depth. It defends specifically against authorization code interception: if an attacker manages to intercept the redirect containing the code (e.g. via a malicious app registering the same custom URL scheme on a mobile OS, or a network-level interception), PKCE ensures that code alone is useless to them.
The mechanism: before redirecting to /authorize, the client generates a cryptographically random code_verifier (a long random string it keeps secret, never sent in the initial redirect) and derives a code_challenge from it -- typically code_challenge = BASE64URL(SHA256(code_verifier)). The client sends the code_challenge (not the verifier) in the initial /authorize redirect. Later, when exchanging the code at the /token endpoint, the client sends the original code_verifier in plaintext. The authorization server, which stored the code_challenge alongside the issued code, independently hashes the received code_verifier and checks it matches the stored code_challenge.
Why this defeats interception: an attacker who only sees the redirect (which carries the code and the code_challenge, both effectively public at that point) still cannot produce the matching code_verifier -- that value never left the legitimate client and SHA-256 cannot be reversed. Without the correct verifier, the token exchange fails even if the attacker has the authorization code itself.
• code_verifier: a random secret the client generates and keeps, never sent in the initial redirect. • code_challenge = BASE64URL(SHA256(code_verifier)) -- sent in the /authorize redirect, effectively public. • code_verifier is sent later, only in the back-channel /token exchange, and checked against the stored code_challenge. • An attacker who intercepts the redirect (and the code) still can't produce the matching verifier -- SHA-256 is one-way. • Recommended for every client type today, not just public/native apps that can't keep a secret.
Access Tokens, Refresh Tokens, and Scopes
An access token is what the client actually presents to the resource server on every API call (usually as Authorization: Bearer <token>). It's intentionally short-lived -- often 15 minutes to an hour -- so that if one leaks, the exposure window is small. A refresh token is longer-lived and is used ONLY against the authorization server's /token endpoint to obtain a new access token once the old one expires, without forcing the user to log in and re-consent again. Refresh tokens are more sensitive than access tokens precisely because they're longer-lived and more powerful (they can mint new access tokens indefinitely, until revoked or expired), so they're stored more carefully -- often not exposed to client-side JavaScript at all, and sometimes rotated (a new refresh token is issued on every use, and the old one is invalidated, so a stolen-but-unused refresh token becomes detectable if both the attacker and the legitimate client try to use it).
Scopes are how the client limits, and the user can see, exactly what access is being granted -- a request might ask for photos.read and profile.read but not photos.write or contacts.read. The authorization server enforces that whatever access token it issues is only valid for the scopes that were actually granted (the user might approve a subset of what was requested), and the resource server should check the token's granted scopes match what a given API endpoint requires, not just that the token is valid at all. This is the OAuth-level analog of the fine-grained permissions from the RBAC lesson: scopes are the client's slice of a user's overall access, deliberately kept as narrow as the integration actually needs.
• Access token: short-lived, sent on every API request to the resource server, 'Authorization: Bearer <token>'. • Refresh token: long-lived, used only against /token to mint a fresh access token -- never sent to the resource server directly. • Refresh token rotation: issue a new refresh token on every use and invalidate the old one, to detect reuse of a stolen token. • Scopes: the specific, narrow slices of access requested and granted (e.g. photos.read) -- always request the minimum needed.
Redirect URI Validation: Exact Match, Never a Wildcard
The redirect_uri is where the authorization server sends the user's browser back after login, carrying the authorization code. If an attacker can get the authorization server to redirect to a URI THEY control instead of the legitimate client's, they can capture the authorization code themselves and, if the client isn't also using PKCE, potentially exchange it for tokens as if they were the legitimate app -- this is 'open redirect'-style abuse of OAuth, and it has been the root cause of real account-takeover vulnerabilities in production OAuth integrations.
The authorization server must validate the redirect_uri on every /authorize request against an exact-match allowlist of URIs the client registered in advance -- character-for-character, including scheme, host, port, and path. It must never accept a redirect_uri merely because it starts with, contains, or matches a wildcard pattern against a registered value. A registered https://app.example.com/callback must reject https://app.example.com/callback/evil, https://app.example.com.attacker.com/callback, and https://evil.com/?redirect=https://app.example.com/callback alike -- all of these are different strings, and 'starts with' or substring matching would wrongly accept several of them.
This matters even with PKCE in play: PKCE protects the CODE EXCHANGE step, but a redirect to an attacker-controlled URI still leaks the authorization code (and potentially the state value) to that attacker's server via the browser's Referer header or their own request logs -- defense-in-depth means neither PKCE nor exact-match redirect validation is optional; they defend against different parts of the attack surface.
• Redirect URIs are pre-registered by the client and validated on every /authorize request. • Validation must be EXACT string match -- scheme, host, port, path -- never prefix/substring/wildcard matching. • A wildcard or 'starts with' match can be tricked by an attacker-controlled URI that merely contains the registered value. • PKCE and exact redirect URI validation defend different steps of the flow -- use both, not one instead of the other.
PKCE code_verifier and code_challenge generation
random_bytes(32) uses a cryptographically secure random source -- never use rand() or mt_rand() for security-sensitive values. The 'S256' challenge method (SHA-256) is required by modern OAuth guidance -- the older 'plain' method (challenge === verifier) offers no real protection and should not be used. $codeVerifier must be stored (e.g. server-side session) between the /authorize redirect and the /token exchange -- it's needed again later.
<?php
declare(strict_types=1);
function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
// A cryptographically random, secret string -- kept by the client only,
// never sent in the initial /authorize redirect.
$codeVerifier = base64UrlEncode(random_bytes(32));
// The public, derived value sent in the /authorize redirect.
$codeChallenge = base64UrlEncode(hash('sha256', $codeVerifier, true));Code Examples
The verifier is stored server-side in the session (never sent to the browser as part of this redirect); only the derived challenge, the state, and the requested scope go out in the URL. response_type=code and code_challenge_method=S256 tell the authorization server exactly which flow and PKCE method the client is using. Expected result: 302 redirect to https://auth.example.com/authorize?response_type=code&client_id=...&code_challenge=...&code_challenge_method=S256
'code_challenge' => $codeChallenge, 'code_challenge_method' => 'S256', hash_equals() performs a timing-safe comparison of the returned state against the stored one before anything else happens. The code_verifier generated back in the authorize step is sent here, in the back-channel /token request, proving to the authorization server that this is the same client that initiated the flow -- not just anyone who happened to intercept the redirect's code. Expected result: Connected. Granted scopes: photos.read profile.read if (!hash_equals($_SESSION['oauth_state'] ?? '', $returnedState)) {... abort... }
Common mistake: Skipping the state check (or comparing it with == instead of hash_equals) opens the callback to CSRF: an attacker can trick a victim's browser into completing an OAuth flow the attacker initiated, potentially linking the attacker's third-party account to the victim's session.
in_array(..., strict: true) with an array of fully-qualified registered URIs is the whole check -- no parsing tricks, no 'does it start with our domain' logic that a crafted lookalike host could defeat. Every listed attack variant is a different string from the registered one and is rejected outright. Expected result: accepted: exact match rejected: redirect_uri 'https://photoprinter.example.com/oauth/callback/evil' is not registered for client 'photoprinter-web' rejected: redirect_uri 'https://photoprinter.example.com.attacker.com/oauth/callback' is not registered for client 'photoprinter-web' rejected: redirect_uri 'https://attacker.com/?u=https://photoprinter.example.com/oauth/callback' is not registered for client 'photoprinter-web' if (!in_array($requestedRedirectUri, $registered, strict: true)) { throw...; }
<?php
declare(strict_types=1);
session_start();
function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
// Step 1: generate PKCE values and a CSRF-protecting state, store what
// we'll need again after the redirect comes back.
$codeVerifier = base64UrlEncode(random_bytes(32));
$codeChallenge = base64UrlEncode(hash('sha256', $codeVerifier, true));
$state = base64UrlEncode(random_bytes(16));
$_SESSION['oauth_code_verifier'] = $codeVerifier;
$_SESSION['oauth_state'] = $state;
$authorizeUrl = 'https://auth.example.com/authorize?' . http_build_query([
'response_type' => 'code',
'client_id' => 'photoprinter-web',
'redirect_uri' => 'https://photoprinter.example.com/oauth/callback',
'scope' => 'photos.read profile.read',
'state' => $state,
'code_challenge' => $codeChallenge,
'code_challenge_method' => 'S256',
]);
header("Location: {$authorizeUrl}");
exit;<?php
declare(strict_types=1);
session_start();
// Step 4: verify the state we get back matches the one we generated --
// this is the CSRF defense on the OAuth callback itself.
$returnedState = $_GET['state'] ?? '';
if (!hash_equals($_SESSION['oauth_state'] ?? '', $returnedState)) {
http_response_code(400);
exit('Invalid state -- possible CSRF attempt, aborting.');
}
$code = $_GET['code'] ?? null;
if ($code === null) {
http_response_code(400);
exit('Missing authorization code.');
}
// Step 5: exchange the code for tokens over a direct, server-to-server
// back-channel request -- the browser never sees this exchange or the
// resulting tokens directly.
$ch = curl_init('https://auth.example.com/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => 'https://photoprinter.example.com/oauth/callback',
'client_id' => 'photoprinter-web',
'code_verifier' => $_SESSION['oauth_code_verifier'], // proves we're the same client
]),
]);
$respo
…<?php
declare(strict_types=1);
final class InvalidRedirectUriException extends RuntimeException {}
final class RedirectUriValidator
{
/** @param array<string, string[]> $registeredUrisByClient client_id => exact registered URIs */
public function __construct(private readonly array $registeredUrisByClient) {}
public function assertValid(string $clientId, string $requestedRedirectUri): void
{
$registered = $this->registeredUrisByClient[$clientId] ?? [];
// Exact string match only -- scheme, host, port, and path must all
// match a registered value character-for-character. No prefix
// match, no substring match, no wildcard pattern of any kind.
if (!in_array($requestedRedirectUri, $registered, strict: true)) {
throw new InvalidRedirectUriException(
"redirect_uri '{$requestedRedirectUri}' is not registered for client '{$clientId}'"
);
}
}
}
$validator = new RedirectUriValidator([
'photoprinter-web' => ['https://photoprinter.example.com/oauth/callback'],
]);
// Legitimate: matches a registered URI exactly.
$validator->assertValid('photoprinter-web', 'https://photoprinter
…Authorization Code flow with PKCE
The authorization code (step 3) is useless alone; only the client holding the original code_verifier can redeem it in step 5.
Client (browser) Authorization Server Resource Server
| | |
| 1. redirect: /authorize | |
| client_id, redirect_uri, |
| scope, state, | |
| code_challenge | |
|-------------------------> | |
| | 2. login + consent |
| | screen shown to user |
| | |
| 3. redirect back to redirect_uri |
| ?code=...&state=... | |
| <-------------------------| |
| | |
| 4. verify state matches | |
| (client-side check) | |
| | |
| 5. POST /token (server-to-server, no browser) |
| code +
…Important Rules
The client never sees or handles the resource owner's password -- the user authenticates directly with the authorization server.
The authorization code is short-lived and single-use; it's exchanged for tokens over a back-channel, server-to-server request.
Always verify the state parameter on the callback with a timing-safe comparison before doing anything else.
Use PKCE (S256 method) for every client type, not only public/native apps -- it's defense-in-depth even for confidential clients.
Validate redirect_uri as an exact-match allowlist on every /authorize request -- never prefix, substring, or wildcard matching.
Request the minimum scopes actually needed; treat access tokens as short-lived and refresh tokens as the more sensitive, longer-lived credential.
Where This Flow Shows Up
'Sign in with Google/GitHub/Microsoft' buttons, apps that connect to your Slack workspace or Google Calendar, and mobile banking apps that link to a budgeting app all use the Authorization Code flow with PKCE today -- it's the flow the OAuth 2.0 Security Best Current Practice document recommends for every client type, having fully superseded the older Implicit flow (which returned tokens directly in a URL fragment and had no equivalent protection against interception). PHP OAuth server implementations like the League OAuth2 Server library implement exactly the roles, code exchange, and PKCE validation described in this lesson.
// Authorization server config: redirect_uris allowed for this client 'redirect_uris' => ['https://*.example.com/*']
'redirect_uris' => ['https://app.example.com/oauth/callback']
A wildcard redirect URI pattern can be satisfied by an attacker-controlled subdomain or path they control (e.g. an open redirect on a marketing subdomain, or a path an attacker can host content at), letting them receive the authorization code intended for the legitimate client. Redirect URIs must be pre-registered, fully-qualified, exact strings.
In OAuth 2.0, which role authenticates the resource owner and issues tokens? A. The client B. The resource server C. The authorization server D. The resource owner authenticates themself, with no server involved
C. The authorization server — The authorization server handles login and consent, and is the only party that issues tokens; the client redirects the user there rather than collecting credentials itself.
What does PKCE's code_challenge/code_verifier pair specifically protect against? A. SQL injection in the /token endpoint B. An attacker who intercepts the authorization code being able to exchange it for tokens themselves C. Cross-site scripting in the consent screen D. The access token being too short-lived
B. An attacker who intercepts the authorization code being able to exchange it for tokens themselves — Even if an attacker captures the redirect's authorization code, they cannot produce the matching code_verifier (which never left the legitimate client), so the token exchange fails for them.
Why should an authorization server validate redirect_uri with exact string matching rather than a wildcard or prefix match? A. Exact matching is faster to execute B. A wildcard/prefix pattern can be satisfied by an attacker-controlled URI, letting them receive the authorization code meant for the legitimate client C. OAuth 2.0 requires all URIs to be lowercase D. It has no real security benefit, it's just convention
B. A wildcard/prefix pattern can be satisfied by an attacker-controlled URI, letting them receive the authorization code meant for the legitimate client — A pattern match can be tricked by a lookalike or attacker-hosted URI that merely satisfies the pattern, redirecting the code to a party other than the legitimate registered client.
What is the key difference between an access token and a refresh token? A. They are identical; the names are interchangeable B. Access tokens are used directly against the resource server's API and are short-lived; refresh tokens are used only against the authorization server to mint new access tokens and are longer-lived C. Refresh tokens are sent on every API call; access tokens are only used once at login D. Access tokens never expire; refresh tokens expire after one use
B. Access tokens are used directly against the resource server's API and are short-lived; refresh tokens are used only against the authorization server to mint new access tokens and are longer-lived — Access tokens authorize API calls and are kept short-lived to limit exposure if leaked; refresh tokens are a separate, more sensitive credential used only to obtain new access tokens without re-prompting the user to log in.
Extend the RedirectUriValidator example so a client can register MULTIPLE valid redirect URIs (e.g. one for production, one for a local dev callback), and write a quick check confirming a URI registered for a different client_id is still rejected even if it matches that other client's registered URI exactly.
$validator = new RedirectUriValidator(['app-a' => ['https://a.example.com/cb'], 'app-b' => ['https://b.example.com/cb']]); calling $validator->assertValid('app-a', 'https://b.example.com/cb') throws, because 'https://b.example.com/cb' is not in app-a's registered list even though it's valid for app-b.
The $registeredUrisByClient array already supports multiple URIs per client id (it's an array of arrays) -- add a second entry to one client's array, then call assertValid() with the RIGHT uri but the WRONG client_id and confirm it throws.