Skip to content

OAuth Flows Compared — Code, PKCE, Implicit, Device, OIDC

These are not five peers:

Authorization Code ─┐
Authorization Code + PKCE ─┴─ same flow. PKCE hardens it. Not a separate flow.
Implicit ─── its own flow, deprecated
Device Code ─── its own flow, niche
OpenID Connect (OIDC) ─── NOT a flow. A LAYER riding on the code flow.

Three flows, one hardening, one layer.

Flow Client type client_secret redirect_uri How the token arrives Status
Authorization Code confidential (backend) ✓ required ✓ required back-channel POST recommended
Code + PKCE public (SPA/mobile/CLI) ✗ — verifier instead ✓ required back-channel POST recommended for all
Implicit SPA (legacy) ✗ ✓ required URL fragment, front channel deprecated
Device Code TV, IoT, CLI, headless ✗ ✗ none polling → back channel niche, opt-in
OIDC any (on top of code flow) depends ✓ required id_token via the code flow the identity standard
Auth Code response_type=code
PKCE response_type=code + code_challenge + code_challenge_method=S256
Implicit response_type=token (or id_token for OIDC implicit)
Device no /authorize redirect at all → POST /device/code, then poll /token
OIDC scope=openid → response gains an id_token
1. 302 → /authorize?response_type=code&client_id&redirect_uri&scope&state
2. user consents
3. 302 → /callback?code=..&state=..
4. back channel: POST /token grant_type=authorization_code
+ code + client_secret + redirect_uri
5. → access_token (+ refresh_token if offline_access)

The code travels the front channel (browser URL); the token is fetched over the back channel. Full detail in the OAuth page.

Same shape as above, plus a per-flow proof so a stolen code is useless:

1. client generates code_verifier = random 43-128 chars (kept local)
code_challenge = base64url(SHA256(verifier))
2. /authorize?...&code_challenge=..&code_challenge_method=S256
3. /callback?code=..
4. POST /token + code + code_verifier ← server recomputes the hash and compares

Why it exists: the code travels the browser and must be assumed leakable. PKCE binds the exchange to whoever started the flow — no pre-shared secret needed, so it works for public clients.

The downgrade attack: if the server doesn’t require code_challenge, an attacker strips PKCE from step 2 and it protects nothing. Servers must enforce it for public clients.

Only S256 counts. plain provides no protection.

OAuth 2.1 requires PKCE for every authorization-code request.

302 → app/#access_token=xyz&expires_in=3600
↑ token in the URL FRAGMENT, no exchange step

Why it died:

Problem Consequence
Token in the URL fragment lands in browser history; readable by any JS on the page
No exchange step no PKCE possible, no client authentication
Historically no refresh token user re-authenticates every expiry
Any XSS instant token theft

The one honest advantage: a fragment is never sent to a server, so it stays out of server logs. Not enough to save it.

Replaced by Code + PKCE — same public-client capability, but through a single-use, verifier-bound exchange.

For devices with no browser or no keyboard:

1. device → POST /device/code (client_id only)
← { device_code, user_code: "ABCD-1234",
verification_uri: "github.com/login/device", interval: 5 }
2. display to the user: "go to github.com/login/device and enter ABCD-1234"
3. user opens it on their phone/laptop and approves
4. device → POST /token grant_type=urn:ietf:params:oauth:grant-type:device_code
← authorization_pending ...retry every `interval` seconds
← { access_token } once approved

Same /token endpoint as always. No redirect_uri, no client_secret — the device can’t receive a redirect, so it polls instead.

Real users: gh auth login, docker login on a headless box, smart TVs, printers, Raspberry Pi.

Security warning — why it’s opt-in per app (GitHub’s own guidance):

The device flow does not require redirect URIs at all, which means that an attacker can use the device flow to remotely impersonate your app as part of a phishing attack.

With no redirect_uri to constrain the flow, an attacker holding your client_id can drive a device flow and show a victim GitHub’s real consent screen branded with your app’s name. Enable it only for genuinely constrained environments.

Not used by: authorization-code clients, and it has no PKCE (nothing to intercept).

OAuth 2.0 → "this app may access these resources" (authorization)
OIDC → "and the user is <identity>" (authentication)

Rides the authorization code flow and adds:

Addition Purpose
scope=openid turns on OIDC
id_token a signed JWT with sub, email, iss, aud, exp
/userinfo endpoint for claims
nonce anti-replay for the id_token — distinct from state
/.well-known/openid-configuration discovery: endpoints + public keys
JWKS public keys to verify the id_token signature offline

Plain OAuth never tells you who the user is. It was designed for delegated access, not identity. “Sign in with Google” is OIDC.

OIDC = a protocol (OpenID Connect 1.0, OpenID Foundation)
Google / Okta = identity providers (IdP) that implement it
your app = the relying party (RP)

Three things named “OpenID”:

Name What Status
OpenID 2.0 old decentralized identity (identity = a URL) dead
OpenID Connect (OIDC) OAuth 2.0 + identity layer current
OpenID Foundation the org stewarding the spec an org

GitHub vs Google — the practical difference

Section titled “GitHub vs Google — the practical difference”

GitHub is an OAuth provider, not an OIDC IdP for user login. Its token response has no id_token:

{ "access_token": "...", "token_type": "bearer", "scope": "...", "expires_in": 28800 }

So identity requires a second call: GET /api.github.com/user with the bearer token. That’s OAuth doing an identity job — trustworthy only because the call goes to GitHub’s own API.

Google is a true OIDC provider. With scope=openid the token response includes a signed id_token, verifiable offline against Google’s JWKS — no extra call to learn who logged in.

Google → id_token ✓ (true OIDC) Okta, Auth0, Keycloak, Entra, Apple → id_token ✓
GitHub → no id_token (OAuth + /user call)

(The id_token claim on an unvalidated JWT is the whole reason the JWT page matters — alg:none, weak HMAC secret, unchecked aud/iss/exp.)

Flow Where it breaks
Auth Code redirect_uri manipulation, code interception, missing/ignored state
Code + PKCE nothing new — but downgrade if the server doesn’t require code_challenge; plain method accepted
Implicit token in URL fragment → history, XSS reads it, no refresh token
Device Code phishing — no redirect_uri constrains the flow; attacker impersonates your app
OIDC nonce replay; id_token is a JWT → alg:none, weak secret, unvalidated aud/iss/exp
New app, any client type → Authorization Code + PKCE
Backend-only service → Code + PKCE (or client_credentials when no user)
CLI / IoT / TV → Device Code (only if genuinely needed)
Sign-in (identity) → OIDC on top of Code + PKCE
Implicit → never. Legacy only.
  • RFC 6749 (OAuth 2.0), RFC 7636 (PKCE), RFC 8628 (Device Authorization Grant), RFC 8725 (JWT BCP)
  • OpenID Connect Core 1.0
  • OAuth 2.1 draft — PKCE required, implicit and password grants removed
  • GitHub docs: authorizing OAuth apps; device flow guidance; PKCE changelog (2025-07-14)
  • OAuth.com Playground — observed flows