Share some knowledge, skills and others — security research, pentesting notes and more.

View on GitHub
23 January 2022

OAuth, OIDC and PKCE

by allencharp

When the Answer Is OAuth

Adding OAuth does not settle the problem, it redistributes it — across four roles, each with its own attack surface. The mistake is treating a valid token as the end of the discussion.

The Four Roles

OAuth 2.0 (RFC 6749) is an authorization framework that lets a user grant a third-party application limited access to their resources without sharing credentials. It defines four roles, and nearly every OAuth bug is a confusion about which role owns which responsibility.

The clearest real example is “Sign in to GitHub with Google”. Watch who plays each part:

The trap to avoid: GitHub is the Client, not the Resource Server. The Resource Server is always the party holding the data the client wants — here, Google. Google actually plays two roles at once: it is both the authorization server (it issues the token) and the resource server (it serves your profile).

Walking through one click on “Sign in with Google”:

  1. You choose Google on GitHub’s login page.
  2. GitHub (Client) redirects you to Google (Authorization Server).
  3. Google authenticates you — password, 2FA, the works.
  4. Google hands GitHub an id_token (who you are) plus an access_token (the key card).
  5. GitHub presents the access_token to Google’s userinfo API (Resource Server) and reads back your email and name.
  6. GitHub now knows who you are and lets you in.

Because this is a login, it is OIDC: that extra id_token is the “name tag” telling GitHub which Google user just signed in — the same split covered later, where OAuth gives you a key card and OIDC also gives an ID card.

OAuth Authorizes, OIDC Authenticates

The shortest correct summary: OAuth is authorization, OIDC is login. But that phrasing hides one nuance worth holding onto — OAuth does authenticate the user. When you sign in, the authorization server checks your password and MFA just the same as it would for a plain login. What OAuth lacks is any standard way to tell the client the result.

With Google login, OAuth gives an access_token, OIDC also gives an id_token

Think of signing in to GitHub with Google. With plain OAuth — GitHub asking to read your Google Drive, say — Google hands GitHub an access_token: GitHub can now call Google’s APIs on your behalf, but the token has no name on it, so it knows it was authorised yet has no standard way to tell who you are. With OIDC — the “Sign in with Google” button — Google hands GitHub that same access_token and an id_token carrying sub, your Google user id, so GitHub finally learns exactly who signed in. Google checks your password and 2FA in both cases; the only difference is whether the outcome of that check is written into a token the app can read.

OIDC is not a replacement for OAuth — it is OAuth with a name tag. Strip the openid scope and the id_token and you are back to plain OAuth; OIDC cannot run without the OAuth flow underneath it. So the question is never “OAuth or OIDC”, it is “do I also need the identity layer?”:

Situation OAuth OIDC
Act on behalf of a user (call their API) yes — access_token optional
The app needs to know who logged in no yes — id_token
Server-to-server, no user present yes — client_credentials no use
Full “log in and call the API” yes (the base layer) yes (base + id_token)

The reason both still matter: the resource server validates the access_token, not the id_token. The id_token is addressed to the client — it tells your app who the user is, but the API does not accept it as proof of permission. So even in a pure OIDC login, every API call is still authorised by the OAuth access_token underneath.

Three Tokens, Three Jobs

Access tokens are deliberately short-lived, which is what makes the refresh token necessary rather than a convenience.

The consequence: do not authorise API calls with an id_token. It is addressed to the client, not to your resource server, and its aud claim will not contain your API. Authentication happened; authorisation did not.

Pick the Grant First

Grant Use it for Do not
Authorization code + PKCE Anything with a user, including web apps Skip PKCE because you have a secret
Client credentials Server-to-server, no user present Reuse one credential for every consumer
Device authorization TVs, CLIs, headless hardware Leave the user code unthrottled
Implicit Nothing — removed in OAuth 2.1 Tokens leaked via referrer, history, extensions
Resource owner password Legacy migration only — removed in 2.1 Ship it in a new system

The Flow and Its Controls

Authorization code flow with PKCE

At /authorize: redirect_uri matched exactly — no prefix matching, no wildcards, and no allowlisted path that happens to be an open redirector. state generated randomly, bound to the user’s session, verified on the way back; it defeats authorisation CSRF and code injection. With OIDC, nonce additionally, also session-bound, to stop id_token replay.

The code: one-time, invalidated the moment it is used, and short-lived — OAuth 2.1 caps it at ten minutes, thirty to sixty seconds is normal. Reuse is an attack signal, not a retry.

The tokens: access tokens travel in the Authorization header — never in a query string, which ends up in access logs and Referer. Refresh tokens go only to confidential clients, are stored encrypted server-side, and rotate on every use with reuse detection: a refresh token presented twice means it leaked, and the entire token family gets revoked.

On the client: SPA and native apps are public clients — they cannot hold a secret, so PKCE replaces it. Always launch the system browser (ASWebAuthenticationSession, Chrome Custom Tabs) and never an embedded WebView, which lets the app itself read both the code and the user’s password. In browsers, prefer the BFF pattern: keep tokens server-side and give the browser an HttpOnly + Secure + SameSite session cookie. Tokens in localStorage are one XSS away from gone. On mobile, a claimed HTTPS redirect (RFC 8252 section 7), App Links or Universal Links beat a custom scheme, because the OS verifies domain ownership before routing.

PKCE: Hash the Secret, Publish Only the Hash

PKCE — Proof Key for Code Exchange, RFC 7636 — is the control that makes the authorization code flow safe for public clients: native apps and SPAs that ship their client_id to every device and cannot keep a client_secret. Because the token endpoint cannot authenticate such a client, the only thing standing between a stolen code and a fresh token is proof that the code’s original sender is the one redeeming it. PKCE supplies that proof.

Authorization code flow with PKCE

The idea is small — hide a secret before asking, present it when collecting:

code_verifier  = BASE64URL(32 random bytes)      # 43-128 chars, [A-Za-z0-9-._~]
code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
  1. The client generates a fresh high-entropy code_verifier before redirecting and keeps it in memory.
  2. Only the hash goes to /authorize, as code_challenge with code_challenge_method=S256.
  3. The authorization server stores that challenge bound to the code record — not in a cookie, not in a session.
  4. At /token the client sends the plaintext code_verifier; the server re-hashes it and compares.

An attacker who intercepts the code still cannot redeem it: the challenge that travelled in the URL is a one-way hash, and the verifier that would satisfy it never crossed the redirect. PKCE does not stop interception — it makes the stolen code worthless.

Two rules that get broken:

What the Resource Server Must Check

Receiving a JWT is not the same as trusting it. Every one of these is a real bypass when missed:

Check What goes wrong without it
alg pinned server-side none, or RS256/HS256 confusion using the public key as an HMAC secret
Signature verified Anyone can mint a token
iss A token from a different tenant’s IdP is accepted
aud contains this service A token minted for API-A is replayed against API-B
exp / nbf / iat Tokens valid forever; allow a small clock skew
kid resolved from a fixed whitelist jku / x5u headers point the verifier at the attacker’s key set
jti on critical operations Payment or password-change requests replay
A revocation path No way to kick a session before expiry

Two things that are not on the list and belong in your head anyway: the payload is base64, not encryption — anything confidential needs JWE — and a valid token does not authorise the request. After all eight checks you know who is calling. Whether they may read this row is still layer three from the API defence layers, and OAuth does not do it for you.

tags: web-security - oauth - oidc - pkce - jwt - authentication