Back to Blog
Security18 min readJun 2026

OAuth 2.0 and OpenID Connect: A Deep Dive

OAuth2 grants access without sharing passwords; OIDC adds identity on top. Here is how the Authorization Code flow with PKCE actually works, what the three token types do, and where to store them safely.

OAuth2OIDCJWTAuthentication
SB

Sri Balaji

Founder

On this page

Why this trips everyone up

Who this is for

You can build a login form and check a password, but the moment a third-party 'Sign in with Google' button appears you freeze. You have heard 'OAuth', 'OIDC', 'JWT', 'PKCE', and 'bearer token' used as if they mean the same thing. This article untangles them from zero, with a picture and copy-pasteable code. No prior OAuth experience assumed.

The single biggest source of confusion is that OAuth 2.0 and OpenID Connect solve two different problems that happen to live in the same flow. OAuth2 answers *authorization*: 'is this app allowed to call this API on the user's behalf?' OpenID Connect (OIDC) answers *authentication*: 'who is this user, and can I prove it?' OIDC is a thin identity layer built on top of OAuth2, same redirects, same endpoints, one extra token.

If the authentication-versus-authorization distinction is fuzzy, read authentication vs authorization first, the rest of this article leans on it constantly.

A mental model: the hotel keycard

OAuth2 is the protocol for handing out limited, revocable keys to your stuff, without ever handing over your master password.

Think about checking into a hotel. You prove who you are once at the front desk (you authenticate). The desk does not give you the master key to the whole building. It encodes a keycard that opens only your room, only until checkout. You can hand that card to a friend to grab your bag, they get into the room without ever knowing your identity or your credit card. That is delegated, scoped, time-limited access. That is OAuth2.

You, the guest who owns the bookingResource owner (the user)
The valet you hand the keycard toClient (the app)
The front desk that verifies you and prints cardsAuthorization server
Your room door's lockResource server (the API)
The keycard: opens one room, expires at checkoutAccess token: scoped, short-lived
A 'valet key' that drives but won't open the trunkScopes limiting what the token can do
The hotel maps almost one-to-one onto the OAuth2 roles and tokens.

The valet-key idea is the heart of it: a valet key starts the car and drives it but cannot open the glovebox or trunk. A good access token is the same, it grants exactly the permissions the app asked for and the user agreed to, and nothing more.

The four roles

Every OAuth2 flow is a conversation between exactly four parties. Naming them precisely is half the battle, because the specs use these exact words.

  • Resource owner, the human who owns the data. Usually just 'the user'. They are the only one who can grant consent.
  • Client, the application that wants access. A SPA, a mobile app, or a backend. The client is *never* trusted with the user's password.
  • Authorization server, the identity provider (Google, Auth0, Okta, Keycloak, Microsoft Entra). It authenticates the user, shows the consent screen, and issues tokens.
  • Resource server, the API holding the protected data. It accepts an access token, validates it, and serves (or refuses) the request.

Auth server and resource server can be the same machine

In small systems one service both issues tokens and serves the API. The roles are still logically distinct, keep them separate in your head, because at scale they split apart (a central identity provider, many APIs).

The picture: Authorization Code flow with PKCE

The Authorization Code flow with PKCE (pronounced 'pixy', Proof Key for Code Exchange) is the modern default for every client type, SPAs, mobile, and traditional web apps alike. The key idea: the browser never receives tokens directly. It receives a short-lived authorization code, then a separate back-channel call swaps that code for tokens, proving it is the same client that started the flow.

1. redirect w/ code_challenge2. back with code3. code + code_verifier4. access + ID + refresh5. Bearer access token
Client app

SPA / browser

Authorization server

/authorize + login + consent

Redirect / callback

?code=... &state=...

Token endpoint

/token (back channel)

Resource server

API + token validation

