Topics
Security

OAuth 2.0 and PKCE

How the authorization code flow lets an app act for a user without their password, what PKCE proves, and where tokens should live in a SPA.

Intermediate·14 min read·Updated Sep 28, 2026

OAuth 2.0 is a delegation protocol: it lets an application act on a user's behalf at another service without ever seeing the user's password. The authorization server hands the app a short-lived access token after the user consents, and the app presents that token instead of credentials. The authorization code flow is the one every web and mobile app should use, and PKCE (Proof Key for Code Exchange) is the extra step that makes it safe even when the app cannot keep a secret.

Context

Before OAuth, "connect your Gmail" meant typing your Gmail password into a third-party site, which then logged in as you and could do anything. OAuth 1.0 (2007, RFC 5849) replaced that with signed requests and per-app tokens; it worked but the request-signing was painful to implement. OAuth 2.0 (RFC 6749, October 2012) dropped signatures in favour of plain bearer tokens over TLS (Transport Layer Security) and became the protocol behind every "Sign in with Google / GitHub / Apple" button, every Slack or Zapier integration, and every CLI that opens a browser tab to log you in.

Two things happened after 2012. Single-page apps (SPAs) and mobile apps could not keep a client secret, so a simplified implicit flow put tokens straight into the browser URL, and that leaked tokens through history, referrers and logs. PKCE (RFC 7636, 2015) fixed the underlying problem for mobile apps, and the "OAuth 2.0 for Browser-Based Apps" best-practice document plus the OAuth 2.1 draft (2020 onward) made PKCE mandatory for every client and deprecated implicit entirely. OpenID Connect (OIDC, 2014) layers identity on top: it adds an ID token that says who logged in, because OAuth on its own only says what the app may access.

You have seen the whole thing every time a site bounced you to accounts.google.com, showed a consent screen listing "view your email address", and bounced you back with ?code=4/0AX…&state=… in the URL for a split second.

Resource owner
The user who owns the data and grants access.
Client
Your app. Confidential if it can hold a secret (server), public if it cannot (SPA, mobile, CLI).
Authorization server
Issues tokens after authenticating the user: Google, Auth0, Keycloak, your own.
Resource server
The API that accepts the access token: the Gmail API, GitHub API, your backend.
Scope
A string naming what the token may do, e.g. repo:read or openid profile.
Redirect URI
The exact URL the authorization server sends the user back to, registered ahead of time.

Why it matters

Any product with a "Connect X" button or social login runs this flow, and the failure modes are silent: a loose redirect URI check or a missing state parameter does not throw an error, it just lets an attacker log in as your users. Most auth incidents in OAuth integrations are not cryptographic breaks, they are one of five or six known mistakes in how the flow is wired.

It also decides your architecture. Whether tokens live on your server or in the browser changes how you handle CORS (cross-origin requests), CSRF (cross-site request forgery), refresh, logout and mobile. Choosing that before you understand the flow is how apps end up with access tokens in localStorage.

The authorization code flow

The flow has two halves. The front channel goes through the user's browser via redirects, so it is visible and tamperable; only a one-time code travels there. The back channel is a direct HTTPS call from your server to the authorization server, and that is where the code is traded for tokens. Tokens never touch the browser URL.

browseryour backendauth server1 · GET /login2 · 302 → /authorize?code_challenge=…3 · GET /authorize · user logs in and consents4 · 302 → redirect_uri?code=…&state=…5 · GET /callback?code&state6 · POST /token code + code_verifier7 · { access_token, refresh_token, id_token }8 · Set-Cookie: session=…tokens stay server-side
Authorization code flow for a server-rendered or BFF (backend-for-frontend) app. Steps 1-5 are the front channel (through the browser); 6-7 are the back channel (server to server).
  1. 1
    Your backend generates a random state and a PKCE pair, stores both in the user's session, and redirects the browser to the authorization server's /authorize endpoint.
  2. 2
    The user authenticates there (password, passkey, multi-factor auth, whatever the provider does) and sees a consent screen listing the requested scopes. Your app never sees any of it.
  3. 3
    The authorization server redirects back to your registered redirect_uri with a one-time code and the same state. The code expires in about a minute and can be redeemed once.
  4. 4
    Your backend checks state against the session, then POSTs the code, its client secret and the PKCE verifier to /token over a direct connection.
  5. 5
    It receives the access token (for the API), usually a refresh token (to get new access tokens later) and, with OpenID Connect, an ID token (who the user is). It stores them server-side and gives the browser an ordinary session cookie.
