Community Article

OAuth2 and OpenID Connect: The Flows That Actually Matter

OAuth 2.0 is delegation. OpenID Connect is identity. Most bugs come from confusing the two. The four flows that matter, the eight checks I review, and what to never roll yourself.

OAuth2 and OpenID Connect: The Flows That Actually Matter

OAuth 2.0 is delegation. OpenID Connect is identity. Most bugs come from confusing the two. The four flows that matter, the eight checks I review, and what to never roll yourself.

oauth2
authentication
authorization
security
jwt
arjunpatel

By @arjunpatel

January 2, 2026

·

Updated May 20, 2026

579 views

6

4.2 (9)

If I had to name the single most-misused phrase in our industry, "implementing OAuth" would be in the top three. Half the time the team really means "implementing login". The other half they mean "implementing authorization for a third-party app". Those are different problems, and OAuth 2.0 only solves one of them. OpenID Connect, the spec layered on top of it, solves the other. Treating them as one thing is how you end up rolling your own identity layer on a protocol that was never designed for it, and shipping subtle vulnerabilities while you do.

I have integrated OAuth flows in production three times across companies of very different sizes, and I have read the relevant RFCs more times than I would like. My stance: OAuth 2.0 is a delegation framework, OpenID Connect is the identity layer on top, and ninety percent of the bugs I see in real auth code come from confusing the two. If you take that distinction seriously, the rest of the spec is mostly mechanical.

What each spec actually does

OAuth 2.0 (RFC 6749, with later RFCs piling on) is an authorization protocol. Its job is to let a user grant a third-party application limited, scoped access to a resource the user controls, without sharing their password. The classic example: a Strava-like app gets permission to read your Google Fit data. You log in to Google, click "Allow", and Strava receives a token it can present to Google's APIs to fetch your steps. Strava never sees your Google password. Google decides what scopes Strava is allowed to request, and the token is bounded by those scopes plus an expiry.

That is delegation. The spec does not actually say anything definitive about who the user is. The token says "the holder of this token is allowed to call these APIs on behalf of some user". It does not say "this user's email is [email protected]". For a long time people pretended it did, and that gap is exactly what OpenID Connect was created to close.

OpenID Connect (OIDC, finalized 2014) is an authentication layer built on top of OAuth 2.0. It adds two specific pieces: a standardized id_token (a JWT containing claims about the user, signed by the identity provider), and a standardized userinfo endpoint. With OIDC, after the OAuth dance you receive both an access token (for calling APIs) and an id token (for proving who the user is). The id token is the thing your application validates and uses to log the user in.

A way I keep these straight in my head: OAuth answers "is the holder of this token allowed to do X?". OIDC answers "who is the user?". If you only need login, you need OIDC. If you only need API delegation, OAuth is enough. Most modern apps that use "Sign in with Google" / "Sign in with Apple" actually use OIDC, even though every blog post calls it OAuth.

Tokens: access, id, refresh

Three token types show up across these flows, and confusing them causes real bugs.

Token taxonomy
  access_token   -> presented to APIs to authorize requests; opaque or JWT, short-lived
  id_token       -> proves the user's identity to the client app; ALWAYS a signed JWT, short-lived
  refresh_token  -> swapped for new access tokens; long-lived, server-side only when possible

The access token is what API endpoints validate. It might be a random opaque string the API has to look up against a token store, or it might be a self-contained JWT the API can verify with the issuer's public key. Both shapes are valid OAuth.

The id token is unique to OIDC. It is always a JWT, signed by the identity provider, and contains user claims (sub, email, email_verified, name, iat, exp, aud, iss, nonce). The client validates the signature, checks the issuer and audience, checks the expiry, and matches the nonce against the one it originally sent. This token is for the client to read; it is not meant to be sent to APIs as authorization.

The refresh token is your "long-lived sessions for browsers and mobile apps without making them re-login every hour" mechanism. Refresh tokens are sensitive. They should be stored server-side (in an httpOnly cookie, an encrypted session store, or a mobile keychain) and never exposed to JavaScript. Single-page apps in 2026 should not be storing refresh tokens in localStorage; that pattern was the default in 2018 and has aged badly.

The four flows that actually matter

The OAuth spec defines several flows. Two are deprecated. Two more are mostly historical. The ones I see in production are these four.

1. Authorization Code with PKCE is the modern default for almost everything: web apps, single-page apps, mobile apps, desktop apps. The flow:

Authorization Code with PKCE
  1. Client generates code_verifier (random) and code_challenge (SHA-256 of verifier)
  2. Client redirects user to /authorize with code_challenge
  3. User logs in at IdP, consents, IdP redirects back with code
  4. Client POSTs /token with code + code_verifier
  5. IdP verifies SHA-256(code_verifier) == code_challenge, returns tokens

PKCE (Proof Key for Code Exchange, pronounced "pixie") prevents code interception. Even if an attacker grabs the authorization code mid-redirect, they cannot exchange it for tokens without the code_verifier, which only the original client has. Use PKCE on every Authorization Code flow, including server-side ones. There is no good reason not to in 2026.