Authorization Code + PKCE: the browser gets a code at the redirect; tokens are fetched in a separate exchange.

  1. 1

    Generate the PKCE pair

    The client invents a random secret, the code_verifier, hashes it (SHA-256) into a code_challenge, and stashes the verifier locally. The challenge is sent now; the verifier is revealed only at step 4.

  2. 2

    Redirect to /authorize

    The browser is sent to the authorization server with client_id, redirect_uri, scope, a random state (anti-CSRF), and the code_challenge. The user logs in and approves the consent screen here, on the auth server's domain, never the client's.

  3. 3

    Receive the authorization code

    The auth server redirects back to the registered redirect_uri with a short-lived ?code=... and the same state. The client verifies state matches what it sent. The code alone is useless to an attacker.

  4. 4

    Exchange the code for tokens

    The client POSTs the code plus the original code_verifier to the /token endpoint. The server re-hashes the verifier and checks it equals the earlier challenge. Match means it is the same client, so it returns the access, ID, and refresh tokens.

  5. 5

    Call the API

    The client sends the access token as an Authorization: Bearer header on every API request. The resource server validates it and serves the data.

Why does PKCE matter? Without it, an attacker who intercepts the authorization code (a malicious app registered for the same redirect URI on mobile, or a leaky redirect) could redeem it for tokens. With PKCE, the code is worthless without the matching code_verifier, which never left the legitimate client. That is why PKCE is now mandatory even for confidential web apps in OAuth 2.1.

Three tokens, three jobs

A successful flow can hand back three tokens, and mixing them up is the second-biggest source of bugs. They are not interchangeable.

Access tokenRefresh tokenID token
PurposeCall APIs (authorization)Get new access tokensProve who the user is (authentication)
Issued byOAuth2OAuth2OIDC
AudienceThe resource server (API)The authorization serverThe client app
LifetimeShort (5-60 min)Long (days-weeks), revocableShort, single use at login
Sent to the API?Yes, every requestNeverNever
FormatOften opaque or JWTOpaque stringAlways a JWT
The three tokens differ in audience, lifetime, and what you are allowed to do with them.

Never send an ID token to your API

The ID token is meant for the client, to learn who logged in. Its audience (aud) is the client_id, not the API. APIs must reject it and accept only access tokens. Using an ID token as an API credential is a classic, dangerous mistake.

Grant types: which to use, which are dead

OAuth2 historically offered several 'grant types' (ways to obtain tokens). Most are now actively discouraged. The short version: use Authorization Code + PKCE for anything with a user, and Client Credentials for machine-to-machine. Avoid the rest.

Grant typeUse it forVerdict
Authorization Code + PKCESPAs, mobile, web apps with usersUse it, the modern default
Client CredentialsService-to-service, no user involvedUse it, for machine clients
Refresh TokenRenewing access without re-loginUse it, alongside Auth Code
Implicit(was for SPAs)Dead, leaks tokens in the URL
Resource Owner Password(app collects username/password)Dead, defeats the point of OAuth
Grant type guidance for new systems in 2026.

Why implicit is dead: it returned the access token directly in the redirect URL fragment, where it landed in browser history, server logs, and referrer headers, and it had no way to deliver a refresh token safely. PKCE made it obsolete. Why password grant is dead: it requires the user to type their password *into the client app*, which is exactly the trust violation OAuth was invented to avoid. If the app sees the password, there is no point delegating.

Inside a JWT

ID tokens (and often access tokens) are JWTs, JSON Web Tokens. A JWT is three Base64URL-encoded parts joined by dots: header.payload.signature. It is *encoded, not encrypted*, anyone can read the contents. The signature is what makes it trustworthy: it proves the token was issued by the auth server and has not been tampered with.

json
// Part 1 - HEADER (which algorithm + which key signed this)
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "a1b2c3d4"        // key id, used to find the right public key
}

// Part 2 - PAYLOAD (the claims)
{
  "iss": "https://auth.example.com",  // issuer
  "aud": "my-client-id",              // audience (who this is for)
  "sub": "user_8f3k2",                // subject (the user id)
  "exp": 1717689600,                    // expiry (unix seconds)
  "iat": 1717686000,                    // issued at
  "email": "ada@example.com",
  "scope": "openid profile read:invoices"
}

// Part 3 - SIGNATURE (not JSON; a cryptographic signature over parts 1+2)
//   RSASHA256( base64url(header) + "." + base64url(payload), privateKey )