login.ts (step 1 — build the authorize URL)
const {verifier, challenge} = await createPkcePair()
const state = base64url(crypto.getRandomValues(new Uint8Array(16)))
await session.set({pkceVerifier: verifier, oauthState: state})

const url = new URL('https://accounts.example.com/authorize')
url.search = new URLSearchParams({
  response_type: 'code',
  client_id: CLIENT_ID,
  redirect_uri: 'https://app.example.com/callback',
  scope: 'openid profile repo:read',
  state,
  code_challenge: challenge,
  code_challenge_method: 'S256',
}).toString()

redirect(url.toString())
callback.ts (steps 4-5 — exchange the code on the back channel)
const {code, state} = query
if (!code || state !== session.oauthState) {
  throw new Error('state mismatch') // login CSRF attempt or stale tab
}

const res = await fetch('https://accounts.example.com/token', {
  method: 'POST',
  headers: {'Content-Type': 'application/x-www-form-urlencoded'},
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: 'https://app.example.com/callback', // must equal step 1
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,        // confidential clients only
    code_verifier: session.pkceVerifier, // PKCE proof, see below
  }),
})
const {access_token, refresh_token, id_token, expires_in} = await res.json()
// expires_in: 3600  → access token is good for an hour

What PKCE actually proves

The code travels through the browser, so assume it can be stolen: a malicious mobile app registering the same custom URL scheme (the original motivation), a browser extension, a referrer leak, a proxy log. For a confidential client the thief still needs the client secret. For a public client (SPA, mobile, CLI) there is no secret, so before PKCE a stolen code was a stolen login.

PKCE (pronounced "pixy") binds the code to the client instance that started the flow. The client invents a random code_verifier, sends only its SHA-256 hash (the code_challenge) in step 1, and reveals the verifier in step 4. The authorization server hashes the verifier and compares. A thief who intercepted the code saw the hash, not the preimage, and cannot redeem it.

client (yours)
code_verifiercode_challengecodecan redeem
auth server
code_challengecodechecks SHA256(verifier) == challenge
attacker
code_challengecodeno verifier → /token rejects
What each party holds. The attacker sees everything on the front channel and still cannot complete step 6.
pkce.ts (Web Crypto — works in browsers and Node 18+)
function base64url(bytes: Uint8Array) {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

export async function createPkcePair() {
  // 32 random bytes → 43 chars, inside the 43-128 range RFC 7636 requires
  const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)))
  const digest = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(verifier),
  )
  const challenge = base64url(new Uint8Array(digest))
  return {verifier, challenge}
}
// verifier:  dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
// challenge: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

Three tokens, three jobs

A successful exchange can return up to three tokens, and mixing them up is the most common conceptual bug in OAuth code.

TokenWho reads itLifetimeRule
access_tokenThe resource server (API)Minutes to an hourSend as Authorization: Bearer. Opaque to you: do not decode it to learn who the user is, even if it happens to be a JWT (JSON Web Token).
refresh_tokenThe authorization server onlyDays to monthsNever sent to an API. Exchange it at /token for a new access token when the old one expires. Treat as a credential.
id_tokenYour app (the client)MinutesOpenID Connect only. A signed JWT with sub, email, nonce, aud. This is the proof of who logged in. Verify signature, issuer, audience and nonce.