2. Client Credentials is for service-to-service auth. No user is involved. The client (a backend service) authenticates with its own credentials and receives an access token. This is the right flow for cron jobs, microservice-to-microservice calls, and CI scripts that need to call your own APIs.

POST /token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=svc-billing&client_secret=...&scope=invoices.read

3. Refresh Token is how you get a new access token without re-prompting the user. The client POSTs the refresh token; the IdP returns a fresh access token (and usually rotates the refresh token too).

4. Device Authorization Grant (RFC 8628) is the "enter this code on your phone" flow you see on smart TVs and CLI tools. The device displays a code, the user opens a browser on a real device, enters the code, logs in, and the device polls until it gets tokens. I use this for CLI auth flows in command-line tools we ship.

The flows that are deprecated or unsafe and that you should not be writing new code for: the Implicit flow (returns access tokens in the URL fragment, no PKCE) and the Resource Owner Password Credentials flow (the user gives the third-party app their password directly, defeating the entire delegation point). If a tutorial recommends either of these in 2026, the tutorial is out of date.

A worked Authorization Code + PKCE flow

Let me walk through a real-shaped flow, because the wire-level detail is where the misconceptions live.

The client (a single-page app) starts the flow:

const codeVerifier = base64URLEncode(crypto.getRandomValues(new Uint8Array(32)));
const codeChallenge = base64URLEncode(await sha256(codeVerifier));
const state = base64URLEncode(crypto.getRandomValues(new Uint8Array(16)));
const nonce = base64URLEncode(crypto.getRandomValues(new Uint8Array(16)));

sessionStorage.setItem('oauth_verifier', codeVerifier);
sessionStorage.setItem('oauth_state', state);
sessionStorage.setItem('oauth_nonce', nonce);

window.location = `https://idp.example.com/authorize?` + new URLSearchParams({
    response_type: 'code',
    client_id: 'spa-client-id',
    redirect_uri: 'https://app.example.com/callback',
    scope: 'openid email profile',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    state,
    nonce,
}).toString();

The state parameter is your CSRF defense for the redirect: it must be unguessable, stored client-side, and verified on return. The nonce is your replay defense for the id token: it appears inside the id token and you check that it matches what you sent. The scope includes openid because we want OIDC behavior (an id token in addition to an access token).

After the user logs in and consents, the IdP redirects back:

https://app.example.com/callback?code=abc123&state=xyz789

The client validates state, then POSTs to the token endpoint:

POST /token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=abc123
&redirect_uri=https://app.example.com/callback
&client_id=spa-client-id
&code_verifier=<the original verifier>

If the IdP confirms SHA-256(code_verifier) == code_challenge from the authorize request, it returns:

{
    "access_token": "eyJhbG...",
    "id_token": "eyJhbG...",
    "token_type": "Bearer",
    "expires_in": 3600,
    "refresh_token": "...",
    "scope": "openid email profile"
}

The client now does id token validation, which is the step that I see skipped or done wrong constantly:

const idToken = jwt.verify(response.id_token, idpPublicKey, { algorithms: ['RS256'] });
if (idToken.iss !== 'https://idp.example.com') throw new Error('bad issuer');
if (idToken.aud !== 'spa-client-id') throw new Error('bad audience');
if (idToken.exp < Date.now() / 1000) throw new Error('expired');
if (idToken.nonce !== sessionStorage.getItem('oauth_nonce')) throw new Error('bad nonce');

Every one of those checks matters. Skipping the audience check lets an attacker reuse a token issued for a different client. Skipping the nonce check opens a replay attack. Skipping the issuer check lets a malicious IdP impersonate yours. The signature verification is necessary but not sufficient; the claims still have to be checked.

OAuth 2.0 vs OAuth 2.1

OAuth 2.1 is a consolidated draft (still moving through standardization at the time I am writing this) that bakes in everything we have learned over the last decade. The substantive changes:

  • PKCE is required for all Authorization Code flows, not optional.
  • The Implicit flow is removed.
  • The Resource Owner Password Credentials flow is removed.
  • Bearer tokens in URL query strings are forbidden.
  • Refresh tokens for public clients must be sender-constrained or rotated on each use.

If you read OAuth 2.0 and OAuth 2.1 side by side, you will notice the 2.1 draft is mostly the security-best-practices RFC distilled into one document. Following it gives you a defensible baseline.

The three flows I shipped last year

Three real shapes from work I did in the last year, sanitized.

Customer-facing web app, "Sign in with Google" / "Sign in with Microsoft": Authorization Code + PKCE, server-side token exchange (no refresh token in the browser), session cookie issued by my backend. The browser never sees the refresh token; the backend holds it in an encrypted session store. When the access token expires, the backend silently refreshes it.

B2B SaaS API consumed by partner backends: Client Credentials grant scoped per partner, with our own authorization server (Keycloak in this case). Each partner has a client_id and client_secret, requests are scoped to specific resources, tokens are short-lived (15 minutes), and we log every issuance for audit.