Validation is non-negotiable, and 'it decodes fine' is not validation. You must verify, in order: the signature (using the auth server's public key, looked up by kid from its JWKS endpoint), the issuer (iss), the audience (aud), and the expiry (exp). Skip any one and you have a hole.

verifyToken.ts
typescript
import { createRemoteJWKSet, jwtVerify } from 'jose';

// 1. Point at the auth server's published public keys (cached & rotated for you)
const JWKS = createRemoteJWKSet(
  new URL('https://auth.example.com/.well-known/jwks.json'),
);

export async function verifyAccessToken(token: string) {
  // 2. jwtVerify checks the signature against JWKS, AND iss/aud/exp in one call
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://auth.example.com',  // must match iss
    audience: 'my-api',                  // must match aud (the API, not the client)
  });

  // 3. exp is enforced automatically; reaching here means the token is valid.
  return payload; // { sub, scope, ... } - now safe to authorize against
}

Two signature traps

Never accept alg: 'none' (an unsigned token), and never trust the alg from the token's own header to pick your verification path - an attacker controls it. Pin the expected algorithm (RS256) server-side. Libraries like jose handle this when you configure them; hand-rolled verification often does not.

Token storage in SPAs: the BFF pattern

Now the part that breaks real apps. Where does a single-page app keep its tokens? Every option in the browser has a sharp edge.

Do not store tokens in localStorage

localStorage is readable by any JavaScript on the page. One XSS bug - a compromised npm dependency, a reflected script - and the attacker reads your access AND refresh tokens straight out of storage and walks off with the user's session. There is no httpOnly protection. This is the most common token-handling mistake in SPAs.

The modern answer is the Backend-For-Frontend (BFF) pattern. Instead of letting the browser hold tokens at all, you run a thin server beside your SPA. The BFF performs the Authorization Code + PKCE flow, holds the tokens server-side, and gives the browser only an httpOnly, Secure, SameSite session cookie. The browser can never read that cookie from JavaScript, so XSS cannot exfiltrate tokens. The BFF attaches the real access token to API calls on the user's behalf.

cookie-auth'd requestPKCE + token exchangeBearer access token
SPA

httpOnly cookie only

BFF

holds tokens, runs PKCE

Auth server

issues tokens

API

resource server

BFF pattern: tokens live on the server; the browser only ever holds an httpOnly cookie.

If a BFF is genuinely out of reach, the least-bad fallback is keeping the access token in memory only (a JavaScript variable, lost on refresh) and using a refresh token in an httpOnly cookie with rotation. But for anything handling real user data, the BFF is the recommended pattern in 2026.

Common mistakes that cost hours

  1. Skipping `state` validation. Without the anti-CSRF state check on the callback, an attacker can fixate a login. Always generate it, store it, and compare it.
  2. Decoding a JWT and calling it 'verified'. Decoding reads the claims; it proves nothing. Always verify signature + iss + aud + exp.
  3. Sending the ID token to your API. The API must accept access tokens only. Check the aud claim and reject mismatches.
  4. Storing tokens in localStorage. Use httpOnly cookies via a BFF, or in-memory at minimum.
  5. Trusting the token's own `alg` header. Pin the algorithm server-side and reject none.
  6. Long-lived access tokens 'to avoid re-login'. Keep access tokens short; use refresh tokens for longevity so you can actually revoke a session.
  7. Treating scopes as roles. Scopes describe what the token may do; do your role/permission checks server-side against the user, not by trusting a scope as authority.

Takeaways

The whole article in nine lines

  • OAuth2 = authorization (what an app may do); OIDC = authentication (who the user is), layered on top.
  • Four roles: resource owner (user), client (app), authorization server (IdP), resource server (API).
  • Authorization Code + PKCE is the one flow to use for any user-facing client. Implicit and password grants are dead.
  • Access token calls the API; refresh token renews it; ID token (a JWT, for the client) proves identity.
  • A JWT is header.payload.signature - encoded, not encrypted. Anyone can read it.
  • Always verify signature (via JWKS), iss, aud, and exp. Decoding is not verifying.
  • Pin the signing algorithm; reject alg: none. Never send an ID token to your API.
  • Scopes are valet-key limits; the consent screen is where the user grants them. Ask for least privilege.
  • Never put tokens in localStorage. Use the BFF pattern: tokens server-side, httpOnly cookie to the browser.

Where to go next

OAuth2 and OIDC sit in the middle of a larger security picture. To go deeper, follow these threads:

Once these click, you will read any 'Sign in with...' integration guide and recognize every step, because you will know which role, which token, and which flow it is talking about.

Want to go deeper?

This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.