Don't like ads? Go Ad-Free Today

OAuth 2.0 Flows Authorization Code, PKCE, and Client Credentials

Updated on

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.

OAuth 2.0 Flows: Authorization Code, PKCE, and Client Credentials 1
ADVERTISEMENT · REMOVE?

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.

FlowWhen to use itUser involved?Needs client_secret?
Authorization CodeWeb app with a backend serverYesYes — stays on the server
Authorization Code + PKCESPA, mobile app, any public clientYesNo
Client CredentialsServer-to-server (no user)NoYes — 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.

Want To enjoy an ad-free experience? Go Ad-Free Today

Install Our Extensions

Add IO tools to your favorite browser for instant access and faster searching

Add to Chrome Extension Add to Edge Extension Add to Firefox Extension Add to Opera Extension

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!

ADVERTISEMENT · REMOVE?
ADVERTISEMENT · REMOVE?
ADVERTISEMENT · REMOVE?

News Corner w/ Tech Highlights

Get Involved

Help us continue providing valuable free tools

Buy me a coffee
ADVERTISEMENT · REMOVE?