Internal CLI for ops: Device Authorization Grant. The CLI prints a URL and a code, the engineer logs in on their laptop browser, the CLI polls until it gets tokens, and stores the refresh token in the OS keychain. I would not ship a flow that requires the user to paste a copied access token into a terminal; the device flow is much more pleasant.

The vulnerabilities I review for

Whenever I am reviewing OAuth code, I run through this list mentally.

  1. Is state validated on the callback? Without it, you have a CSRF on the auth callback.
  2. Is nonce validated inside the id token? Without it, replay of a captured id token works.
  3. Is the id token signature verified? With the IdP's public key, fetched from the JWKS endpoint? Cached but rotated?
  4. Is aud checked? A token issued for client A should never be accepted by client B's session middleware.
  5. Is iss checked? Pinning the issuer to your IdP's URL stops issuer-confusion attacks.
  6. Is the redirect URI registered exactly? Open redirect bugs at this layer give attackers a free token-theft primitive. Registered URIs must match exactly, including paths.
  7. Are tokens stored where they belong? Refresh tokens never in localStorage. Access tokens for SPAs in memory. Server-issued session cookies as Secure; HttpOnly; SameSite=Lax (or Strict).
  8. Are scopes minimal? Asking for email is fine. Asking for https://www.googleapis.com/auth/gmail.modify because "we might need it later" is how you end up on an audit ban.

I have shipped bugs on items 1, 2, 4, and 7 across my career. Each one was caught by a security review or a penetration test, not by me. The list above is the one I now run before merging anything that touches auth.

What confused me longest, and the fix

The single thing that confused me longest is that the access token is opaque to the client. As a client, you do not parse the access token, you do not look inside it, you do not check its claims. You just present it to the API. The API parses or looks it up. If you are inspecting access token contents in your client app to make decisions, you are doing OAuth wrong (and you have probably been treating the access token like an id token, which is a real bug).

The id token is yours to read. The access token is the API's to read. If you find your client code calling jwt.decode(access_token) to extract a user id, replace it with the id token claim or a /me API call.

The second source of confusion is JWKS, the JSON Web Key Set endpoint that the IdP publishes. Your library hits https://idp.example.com/.well-known/jwks.json, downloads the public keys, caches them, and uses them to verify token signatures. The trap is that IdPs rotate keys. If your cache is set to "forever", the day they rotate is the day every login starts failing. If your cache is set to "never", you hammer the JWKS endpoint on every request. The right shape is "cache for an hour, refresh on signature failure with a known kid". Most libraries do this for you, but I have seen handrolled implementations that did not, and the resulting outage was the kind that wakes you up.

A related issue is the kid (key id) header in the JWT. The JWT header tells you which key to use; you look up that kid in the JWKS. If the kid is missing from your cache, refresh JWKS once and try again. If it is still missing, fail the request. Some early implementations would re-fetch on every miss, which an attacker can use to drive load against your auth path by sending tokens with garbage kid values. Rate-limit the refresh.

Refresh token rotation and the silent revocation problem

Refresh tokens are sensitive. The current best practice (and the OAuth 2.1 default) is rotation on every use: when the client exchanges a refresh token for a new access token, the IdP also issues a new refresh token and invalidates the old one. If the old one is presented again, that is evidence of theft, and the IdP revokes the entire refresh token family.

Refresh token rotation (with reuse detection)
  Client uses RT_1 -> IdP issues access token + RT_2, marks RT_1 as used
  Attacker also has stolen RT_1, replays it -> IdP detects reuse, revokes RT_2 and the family
  Both legitimate client and attacker are kicked out, user must re-login

The "kick everyone out" outcome is preferable to "silent token theft for the next 30 days". The legitimate user logs in again; the attacker is locked out. Without rotation, there is no signal that the refresh token was stolen until something obvious goes wrong, which might be never.

A subtle gotcha: rotation interacts badly with concurrent requests. If the client makes two near-simultaneous API calls and both hit a 401 around the same access-token expiry, both will try to refresh, both will present the same refresh token, the second will be rejected as "reused", and the user gets logged out. The fix is to serialize refresh attempts in the client (a single in-flight refresh promise that all callers await) so the rotation only happens once per real expiry.

Don't roll your own

If there is one piece of advice I would tape to the monitor of any junior engineer who has been told to "implement OAuth", it is this. Use a real library. Use a real identity provider. The libraries (openid-client for Node, Authlib for Python, go-oidc for Go, the Microsoft Identity client libraries, the Auth0 / Clerk / Stytch / Ory / Keycloak SDKs) handle the dozens of subtle checks I listed above for you. The IdPs (Auth0, Okta, Google, Microsoft, Keycloak, Ory Hydra) handle the long tail of edge cases (token rotation, key rotation, JWKS caching, scope discovery) so you do not have to.

The auth flow is exactly the kind of thing where rolling your own gets you a working demo and a security incident two years later. The libraries exist. They are usually free. Use them, and reserve your engineering time for the parts of your product that are actually about your product. That is the real lesson under the spec details: OAuth and OIDC are well understood, the hard work has been done, and the only way to lose is to reimplement it from scratch.

Back to Articles