OAuth 2.0 is a delegation protocol: the user lets an application act on their behalf without handing over a password, and the application receives an access token with a limited scope. OpenID Connect adds sign-in on top: the ID token states who signed in, and it must be validated — iss, aud, exp, nonce and the signature. The default flow today is Authorization Code with PKCE; per RFC 9700, the implicit and password grants are not used.
After this you will be able to
- explain the OAuth 2.0 roles and the steps of the Authorization Code flow with PKCE, including what
state,nonceandcode_verifierare for - tell access, refresh and ID tokens apart by audience, lifetime and how each is validated
- run a local provider on
oidc-providerand complete a full sign-in with anopenid-clientapp, up to the ID token and UserInfo - validate an ID token by hand with
jose— signature,iss,aud,exp,iat,nonce— and prove with a test that forgeries are rejected
Know beforehand
Verified with
Node.js 24 LTS (24.18)openid-client 6.8oidc-provider 9.12jose 6.2
Checked against docs: October 11, 2026
Contents
Why OAuth exists and what it is not
A classic case: a reporting service wants to read a user's calendar in another system. The old answer — ask the user for their password to that system — is bad on every count: the service gets full access, the password lives in two places, and the only way to revoke access is to change the password. OAuth 2.0 (RFC 6749) solves this with delegation: the user approves access on the data owner's side, and the application receives an access token with limited rights and a limited lifetime.
It matters what OAuth is not. It is a protocol for authorizing access to APIs, not a sign-in protocol. An access token is meant for the resource server; the application does not have to — and often cannot — tell from it who the user is. To «sign in with another account» you need OpenID Connect, a layer on top of OAuth that adds the ID token: a signed statement from the provider about which user authenticated, when, and for which application. Sessions, cookies and the JWT format itself are covered in the authentication lesson; this one is about the protocol around them.
Resource owner
- Who it is
- Whoever can grant access, usually the user
- Example
- Aigerim, who has an account with the provider
Client
- Who it is
- The application asking for access on the user's behalf
- Example
- Your website or mobile app
Authorization server
- Who it is
- Authenticates the user and issues tokens
- Example
- Google, a corporate Keycloak,
oidc-providerin the example
Resource server
- Who it is
- The API that accepts the access token
- Example
- A calendar API, the provider's UserInfo endpoint
In OpenID Connect the authorization server is called the OpenID Provider (OP) and the client is the Relying Party (RP). One more distinction drives the choice of flow: a confidential client (a backend) can keep a secret, a public client (an SPA or a mobile app) cannot, because its code is in the user's hands.
Authorization Code + PKCE step by step
Authorization Code is the default flow for anything with a user and a browser. Tokens never travel through the address bar: the browser only carries a short-lived single-use code, and the code is exchanged for tokens directly between the client's server and the provider. PKCE (Proof Key for Code Exchange, RFC 7636) binds that code to whoever started the sign-in: an intercepted code is useless without the secret «verifier».
The client prepares one-time values
code_verifieris a random 43–128 character string,code_challenge= BASE64URL(SHA-256(verifier)), plusstateandnonce. All of them are stored in the user's server-side session.Redirect to the authorization endpoint
The URL carries
client_id,redirect_uri,scope,response_type=code,code_challenge,code_challenge_method=S256,state,nonce. The verifier itself is not sent.The user signs in at the provider
Password, second factor and consent are entered on the provider's side. The client never sees the password.
Back to redirect_uri with a code
The provider redirects the browser to the registered address with
code,stateandiss(RFC 9207). The client checksstateandiss.Code exchange
The client's server sends
code,redirect_uriandcode_verifierto the token endpoint and authenticates itself. The provider hashes the verifier and compares it with the challenge from step 2.Response validation
The client receives an access token and an ID token (and a refresh token if requested), validates the ID token and creates its own session.
- 01Browser
- 02Your server (RP)
- 03Provider (OP)
- 04API / UserInfo
Why even a confidential server-side client needs PKCE. RFC 9700 — the current IETF security guidance for OAuth (BCP 240, January 2025) — makes PKCE mandatory for public clients and recommended for confidential ones: it protects against injecting a stolen code into someone else's session (authorization code injection) and closes CSRF on the callback as a side effect. Use S256 only: with plain the challenge equals the verifier and is visible to anyone who can read the authorization request. Here is all the PKCE math on the built-in node:crypto, checked against the test vector from RFC 7636, Appendix B:
// PKCE S256 by hand (RFC 7636, section 4): verifier -> SHA-256 -> base64url.
import { createHash, randomBytes } from "node:crypto";
export function createVerifier() {
return randomBytes(32).toString("base64url"); // 43 characters, allowed range is 43–128
}
export function challengeS256(verifier) {
return createHash("sha256").update(verifier, "ascii").digest("base64url");
}
// Test vector from RFC 7636, Appendix B
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
console.log(challengeS256(verifier));
console.log(challengeS256(verifier) === "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
console.log(createVerifier().length);openid-client does the same (randomPKCECodeVerifier, calculatePKCECodeChallenge)$ node pkce.mjs
E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
true
43Tokens and scope: access, refresh, ID token
A token endpoint response can contain three different tokens, and mixing them up is a common source of vulnerabilities. The key question for any token is who it is addressed to.
Access token
- Audience
- The resource server (API)
- What it carries
- Rights: scope, expiry, sometimes the resource
aud. The format is not standardized: an opaque string or a JWT (RFC 9068) - Who validates it and how
- The API — via introspection or JWT signature check. The client does not parse it
Refresh token
- Audience
- The authorization server
- What it carries
- The right to get a new access token without the user
- Who validates it and how
- Only the authorization server; the client stores it as a secret
ID token
- Audience
- The client (RP)
- What it carries
- JWT:
iss,sub,aud,exp,iat,nonce, sometimesauth_time - Who validates it and how
- The client: signature, issuer, audience, expiry, nonce
Scope is a space-separated list of requested rights: openid profile email, reports:read. RFC 9700 advises restricting access tokens to the minimum privileges needed and, where possible, to a specific resource server (the resource parameter from RFC 8707 or aud). Then a token leaked from one integration does not open the other APIs.
A refresh token lives longer and is therefore more dangerous. For public clients RFC 9700 requires either sender-constraining (mTLS, DPoP) or rotation: every refresh issues a new refresh token and invalidates the old one; if the old one is presented again, the server revokes the whole chain. In OpenID Connect a refresh token is usually issued for the offline_access scope, and the provider must obtain explicit consent for it (prompt=consent) — which is why the example below, which does not request that scope, prints has_refresh_token: false.
When there is no user at all — a nightly CRM sync, a background report worker — use the client credentials grant (RFC 6749, section 4.4): the client authenticates with its own secret and gets a token for its own rights. No ID token is issued or needed: nobody is signing in. Each such integration gets its own client with its own secret and a narrow scope.
// Server-to-server: a background worker gets its own token, no user involved.
import * as client from "openid-client";
const config = await client.discovery(
new URL(process.env.OIDC_ISSUER),
process.env.WORKER_CLIENT_ID,
process.env.WORKER_CLIENT_SECRET,
undefined,
{ execute: [client.allowInsecureRequests] }, // test IdP on http://localhost only
);
const tokens = await client.clientCredentialsGrant(config, { scope: "reports:read" });
console.log({ token_type: tokens.token_type, expires_in: tokens.expiresIn(), scope: tokens.scope, id_token: tokens.id_token });reports-worker client with a single reports:read scope$ node --env-file=.env client-credentials.mjs
{
token_type: 'bearer',
expires_in: 600,
scope: 'reports:read',
id_token: undefined
}reports:write request or a wrong secret is refused — see the tests belowOpenID Connect: discovery, ID token and UserInfo
An OpenID Connect provider publishes its settings at /.well-known/openid-configuration (OpenID Connect Discovery 1.0): endpoint URLs, the JWKS with public signing keys, supported flows and algorithms. The client only needs the issuer — it reads everything else from there. RFC 9700 recommends configuring clients this way: fewer misconfigurations, easier key rotation.
$ curl -s http://localhost:4100/.well-known/openid-configuration
{
"issuer": "http://localhost:4100",
"authorization_endpoint": "http://localhost:4100/auth",
"token_endpoint": "http://localhost:4100/token",
"userinfo_endpoint": "http://localhost:4100/me",
"jwks_uri": "http://localhost:4100/jwks",
"code_challenge_methods_supported": ["S256"],
"grant_types_supported": ["implicit", "authorization_code", "refresh_token", "client_credentials"],
"id_token_signing_alg_values_supported": ["RS256"],
"authorization_response_iss_parameter_supported": true,
…
}code_challenge_methods_supported shows PKCE support, authorization_response_iss_parameter_supported the iss response parameterA request becomes an OpenID Connect request when the scope contains openid. Then the token endpoint response includes an ID token. Per the specification, profile data for the profile, email, address and phone scopes is returned by the UserInfo endpoint in the Authorization Code flow, using the access token — which is why the name and email in the output below come from UserInfo, not from the ID token.
- The only stable user identifier is the
iss+subpair. OpenID Connect Core (section 5.7) explicitly forbids usingemail,phone_number,preferred_usernameornameas a unique key: a provider may reassign an email address to another person. - The
subin the UserInfo response must match thesubin the ID token, otherwise the UserInfo data must not be used (section 5.3.2). Inopenid-clientthat is the third argument offetchUserInfo. emailwithoutemail_verified: truedoes not prove ownership of the address; linking an existing account by it is dangerous.
The stand: a local provider and an openid-client app
To see the whole flow without registering an application with an external provider, we run a local OpenID Provider on oidc-provider — a certified server implementation for Node.js — and a client on openid-client by the same author. The provider is for development only: data in memory, a built-in login form that accepts any password, a signing key generated on every start. The secrets in the example are test strings.
mkdir oidc-stand && cd oidc-stand
npm init -y
npm install openid-client@6.8.8 oidc-provider@9.12.2 jose@6.2.12// Test identity provider for local development only: in-memory storage,
// built-in dev login form, keys generated on every start.
import { generateKeyPairSync } from "node:crypto";
import Provider from "oidc-provider";
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const users = {
"aigerim": { sub: "aigerim", name: "Aigerim Test", email: "aigerim@example.test", email_verified: true },
};
const provider = new Provider("http://localhost:4100", {
clients: [
{
client_id: "interlab-web",
client_secret: "test-secret-change-me",
redirect_uris: ["http://localhost:4200/callback"],
grant_types: ["authorization_code", "refresh_token"],
},
{
client_id: "reports-worker",
client_secret: "test-worker-secret",
grant_types: ["client_credentials"],
redirect_uris: [],
response_types: [],
scope: "reports:read",
},
],
jwks: { keys: [{ ...privateKey.export({ format: "jwk" }), kid: "test-key-1", use: "sig", alg: "RS256" }] },
cookies: { keys: ["test-cookie-key-change-me"] },
pkce: { required: () => true },
scopes: ["openid", "profile", "email", "offline_access", "reports:read", "reports:write"],
claims: { openid: ["sub"], profile: ["name"], email: ["email", "email_verified"] },
features: { clientCredentials: { enabled: true } },
ttl: { AccessToken: 600, IdToken: 600, AuthorizationCode: 60, ClientCredentials: 600, RefreshToken: 86400, Interaction: 600, Session: 3600, Grant: 86400 },
async findAccount(ctx, id) {
const user = users[id];
if (!user) return undefined;
return { accountId: id, async claims() { return user; } };
},
});
provider.listen(4100, () => {
console.log("IdP: http://localhost:4100/.well-known/openid-configuration");
});findAccount queries your databaseOIDC_ISSUER=http://localhost:4100
OIDC_CLIENT_ID=interlab-web
OIDC_CLIENT_SECRET=test-secret-change-me
WORKER_CLIENT_ID=reports-worker
WORKER_CLIENT_SECRET=test-worker-secretThe client is a minimal node:http server, so a framework does not get in the way. /login creates the verifier, state and nonce, stores them under a random attempt id and sends the browser to the provider. /callback looks the values up by cookie, uses them exactly once and calls authorizationCodeGrant, which checks iss and state in the response, exchanges the code and validates the ID token.
// Relying party (the client): sign-in through an external provider.
// Run: node --env-file=.env app.mjs, then open http://localhost:4200/login
import { randomBytes } from "node:crypto";
import { createServer } from "node:http";
import * as client from "openid-client";
const REDIRECT_URI = "http://localhost:4200/callback";
// Discovery: endpoints and keys come from /.well-known/openid-configuration.
// allowInsecureRequests only because the test IdP runs on http://localhost.
const config = await client.discovery(
new URL(process.env.OIDC_ISSUER),
process.env.OIDC_CLIENT_ID,
process.env.OIDC_CLIENT_SECRET,
undefined,
{ execute: [client.allowInsecureRequests] },
);
// Pending sign-ins: transaction id -> PKCE verifier, state, nonce.
// In production keep this in the server-side session store, not in memory.
const pending = new Map();
function readCookie(req, name) {
const pair = (req.headers.cookie ?? "").split("; ").find((c) => c.startsWith(`${name}=`));
return pair?.slice(name.length + 1);
}
async function login(res) {
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const state = client.randomState();
const nonce = client.randomNonce();
const tx = randomBytes(16).toString("base64url");
pending.set(tx, { codeVerifier, state, nonce });
const url = client.buildAuthorizationUrl(config, {
redirect_uri: REDIRECT_URI,
scope: "openid profile email",
code_challenge: codeChallenge,
code_challenge_method: "S256",
state,
nonce,
});
// SameSite=Lax: the cookie is sent when the provider redirects the browser back (top-level GET).
res.writeHead(302, {
location: url.href,
"set-cookie": `oidc_tx=${tx}; HttpOnly; SameSite=Lax; Path=/callback; Max-Age=600`,
});
res.end();
}
async function callback(req, res, currentUrl) {
const tx = readCookie(req, "oidc_tx");
const checks = tx && pending.get(tx);
if (!checks) {
res.writeHead(400).end("Unknown or expired sign-in attempt");
return;
}
pending.delete(tx); // one attempt — one use
// Checks iss in the response, state, exchanges code + code_verifier,
// validates the ID token: iss, aud, exp, iat, nonce.
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: checks.codeVerifier,
expectedState: checks.state,
expectedNonce: checks.nonce,
});
const idToken = tokens.claims();
// UserInfo is accepted only if its sub matches the ID token's sub.
const profile = await client.fetchUserInfo(config, tokens.access_token, idToken.sub);
console.log("token response:", {
token_type: tokens.token_type,
expires_in: tokens.expiresIn(),
scope: tokens.scope,
has_refresh_token: Boolean(tokens.refresh_token),
});
console.log("ID token claims:", idToken);
console.log("UserInfo:", profile);
// Stable user key = iss + sub (not email). Next step: create your own session.
res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "set-cookie": "oidc_tx=; Path=/callback; Max-Age=0" });
res.end(`Signed in: ${idToken.iss} / ${idToken.sub} (${profile.name})`);
}
createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost:4200");
try {
if (url.pathname === "/login") return await login(res);
if (url.pathname === "/callback") return await callback(req, res, url);
res.writeHead(404).end();
} catch (err) {
console.error("sign-in failed:", err.name, err.code ?? "", err.message);
res.writeHead(400).end("Sign-in failed");
}
}).listen(4200, () => console.log("App: http://localhost:4200/login"));allowInsecureRequests is there only because the test provider runs over http on localhost — it must not be used with a real providerStart the provider and the client in two terminals, open http://localhost:4200/login, enter the login aigerim with any password and approve access. This is the browser's path — every redirect is visible:
302 GET http://localhost:4200/login
303 GET http://localhost:4100/auth?redirect_uri=http%3A%2F%2Flocalhost%3A4200%2Fcallback&scope=openid+profile+email&code_challenge=…&code_challenge_method=S256&state=…&nonce=…&client_id=interlab-web&response_type=code
200 GET http://localhost:4100/interaction/rJBqRAwenOqCqEBf1z3I_h0OWVqZlzE3jj9MnzNQYRN
303 POST http://localhost:4100/interaction/rJBqRAwenOqCqEBf1z3I_h0OWVqZlzE3jj9MnzNQYRN
303 GET http://localhost:4100/auth/rJBqRAwenOqCqEBf1z3I_h0OWVqZlzE3jj9MnzNQYRN
200 GET http://localhost:4100/interaction/bSwi-VlD7oDKJ1I4g6ZjNDzuc8L2B6Wd_a2StkjfvEk
303 POST http://localhost:4100/interaction/bSwi-VlD7oDKJ1I4g6ZjNDzuc8L2B6Wd_a2StkjfvEk
303 GET http://localhost:4100/auth/bSwi-VlD7oDKJ1I4g6ZjNDzuc8L2B6Wd_a2StkjfvEk
200 GET http://localhost:4200/callback?code=…&state=…&iss=http%3A%2F%2Flocalhost%3A4100code, state and iss$ node provider.mjs
oidc-provider WARNING: a quick start development-only in-memory adapter is used, you MUST change it in order to not lose all stateful provider data upon restart and to be able to share these between processes
oidc-provider WARNING: a quick start development-only feature devInteractions is enabled, you are expected to disable these interactions and provide your own
IdP: http://localhost:4100/.well-known/openid-configuration
$ node --env-file=.env app.mjs
App: http://localhost:4200/login
token response: {
token_type: 'bearer',
expires_in: 599,
scope: 'openid profile email',
has_refresh_token: false
}
ID token claims: {
sub: 'aigerim',
nonce: '-LzAqMKyjMJKsyDq5VUnxw_j__msNftg7EGno4Fsg-k',
aud: 'interlab-web',
exp: 1791774176,
iat: 1791773576,
iss: 'http://localhost:4100'
}
UserInfo: {
sub: 'aigerim',
name: 'Aigerim Test',
email: 'aigerim@example.test',
email_verified: true
}Validating the ID token
OpenID Connect Core (section 3.1.3.7) lists what the client must check in an ID token. openid-client does it inside authorizationCodeGrant, but you should know the list — you will need it when the ID token does not come from the token endpoint but, for example, from a mobile app to your API.
issexactly matches the issuer from discoveryaudcontains yourclient_idand no untrusted extra audiences- the signature is verified with a key from the provider's
jwks_uri, the algorithm is from an expected list,alg: noneis rejected - the current time is before
exp;iatis not too far in the past noncematches the value stored when the sign-in started- if
max_agewas requested,auth_timeis checked
A subtlety worth knowing: when the ID token comes directly from the token endpoint over TLS, the specification allows TLS server validation to stand in for the signature check, and by default openid-client does not verify the signature in that case (enableNonRepudiationChecks turns it on). The other checks — iss, aud, exp, iat, nonce — the library always performs. When the token arrives any other way, the signature check is mandatory. This is the full check with jose:
// Full ID token check when the token did NOT come straight from the token endpoint,
// e.g. a mobile app sends it to your API. OpenID Connect Core 1.0, section 3.1.3.7.
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwksCache = new Map();
export async function verifyIdToken(idToken, { issuer, jwksUri, clientId, nonce }) {
if (!jwksCache.has(jwksUri)) jwksCache.set(jwksUri, createRemoteJWKSet(new URL(jwksUri)));
const { payload } = await jwtVerify(idToken, jwksCache.get(jwksUri), {
issuer, // iss must match exactly
audience: clientId, // aud must contain our client_id
algorithms: ["RS256"], // only what we expect; never "none"
requiredClaims: ["sub", "iat", "exp"],
maxTokenAge: "10m", // reject tokens issued too long ago (iat)
clockTolerance: 30, // seconds of clock skew allowed for exp/iat
});
if (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== clientId) {
throw new Error("unexpected extra audiences");
}
if (payload.nonce !== nonce) throw new Error("nonce mismatch");
return payload;
}The stand's tests run the full flow, tampering with one parameter at a time: a foreign verifier, a foreign state, a foreign nonce, a reused code, a request without PKCE, an unregistered redirect_uri — and, for the manual check, a forged sub, a foreign audience and alg: none.
$ node --test negative.test.mjs idtoken.test.mjs
valid -> aigerim interlab-web
forged sub -> ERR_JWS_SIGNATURE_VERIFICATION_FAILED
other aud -> ERR_JWT_CLAIM_VALIDATION_FAILED aud
nonce -> nonce mismatch
alg none -> ERR_JOSE_ALG_NOT_ALLOWED
✔ ID token: valid passes, tampered / foreign aud / wrong nonce fail (119.2465ms)
-> invalid_scope requested scope is not allowed
-> invalid_client client authentication failed
✔ client credentials: scope outside the client's allowance is refused (28.9577ms)
✔ happy path returns an ID token for aigerim (86.1297ms)
-> ResponseBodyError invalid_grant grant request is invalid
✔ wrong code_verifier: the IdP refuses the code (PKCE) (114.0837ms)
-> ClientError OAUTH_INVALID_RESPONSE invalid response encountered
✔ state from another attempt is rejected before the token request (95.1324ms)
-> ClientError OAUTH_JWT_CLAIM_COMPARISON_FAILED unexpected JWT claim value encountered
✔ unexpected nonce is rejected (109.1164ms)
-> ResponseBodyError invalid_grant grant request is invalid
✔ the same code cannot be used twice (122.6895ms)
-> 303 invalid_request Authorization Server policy requires PKCE to be used for this request
✔ authorization request without PKCE is refused by the IdP (2.7214ms)
-> 400 null
✔ redirect_uri not registered: the IdP does not redirect at all (11.6301ms)
ℹ tests 9
ℹ pass 9
ℹ fail 0node --test output on the stand (test code not shown: it repeats the app.mjs flow with tampered values)Security mistakes: redirect_uri, state, implicit flow
Matching redirect_uri by prefix or pattern
- Why it is dangerous
- The code goes to the attacker's address, e.g. via an open redirect on your own domain
- What to do instead
- The authorization server compares the full string (RFC 9700, section 2.1); in the tests above the provider answers 400 and redirects nowhere
No state/PKCE, or constant values
- Why it is dangerous
- CSRF on the callback: the user gets «signed in» to the attacker's account
- What to do instead
- One-time values per attempt, bound to the browser session
Implicit flow in an SPA
- Why it is dangerous
- The token in the URL ends up in history, logs and
Referer, and cannot be bound to the client - What to do instead
- Authorization Code + PKCE, tokens via a backend (the BFF pattern) or kept in SPA memory
Password grant «for our own app»
- Why it is dangerous
- The password passes through the client, 2FA does not work, users learn to type passwords anywhere
- What to do instead
- Sign-in only on the provider's page
Signing in with an access token instead of an ID token
- Why it is dangerous
- A token issued to another application is accepted as proof of identity
- What to do instead
- Identity comes only from a validated ID token with your
aud
Looking users up by email
- Why it is dangerous
- Account takeover when the provider reassigns the address
- What to do instead
- Key on
iss+sub
Several providers without checking iss
- Why it is dangerous
- Mix-up attack: one provider's code is sent to another
- What to do instead
- Check
issin the response (RFC 9207) or in the ID token;openid-clientdoes it automatically
In our experience most problems are not in the protocol but where it meets your own code: a callback that never checks whether the sign-in actually started in this browser; a session created before the ID token is validated; a client secret in the repository or in the frontend. How to store client secrets and give each integration its own least-privilege keys is covered in the integration security lesson.
How this was verified7 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.