"Sign in with Google" and "Connect your GitHub account" both run on OAuth 2.0, and most login implementations add OpenID Connect (OIDC) on top. The protocols are well designed but have many optional parts, and the insecure combinations are easy to pick by accident.
In this lesson you will learn the roles and token types, the Authorization Code flow with PKCE, how to validate the callback and ID token, and the classic redirect and token-confusion mistakes.
OAuth 2.0 is an authorization framework: a user (resource owner) lets your application (client) call an API (resource server) on their behalf, using an access token issued by an authorization server. OIDC adds authentication: the authorization server also returns an ID token, a JWT that states who the user is.
| Token | Audience | Purpose | Format | |---|---|---|---| | Access token | The API (resource server) | Authorise API calls | Opaque or JWT; the client does not inspect it | | ID token | Your application (client) | Prove who logged in | JWT; validate it, never send it to APIs | | Refresh token | The authorization server | Obtain new access tokens | Opaque; stored server-side only |
Confusing these is a common bug: an ID token is not an API credential, and an access token is not proof of identity.
The only flow you should use for user login is Authorization Code with PKCE (Proof Key for Code Exchange). The implicit flow, which returned tokens in the URL fragment, is deprecated because tokens leaked through browser history and referrers.
code_verifier, hashes it into a code_challenge, creates a random state, and redirects the user to the authorization server (the sample code).redirect_uri with a short-lived code and the same state.code_verifier at the token endpoint; the server hashes the verifier and compares it with the challenge, so a stolen code is useless alone.const res = await fetch("https://accounts.example.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code", code, code_verifier: req.session.oauth.verifier,
redirect_uri: "https://app.example.com/auth/callback",
client_id: process.env.OAUTH_CLIENT_ID, client_secret: process.env.OAUTH_CLIENT_SECRET
})
});
const { access_token, id_token } = await res.json();Public clients (mobile and single-page apps) have no client_secret; PKCE protects them.
The callback handler is where most implementation bugs live. Before trusting anything:
if (req.query.state !== req.session.oauth.state) throw new Error("state mismatch"); // login CSRFconst { jwtVerify, createRemoteJWKSet } = require("jose");
const JWKS = createRemoteJWKSet(new URL("https://accounts.example.com/.well-known/jwks.json"));
const { payload } = await jwtVerify(id_token, JWKS, {
issuer: "https://accounts.example.com",
audience: process.env.OAUTH_CLIENT_ID
});
if (payload.nonce !== req.session.oauth.nonce) throw new Error("nonce mismatch"); // replayed token
if (!payload.email_verified) throw new Error("unverified email");
req.session.regenerate(() => { req.session.userId = findOrCreateUser(payload.sub).id; });Link accounts by the stable sub claim, never by email alone, and check email_verified: an attacker can register a provider account with an unverified email that matches a victim on your site.
https://app.example.com/anything, an open redirect on your site turns into code theft. Register exact URIs.state. An attacker can complete a login with their own code in the victim's browser, logging the victim into the attacker's account (login CSRF).What does PKCE protect against?
state, then validate the ID token's signature, iss, aud, exp and nonce before creating a session.sub and require email_verified; never treat an ID token as an API credential.Next lesson: Software and Data Integrity Failures — trusting updates, pipelines and serialized data without verifying them.