Where tokens live in a SPA

A browser app has no secret and no safe storage: anything JavaScript can read, an XSS (cross-site scripting) payload can read. The browser-based-apps BCP (Best Current Practice document) ranks the options, and the ranking is the answer interviewers want.

PatternWhere tokens sitXSS blast radiusVerdict
Backend for Frontend (BFF)On your server, keyed by an httpOnly session cookieAttacker can make requests while the page is open, but cannot exfiltrate tokensRecommended. The SPA talks only to your backend, which proxies to APIs.
In memory + refresh rotationAccess token in a JS variable, refresh token rotated on every useAttacker gets the current access token; rotation + reuse detection limits refresh abuseAcceptable when a BFF is impossible. Lost on reload, so silent re-auth is needed.
localStorage / sessionStorageReadable by any script on the originFull token theft, including refresh tokensNo. Any dependency with XSS becomes account takeover.

Refresh token rotation

Every refresh returns a new refresh token and invalidates the old one. If the authorization server ever sees an already-used refresh token, someone has a copy: it revokes the whole token family and forces a fresh login. That single rule turns a long-lived credential into a short-lived one with an alarm attached.

refresh.ts (back channel; the access token expired)
const res = await fetch('https://accounts.example.com/token', {
  method: 'POST',
  headers: {'Content-Type': 'application/x-www-form-urlencoded'},
  body: new URLSearchParams({
    grant_type: 'refresh_token',
    refresh_token: stored.refreshToken,
    client_id: CLIENT_ID,
  }),
})
if (res.status === 400) {
  // invalid_grant: rotated-out or revoked → the family is dead, re-login
  await session.destroy()
  return redirect('/login')
}
const {access_token, refresh_token} = await res.json()
await stored.replace({accessToken: access_token, refreshToken: refresh_token})

Other grants and when to use them

"Grant type" is OAuth's word for which flow. The authorization code flow covers every case where a human is present in a browser. The rest exist for cases where they are not, plus two that survive only as legacy.

GrantUse whenStatus
authorization_code + PKCEA user logs in through a browser: web apps, SPAs, mobile, desktopThe default. Required form in OAuth 2.1.
client_credentialsNo user: service-to-service calls, cron jobs, one backend calling another with its own identityCurrent. Token represents the app, not a person.
device_codeNo browser on the device: CLIs, TVs, IoT devices. The device shows a short code, the user enters it on their phoneCurrent (RFC 8628). This is what gh auth login does.
implicitSPAs before PKCE; access token returned directly in the URL fragmentDeprecated. Removed in OAuth 2.1. Leaks tokens via history and referrers.
password (ROPC, resource owner password credentials)App collects username + password itself and posts them for a tokenDeprecated. Removed in OAuth 2.1. Defeats the whole point of delegation and blocks MFA.

Pitfalls

  • Loose redirect_uri matching

    Registering https://app.example.com/* or comparing only the host lets an attacker request a redirect to https://app.example.com/open?url=evil.com and receive the code via your own open redirect. Register the full URL and compare it byte for byte, which is what RFC 6749 §3.1.2 and OAuth 2.1 require.

  • Skipping the state check because "it works without it"

    It does work, for the attacker too. Login CSRF logs the victim into the attacker's account, so every card, address or document the victim adds afterwards lands in an account the attacker can read. Generate state per attempt, bind it to the session, and reject a callback that does not match.

  • Tokens in localStorage

    It is convenient because it survives reloads, and that is exactly the problem: it survives for any script on the origin, including a compromised analytics snippet or an npm dependency with a supply-chain payload. Use a BFF with an httpOnly cookie, or keep the access token in memory and rotate refresh tokens.

  • Using the access token as the user identity

    Access tokens are for resource servers and are often opaque; decoding one to find an email is undefined behaviour that breaks when the provider changes formats, and accepting one from the client is the token-substitution attack above. Identity comes from the ID token or a server-side /userinfo call using a token you obtained.

  • Asking for every scope up front

    Consent screens listing twelve permissions get declined, and a leaked token with repo instead of read:user is a much bigger incident. Request the minimum, and use incremental authorization (a second flow with extra scopes) when a feature actually needs more.

