OAuth 2.0 Flows Authorization Code, PKCE, and Client Credentials
A developer's guide to picking the right OAuth 2.0 flow. Covers authorization code (web apps), PKCE (SPAs and mobile), and client credentials (server-to-server) with working code examples and the mistakes that bite you later.
Three flows cover 95% of real OAuth 2.0 use cases. The spec defines more, but the rest are deprecated, edge-case, or both. Pick the right one up front and you skip a painful refactor when auth requirements change.
| Flow | When to use it | User involved? | Needs client_secret? |
|---|---|---|---|
| Authorization Code | Web app with a backend server | Yes | Yes — stays on the server |
| Authorization Code + PKCE | SPA, mobile app, any public client | Yes | No |
| Client Credentials | Server-to-server (no user) | No | Yes — stays on the server |
Authorization Code Flow
The standard flow for web apps with a backend. The access token and client secret never touch the browser — the token exchange happens server-side. That’s the whole point.
Step 1: Redirect the user
Build an authorization URL and redirect the browser to it:
GET https://accounts.example.com/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=openid+profile+email
&state=RANDOM_CSRF_TOKEN
The state value is your CSRF defense for the redirect. Generate a fresh random string per flow, store it in the session, and verify it when the user comes back. If you skip this check, an attacker can drive users through a flow using the attacker’s own authorization code — silently linking the user’s account to the attacker’s identity.
Step 2: Handle the callback
The authorization server redirects back to your redirect_uri with a short-lived code:
GET https://yourapp.com/callback?code=AUTH_CODE&state=SAME_STATE_YOU_SENT
Verify state matches what you stored. Then exchange the code server-side.
Step 3: Exchange the code for tokens (server-side only)
POST https://accounts.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https://yourapp.com/callback
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
The response:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "def50200a12b3c...",
"scope": "openid profile email"
}
Keep both tokens server-side. When access_token expires (check expires_in), use the refresh_token to get a new one without sending the user through login again.
PKCE — For Clients That Can’t Keep Secrets
A SPA or mobile app has no safe place for a client_secret. Anyone can open DevTools and find it in your JS bundle. Anyone can decompile your APK. PKCE (Proof Key for Code Exchange, pronounced “pixy”) solves this with a one-time cryptographic challenge — no shared secret needed.
The flow is identical to Authorization Code with two additions: a code_verifier (random string you generate) and a code_challenge (SHA-256 hash of the verifier, base64url-encoded). You send the challenge upfront, then prove you hold the verifier at exchange time.
Step 1: Generate the verifier and challenge
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64url(array);
}
async function generateCodeChallenge(verifier) {
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64url(new Uint8Array(digest));
}
function base64url(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// Usage
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store codeVerifier in memory — NOT localStorage
Store the code_verifier in memory — a module-scoped variable, not localStorage. You’ll send it at token exchange time.
Step 2: Authorization request — add the challenge
GET https://accounts.example.com/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=openid+profile
&state=RANDOM_CSRF_TOKEN
&code_challenge=BASE64URL_SHA256_OF_VERIFIER
&code_challenge_method=S256
Step 3: Token exchange — verifier instead of client_secret
POST https://accounts.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https://yourapp.com/callback
&client_id=YOUR_CLIENT_ID
&code_verifier=ORIGINAL_VERIFIER_YOU_GENERATED
The authorization server hashes the verifier and checks it against the challenge you sent in step 2. An attacker who intercepted the auth code has no idea what the verifier is — they cannot exchange it.
Worth knowing: OAuth 2.1 (the in-progress modernization of 2.0) mandates PKCE for all flows involving redirects. If you’re writing new code, use PKCE regardless of whether your provider currently requires it.
Client Credentials — No User, No Problem
Background jobs, microservices calling other microservices, cron tasks hitting an API — none of these involve a user. Client Credentials is the right flow: the service authenticates directly using its own client ID and secret.
POST https://accounts.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&scope=api:read api:write
Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 86400
}
No refresh token — when it expires, just request a new one. The common mistake: hitting the token endpoint on every API call. Cache the token, check expiry before each request, only re-fetch when it’s about to expire. One token request per day (or per hour, depending on expires_in) instead of one per request:
let cachedToken = null;
let tokenExpiresAt = 0;
async function getAccessToken() {
// Refresh 30 seconds before actual expiry
if (cachedToken && Date.now() < tokenExpiresAt - 30_000) {
return cachedToken;
}
const res = await fetch('https://accounts.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
scope: 'api:read api:write',
}),
});
const data = await res.json();
cachedToken = data.access_token;
tokenExpiresAt = Date.now() + (data.expires_in * 1000);
return cachedToken;
}
Mistakes Developers Actually Make
Storing tokens in localStorage
Any XSS vulnerability — in your own code, a dependency, a tag manager script — can read everything in localStorage. For SPAs: store the access token in memory (a module-scoped variable that disappears on refresh). Use httpOnly cookies for refresh tokens when you have a backend that can set them. JavaScript can't read httpOnly cookies.
Using the implicit flow
The implicit flow returns tokens directly in the URL fragment (#access_token=...). Those tokens end up in browser history, server access logs, and Referer headers. It was deprecated in RFC 9700. There is no reason to use implicit flow for new code. Use PKCE.
Skipping state validation
Without state validation on the callback, an attacker can craft a redirect URL that completes an OAuth flow using their own authorization code. The result: your user's account gets linked to the attacker's identity at the provider. Generate it fresh per flow, store it in the session, check it on callback.
Putting client_secret in frontend code
There is no such thing as a secret that lives in a browser. Minification does not hide it. Obfuscation does not protect it. If your runtime is a browser or a mobile app, you have a public client — use PKCE and omit the client_secret entirely. That's not a workaround; it's how the spec intends public clients to work.
Not handling token expiry proactively
Every access token has an expires_in value. If you hold onto a token until it fails with a 401 and then re-auth, users hit mysterious errors. Check expiry before making requests, refresh proactively (30 seconds before expiry is a reasonable buffer), and handle the rare case where a refresh token itself has expired.
Inspecting Tokens While You Work
Most OAuth providers issue JWTs as access tokens. The payload is base64url-encoded and readable without the private key — only the signature needs the key to verify. When you're debugging a flow and want to see the claims, scopes, or expiry in a token, paste it into the JWT Decoder.
If you're hand-testing PKCE and need to verify what a base64url-encoded code_challenge decodes to, the Base64 converter handles standard and URL-safe variants.
The Short Version
One question determines the right flow: does your runtime have a secure place to keep a secret?
- Backend server → Authorization Code or Client Credentials (secret stays on the server)
- Browser or mobile app → Authorization Code + PKCE (no secret at all)
- No user involved → Client Credentials
PKCE works for every flow involving a redirect — there's no downside to using it even when your provider doesn't require it yet. OAuth 2.1 will.
You may also like
Install Our Extensions
Add IO tools to your favorite browser for instant access and faster searching
恵 Scoreboard Has Arrived!
Scoreboard is a fun way to keep track of your games, all data is stored in your browser. More features are coming soon!
Must-Try Tools
View All New Arrivals
View AllUpdate: Our latest tool was added on Jun 13, 2026
