OAuth 2.0 Is Authorization. OIDC Is Identity. They Are Not the Same Thing.

OAuth 2.0 answers “can this client access this resource on behalf of this user?” OpenID Connect adds “and who is the user, provably?” on top. Confusing the two is the source of nearly every “we built our own auth and it's fine” bug.

🤔 Sound familiar?
  • Your “Login with Google” flow uses the OAuth access token to identify the user
  • SPAs use the implicit flow because that's what the 2015 tutorial said
  • You don't know the difference between an ID token and an access token
  • Your mobile app stores the client secret in the bundle

The 2026 baseline: Authorization Code with PKCE for every client (SPA, mobile, server), ID tokens for identity, access tokens for API calls, and refresh-token rotation with reuse detection.

The Core Roles


flowchart LR
    RO[Resource Owner<br/>(user)] -->|authorizes| AS[Authorization Server<br/>(Auth0, Entra, Cognito)]
    C[Client<br/>(your app)] -->|requests tokens| AS
    AS -->|issues access_token + id_token| C
    C -->|Bearer access_token| RS[Resource Server<br/>(your API)]
    RS -->|verify via JWKS| AS
      

The Grant Types You Actually Use in 2026

Authorization Code + PKCE

The default for everything with a UI. The user is redirected to the auth server; on return your client exchanges a one-time code for tokens. PKCE (Proof Key for Code Exchange) binds the redirect to the original request so an intercepted code can't be redeemed by an attacker.

// PKCE in 3 steps, generated client-side
import { randomBytes, createHash } from 'node:crypto';

// 1. Create a verifier (high entropy, kept secret in client memory)
const verifier = randomBytes(32).toString('base64url');

// 2. Derive the challenge (sent in the /authorize redirect)
const challenge = createHash('sha256').update(verifier).digest('base64url');

// 3. Build the authorize URL
const url = new URL('https://auth.example/authorize');
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('redirect_uri', REDIRECT_URI);
url.searchParams.set('scope', 'openid profile email api:read');
url.searchParams.set('state', randomBytes(16).toString('base64url')); // CSRF
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');

// 4. On callback, exchange code + verifier for tokens
await fetch('https://auth.example/token', {
  method: 'POST',
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code, redirect_uri: REDIRECT_URI,
    client_id: CLIENT_ID,
    code_verifier: verifier,
  }),
});

Client Credentials

Machine-to-machine. No user, no browser — just a client ID and secret (or a private-key JWT assertion) exchanged for an access token. Use for backend services calling other services with their own identity, not a user's.

Device Authorization Grant

TVs, CLIs, IoT — devices with no easy way to render a login form. The device shows a code and URL; the user authenticates on their phone; the device polls until tokens are issued. Pattern used by az login, gh auth login, and most CLI tools.

Deprecated Grants — Don't Use These

GrantStatusReplacement
Implicit (response_type=token)Deprecated by OAuth 2.1Auth Code + PKCE
Resource Owner Password CredentialsDeprecated; only first-party legacyAuth Code + PKCE
Hybrid flow (response_type=code id_token)Niche; complexity rarely worth itPlain Auth Code + PKCE

Access Token vs ID Token — Don't Mix Them

The ID token is for your client to know who logged in. It's a JWT with claims like sub,email, name. Validate it once at login. Don't send it to APIs.

The access tokenis for your APIs. It may be a JWT or opaque. The API validates it on every request. Don't parse it on the client to display the user's name — use the userinfo endpoint or the ID token instead.

OIDC = OAuth + Identity

OIDC adds three things on top of OAuth: the openid scope, the ID token, and the /.well-known/openid-configurationdiscovery document. The discovery document tells you where everything lives (authorize, token, jwks, userinfo, end_session) — your client should fetch it once at startup rather than hardcoding endpoints.

# Every compliant OIDC provider exposes this
curl https://issuer.example/.well-known/openid-configuration

{
  "issuer": "https://issuer.example",
  "authorization_endpoint": ".../authorize",
  "token_endpoint": ".../token",
  "userinfo_endpoint": ".../userinfo",
  "jwks_uri": ".../jwks.json",
  "end_session_endpoint": ".../logout",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code","refresh_token","client_credentials"],
  "code_challenge_methods_supported": ["S256"]
}

Token Introspection vs Local Validation

JWT access tokens validate locally — pull the public key from JWKS once, cache it, verify on every request without a network call. Opaque access tokens require /introspect calls — secure (revocation is instant) but slower and a hard dependency on the auth server.

High-traffic APIs almost always use JWT access tokens with short TTLs. Banking-grade systems often use opaque tokens with introspection. Pick based on the latency vs revocation tradeoff you're willing to make.

Scopes and Audience

Scopes name capabilities (orders:read, payments:write). Audience names the API the token is intended for. Together they prevent the “confused deputy” problem — a token meant for one service being replayed against another.

// In your API middleware
const required = 'orders:read';
const requiredAud = 'urn:orders-api';

const { payload } = await jwtVerify(token, JWKS, {
  audience: requiredAud,
  issuer: ISSUER,
  algorithms: ['RS256'],
});

const scopes = String(payload.scope ?? '').split(' ');
if (!scopes.includes(required)) return res.sendStatus(403);

Logout, Done Right

Three layers must clear: the local session, the auth-server session, and any active access tokens. RP-Initiated Logout (OIDC) handles the first two via the end_session_endpoint. The third needs short-lived access tokens — there's no other realistic option.

Pitfalls

Storing client secrets in public clients

Mobile apps and SPAs are public clients — they can't keep a secret. Use PKCE, not a client secret. If your auth server requires a secret for SPAs, it's misconfigured.

Skipping state and nonce

state protects against CSRF on the redirect; noncebinds the ID token to the original request. Both must be random per request and validated on return. Most libraries do this for you — don't roll your own.

Treating the OAuth access token as proof of identity

Google's “Sign in with Google” vulnerability era was full of apps that took the access token, calleduserinfo, and trusted whatever email came back — even if the access token was minted for a different app. Use the ID token, validate aud, done.