Interview questions

Q1Walk me through the authorization code flow with PKCE for a single-page app.

The app generates a random code verifier and its SHA-256 challenge, plus a state value, and redirects the browser to the provider's authorize endpoint with the challenge and state. The user logs in and consents there; the provider redirects back with a one-time code and the state. The app checks state, then POSTs the code and the verifier to the token endpoint; the provider hashes the verifier, compares it to the stored challenge, and returns access, refresh and ID tokens. In a SPA I would put that exchange behind a backend-for-frontend so the tokens land in a server session and the browser only gets an httpOnly cookie.

Q2Why was the implicit flow deprecated?

It returned the access token in the URL fragment, so the token ended up in browser history, could leak through referrers and extensions, and there was no way to bind it to the client that requested it. It existed only because SPAs could not make a cross-origin token request in 2012; CORS and PKCE removed that reason, so OAuth 2.1 and the browser-apps BCP tell SPAs to use the code flow with PKCE instead.

Q3What does PKCE protect against, and does a confidential client need it?

It protects against someone who intercepts the authorization code redeeming it: the token endpoint demands the verifier whose hash was sent at the start, and the interceptor only saw the hash. Confidential clients should still use it, because it also stops authorization code injection, where an attacker gets the victim's browser to complete a flow with a code from the attacker's own session. OAuth 2.1 makes it mandatory for all clients.

Q4What is the state parameter for, and what happens if you drop it?

It is a per-attempt random value that ties the callback to the session that started the flow, which is CSRF protection. Without it, an attacker can start a login as themselves, capture the callback URL, and get the victim to open it; the victim is then silently logged into the attacker's account and anything they store is exposed. With OpenID Connect the nonce plays the same role for the ID token specifically.

Q5Access token, refresh token, ID token: which one do you send to your own API?

The access token, as a bearer header, and only that. The refresh token goes to the authorization server alone to get new access tokens; the ID token is for the client to learn who logged in and is never an API credential. If my own backend is the resource server, I validate the access token there, either by introspection or, if it is a JWT, by verifying signature, issuer, audience and expiry against the provider's JWKS (its published key set).

Q6What happens when an access token expires in the middle of a user session?

The API returns 401, the client uses the refresh token at the token endpoint, and retries with the new access token; the user never notices. With rotation the refresh call also returns a new refresh token and invalidates the old one. If the refresh call itself fails with invalid_grant, the token family is revoked or a reuse was detected, so I clear the session and send the user back through login.

Q7Is OAuth an authentication protocol?

No, it is authorization: an access token says what the bearer may do, not who they are. Treating it as a login lets an attacker present a token issued to a different app and impersonate the victim, because nothing in the token names your client. OpenID Connect is the authentication layer: its ID token is signed, carries an audience claim for your client and a nonce you generated, and that is what you verify to log someone in.

Key takeaways
  • OAuth 2.0 delegates access without sharing passwords; the client gets a scoped, expiring access token after the user consents at the authorization server.
  • Use the authorization code flow for anything with a browser: only a one-time code crosses the front channel, and the token exchange happens server to server.
  • PKCE binds the code to the client that started the flow with a verifier/challenge hash pair; use it for every client, confidential ones included.
  • state and exact redirect_uri matching are not optional; dropping either is a working account-takeover path.
  • In a SPA prefer a backend-for-frontend with an httpOnly session cookie; never put tokens in localStorage. Rotate refresh tokens and revoke the family on reuse.
  • OAuth is authorization. Identity comes from OpenID Connect's ID token, verified for signature, issuer, audience and nonce.

Preparing for interviews? DevRecall turns a job description into a prep plan that points at topics like this one.

Start free