Session, JWT or OAuth: before choosing, decide where the state lives
"Session or JWT, which is better?" is the wrong question to start from, because it confuses three things. An opaque session is a random identifier that the server keeps in a table: the state lives on the server, and revoking it means deleting a row. A JWT is a set of signed claims that anyone holding the key can verify without asking anybody: the state lives in the token, and revoking it before expiry is the problem. OAuth 2.0 is neither: it is a delegation protocol, which lets an application act with limited access on behalf of a user without knowing their password. For a web application that talks to its own backend, the opaque session in a cookie is, in my judgement, the simplest road (it is an assessment, not a fact); JWT and OAuth come in when you have several services that do not share a store or a third-party application that must access the data. Here you build the pieces on a test project, in-process, and see where each one breaks.
What each choice really costs: revocation
A concrete example helps. Ada logs in and receives two things together: an opaque session and a JWT expiring in 15 minutes (900 seconds, a value chosen for the example). After 100 seconds she presses "Log out". The server deletes the session row: from the next second that session is no longer valid. The JWT, instead, has not been touched, and the server that verifies it with the signature alone has no way of knowing that Ada has left. In the program below the output says it in numbers: the session is rejected, the token remains valid for another 800 seconds.
- Opaque session: the state lives in a shared store, so every request costs a read, and revocation is immediate.
- Signed JWT: no read to verify it, but revoking before expiry requires reintroducing state (a list of revoked tokens) and therefore the read you wanted to avoid.
- Short-lived token with a refresh token: the access token stays stateless, and the state moves into the refresh token alone, which is checked rarely.
The three lines are not equivalent alternatives: they are three points on the same curve that trades read cost for promptness of revocation. None is "more secure" in absolute terms. The useful question is how long an access can stay valid after you have decided to remove it, and how many reads you can afford for each request. If the answer is "zero seconds" for sensitive operations, you need a state consulted on every request, whatever the token format.
JWT: a signature over integrity, not a secret
A JWT signed with HMAC is made of three base64url parts: header, claims and signature. The signature guarantees integrity, that is, that nobody has changed the bytes after signing (RFC 7515, section 1); it hides nothing, because the claims can be read by decoding the base64url, without a key. That is why no confidential data goes in the token. A second property concerns HMAC: the same key is used to sign and to verify, so every service that can verify can also forge, and RFC 7515 (section 10.5) explains that validating a MAC only proves that the message was generated by one of the parties that know the key. With several services belonging to different owners, asymmetric signatures are used, which I do not cover here.
The module below contains two verifiers set side by side, and the first is wrong on purpose. verifyNaive reads the algorithm from the token's header and, if it is none, returns the claims without checking anything: it is the defect that RFC 8725, section 2.1, describes: an attacker can change the algorithm to none and some libraries, trusting the value, treated the JWT as valid without checking any signature. verify instead chooses the algorithm itself (HS256, and nothing else, as section 3.1 of RFC 8725 requires), compares the signature in constant time with timingSafeEqual, and then checks expiry, nbf, issuer and audience. A token without exp is rejected: RFC 7519 considers the claim optional, but here it is a choice of the application.
import { createHmac, timingSafeEqual } from 'node:crypto';
const b64u = (data) => Buffer.from(data).toString('base64url');
const parse = (part) => JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
const hmac = (input, key) => createHmac('sha256', key).update(input).digest();
export function sign(payload, key) {
const input = `${b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64u(JSON.stringify(payload))}`;
return `${input}.${b64u(hmac(input, key))}`;
}
// WRONG: it trusts the algorithm declared by the token, which is written by whoever presents it.
export function verifyNaive(token, key) {
const [h, p, s] = token.split('.');
if (parse(h).alg === 'none') return parse(p);
if (b64u(hmac(`${h}.${p}`, key)) !== s) throw new Error('invalid signature');
return parse(p);
}
// RIGHT: the server chooses the algorithm; signature, expiry, issuer and audience are all checked.
export function verify(token, key, { iss, aud, now }) {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('invalid format');
const [h, p, s] = parts;
const { alg } = parse(h);
if (alg !== 'HS256') throw new Error(`algorithm not allowed: ${alg}`);
const expected = hmac(`${h}.${p}`, key);
const received = Buffer.from(s, 'base64url');
if (received.length !== expected.length || !timingSafeEqual(received, expected)) throw new Error('invalid signature');
const c = parse(p);
if (typeof c.exp !== 'number' || c.exp <= now) throw new Error('expired or missing exp');
if (c.nbf !== undefined && c.nbf > now) throw new Error('not yet valid');
if (c.iss !== iss) throw new Error('unexpected issuer');
if (![].concat(c.aud ?? []).includes(aud)) throw new Error('unexpected audience');
return c;
}
The test that follows does two things. First it verifies that the code is correct with two independent checks: the signature of the RFC 7515 test vector (appendix A.1, with its key and its input) coincides byte for byte with the expected one, and the jose library (version 6.2.17) verifies a token of mine while I verify one signed by it. Then it attacks the two verifiers with four tokens built by me. The clock is fixed (NOW), so the output does not depend on the machine's time. The claim role and its value user are the example's own data.
import { createHmac } from 'node:crypto';
import { SignJWT, jwtVerify } from 'jose';
import { sign, verify, verifyNaive } from './jwt.mjs';
const NOW = 1_700_000_000;
const key = Buffer.from('0123456789abcdef0123456789abcdef');
const ok = { iss: 'api.example', aud: 'app', now: NOW };
const claim = { sub: 'ada', role: 'user', iss: 'api.example', aud: 'app', exp: NOW + 900 };
const outcome = (f) => { try { f(); return 'accepted'; } catch (e) { return `rejected (${e.message})`; } };
// 1. RFC 7515 vector, appendix A.1: same key, same input, same expected signature.
const rfcKey = Buffer.from('AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow', 'base64url');
const rfcInput = 'eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ';
const rfcSignature = createHmac('sha256', rfcKey).update(rfcInput).digest('base64url');
console.log('RFC 7515 A.1 vector:', rfcSignature === 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' ? 'same signature' : 'DIFFERENT');
// 2. Second, independent implementation: the jose library.
const mine = sign(claim, key);
const byJose = await jwtVerify(mine, key, { algorithms: ['HS256'], issuer: 'api.example', audience: 'app', currentDate: new Date(NOW * 1000) });
console.log('jose verifies my token:', byJose.payload.sub);
const theirs = await new SignJWT({ sub: 'ada' }).setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuer('api.example').setAudience('app').setExpirationTime(NOW + 900).sign(key);
console.log('I verify the jose token:', verify(theirs, key, ok).sub);
// 3. Attacks against the naive verifier and against the real one.
const unsigned = (c) => `${Buffer.from('{"alg":"none"}').toString('base64url')}.${Buffer.from(JSON.stringify(c)).toString('base64url')}.`;
const forged = unsigned({ ...claim, role: 'admin' });
const [h, p, s] = mine.split('.');
const tampered = `${h}.${Buffer.from(JSON.stringify({ ...claim, role: 'admin' })).toString('base64url')}.${s}`;
const cases = [
['alg none, role admin', forged],
['payload changed, old signature', tampered],
['expired token', sign({ ...claim, exp: NOW - 1 }, key)],
['wrong audience', sign({ ...claim, aud: 'other-app' }, key)],
];
for (const [name, token] of cases) {
console.log(name.padEnd(32), '| naive:', outcome(() => verifyNaive(token, key)).padEnd(28), '| correct:', outcome(() => verify(token, key, ok)));
}
// Output:
// RFC 7515 A.1 vector: same signature
// jose verifies my token: ada
// I verify the jose token: ada
// alg none, role admin | naive: accepted | correct: rejected (algorithm not allowed: none)
// payload changed, old signature | naive: rejected (invalid signature) | correct: rejected (invalid signature)
// expired token | naive: accepted | correct: rejected (expired or missing exp)
// wrong audience | naive: accepted | correct: rejected (unexpected audience)
The first line of the output shows the RFC vector confirmed; the second and third show that the two codes understand each other. In the table the token with alg: none and role admin passes the naive verifier and is stopped by the real verifier; the same happens with the expired token and with the one meant for another application, which the naive verifier accepts because it checks nothing beyond the signature. Only the tampered payload with the old signature is rejected by both, because the naive verifier at least compares the HS256 signature (with a non-constant-time operator, another defect). A library like jose saves you from writing all this, but the defects are the same ones you will find in wrong configurations, and this table tells you which checks to demand.
Refresh token: rotating to notice thefts
If the access token lives 15 minutes, you need a way to obtain a new one without asking for the password every time. That is the refresh token, and it is worth more than the access token, because it allows minting others. RFC 9700 (Best Current Practice for OAuth 2.0 Security, January 2025) requires, for public clients, detecting theft with one of two methods: binding the refresh token to the client cryptographically, or rotating it, that is, issuing a new refresh token at every renewal and invalidating the previous one while keeping track of the lineage. If a thief and the legitimate client both use the same token, one of the two will present an already invalidated token and the server notices. The RFC is explicit about the price: the server cannot know which of the two presented that token, revokes the active token and the legitimate client must repeat the authorization.
The in-memory model below reproduces the case, together with the comparison between session and JWT from before. The identifiers are counters and not random values, to make the output reproducible: in a real system they are unpredictable values. The model is not an implementation to copy: it lacks persistence, expiry on inactivity and revocation on password change that the RFC mentions among the possible security events.
import { sign, verify } from './jwt.mjs';
const key = Buffer.from('0123456789abcdef0123456789abcdef');
const ACCESS_S = 900; // lifetime of the access token: 15 minutes (the example's choice)
const cfg = { iss: 'api.example', aud: 'app' };
// In-memory model. The identifiers are counters so that the output is reproducible:
// in a real system they would be random values of at least 128 bits.
class Authorizations {
#n = 0; #sessions = new Set(); #families = new Map(); #refresh = new Map();
access(user, t) { return sign({ sub: user, ...cfg, exp: t + ACCESS_S }, key); }
openSession() { const id = `sess-${++this.#n}`; this.#sessions.add(id); return id; }
sessionValid(id) { return this.#sessions.has(id); }
closeSession(id) { this.#sessions.delete(id); }
login(user) {
const family = `fam-${++this.#n}`;
this.#families.set(family, { user, revoked: false });
return this.#newRefresh(family);
}
#newRefresh(family) {
const token = `refresh-${++this.#n}`;
this.#refresh.set(token, { family, used: false });
return token;
}
renew(token, t) {
const r = this.#refresh.get(token);
if (!r) return { error: 'unknown' };
const f = this.#families.get(r.family);
if (f.revoked) return { error: 'family revoked' };
if (r.used) { f.revoked = true; return { error: 'reuse detected: family revoked' }; }
r.used = true;
return { access: this.access(f.user, t), refresh: this.#newRefresh(r.family) };
}
}
const a = new Authorizations();
// A. After logout, what is still valid?
const session = a.openSession();
const jwt = a.access('ada', 0);
a.closeSession(session);
const t = 100;
let jwtValid = true;
try { verify(jwt, key, { ...cfg, now: t }); } catch { jwtValid = false; }
console.log(`logout at t=${t}s | opaque session: ${a.sessionValid(session) ? 'valid' : 'rejected'} | JWT: ${jwtValid ? `still valid for ${ACCESS_S - t} s` : 'rejected'}`);
// B. Refresh token rotation with reuse detection.
const r1 = a.login('ada');
const r2 = a.renew(r1, 10).refresh;
console.log('user renews with r1 ->', r2 ? 'new refresh issued' : 'error');
console.log('thief reuses r1 ->', a.renew(r1, 20).error);
console.log('user renews with r2 ->', a.renew(r2, 30).error);
// Output:
// logout at t=100s | opaque session: rejected | JWT: still valid for 800 s
// user renews with r1 -> new refresh issued
// thief reuses r1 -> reuse detected: family revoked
// user renews with r2 -> family revoked
The output shows the first line that we have already commented on and then the rotation in three moves: the user renews with r1 and receives a new refresh; the thief reuses r1 and the server detects the reuse and revokes the family; the user, with their r2 perfectly valid until a moment before, finds the family revoked. It is the intended behaviour: the defence works by accepting that the legitimate user must authenticate again. The limit remains that the access token already issued, even after revocation, is valid until expiry: with a 15-minute life, that is the window of risk you accept.
OAuth 2.0: the authorization code with PKCE
OAuth solves a different problem: an application (the client) wants to access a user's data on another service without having their password. The recommended flow is the authorization code: the client sends the user to authorization, the user consents, the client receives a short-lived code and exchanges it for tokens. The fragile point is the code, which passes through the browser: if someone intercepts it, they can exchange it in the client's place. PKCE (RFC 7636) closes it: the client invents a random secret, the verifier, and sends to authorization only its SHA-256 hash (the "challenge"); at the exchange it presents the verifier, and whoever stole only the code does not have it. RFC 9700 requires authorization servers to support PKCE and to compare redirect URIs by exact string, and advises clients against the implicit flow.
The test server below has two paths, /authorize and /token, and is a teaching model: it issues no real tokens, has no users and opens no port (the test uses inject()). Three choices in the code correspond to rules in the text: the comparison of the redirect URI is an exact equality (RFC 9700, section 2.1); the code is deleted at every attempt, successful or not, because RFC 6749 (section 10.5) asks for short-lived, single-use codes; and a verifier presented for a code issued without a challenge is rejected, because RFC 9700 asks to prevent the downgrade of PKCE.
import Fastify from 'fastify';
import { createHash } from 'node:crypto';
const s256 = (verifier) => createHash('sha256').update(verifier).digest('base64url');
const REDIRECT = 'https://app.example/callback';
const VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; // example from RFC 7636, appendix B
console.log('RFC 7636 B vector:', s256(VERIFIER) === 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' ? 'same challenge' : 'DIFFERENT');
const codes = new Map();
let n = 0;
const app = Fastify();
app.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' },
(req, body, done) => done(null, Object.fromEntries(new URLSearchParams(body))));
app.get('/authorize', async (req, reply) => {
const { redirect_uri, code_challenge, code_challenge_method } = req.query;
if (redirect_uri !== REDIRECT) return reply.code(400).send({ error: 'invalid_request' }); // exact match
if (code_challenge && code_challenge_method !== 'S256') return reply.code(400).send({ error: 'invalid_request' });
const code = `code-${++n}`;
codes.set(code, { code_challenge });
return reply.redirect(`${redirect_uri}?code=${code}`);
});
app.post('/token', async (req, reply) => {
const { code, code_verifier, redirect_uri } = req.body;
const entry = codes.get(code);
codes.delete(code); // a code is valid only once, even if the request fails
const failed = () => reply.code(400).send({ error: 'invalid_grant' });
if (!entry || redirect_uri !== REDIRECT) return failed();
if (entry.code_challenge) {
if (!code_verifier || s256(code_verifier) !== entry.code_challenge) return failed();
} else if (code_verifier) return failed(); // anti-downgrade: verifier without a registered challenge
return { access_token: 'test-token', token_type: 'Bearer' };
});
async function getCode(query) {
const r = await app.inject({ url: '/authorize?' + new URLSearchParams(query) });
return new URL(r.headers.location).searchParams.get('code');
}
async function exchange(fields) {
const r = await app.inject({ method: 'POST', url: '/token', headers: { 'content-type': 'application/x-www-form-urlencoded' }, payload: new URLSearchParams({ redirect_uri: REDIRECT, ...fields }).toString() });
return `${r.statusCode} ${r.json().error ?? 'token issued'}`;
}
const withChallenge = { redirect_uri: REDIRECT, code_challenge: s256(VERIFIER), code_challenge_method: 'S256' };
console.log('legitimate client, right verifier'.padEnd(36), ':', await exchange({ code: await getCode(withChallenge), code_verifier: VERIFIER }));
console.log('stolen code, no verifier'.padEnd(36), ':', await exchange({ code: await getCode(withChallenge) }));
console.log('stolen code, wrong verifier'.padEnd(36), ':', await exchange({ code: await getCode(withChallenge), code_verifier: 'x'.repeat(43) }));
const stolen = await getCode(withChallenge);
await exchange({ code: stolen, code_verifier: VERIFIER });
console.log('same code a second time'.padEnd(36), ':', await exchange({ code: stolen, code_verifier: VERIFIER }));
console.log('verifier on a code without challenge'.padEnd(36), ':', await exchange({ code: await getCode({ redirect_uri: REDIRECT }), code_verifier: VERIFIER }));
const r = await app.inject({ url: '/authorize?' + new URLSearchParams({ ...withChallenge, redirect_uri: 'https://app.example/callback/../evil' }) });
console.log('redirect_uri not identical'.padEnd(36), ':', r.statusCode, r.json().error);
// Output:
// RFC 7636 B vector: same challenge
// legitimate client, right verifier : 200 token issued
// stolen code, no verifier : 400 invalid_grant
// stolen code, wrong verifier : 400 invalid_grant
// same code a second time : 400 invalid_grant
// verifier on a code without challenge : 400 invalid_grant
// redirect_uri not identical : 400 invalid_request
The first line is the check with appendix B of RFC 7636: the same verifier gives the same challenge. The legitimate client gets the token. Whoever stole the code, without a verifier or with a wrong one, gets a 400 invalid_grant; the same code used twice fails the second time; the verifier on a code without a challenge is rejected; a redirect URI that does not coincide exactly with the registered one gives invalid_request. The model does not cover the real exchange (client authentication, state, scope): for those points I refer you to API Security: OAuth 2.1, JWT Best Practices and Rate Limiting. A note on status: "OAuth 2.1" is, as of 2026-11-13, an IETF draft (draft-ietf-oauth-v2-1, revision 17 of 2026-10-31, a working group document), not a standard; what is published as a recommendation is RFC 9700.
One last piece, which I do not build here: if besides delegating access you need to know who the user is, OpenID Connect is used, which its specification defines as "a simple identity layer on top of the OAuth 2.0 protocol" and whose ID token is a JWT. The access token serves to call an API; the ID token serves the client to know who has logged in. Confusing them, using the access token as proof of identity, is a typical mistake.
What you can do now
You can say at what cost each choice buys its convenience: the session pays a read per request and revokes at once, the JWT reads nothing and does not revoke without state, the rotating refresh moves the check onto a rare token. You can write a JWT verifier that chooses the algorithm itself and checks expiry, issuer and audience, and you have seen the four tokens that put it to the test. You can explain why PKCE makes a stolen authorization code useless, and why "OAuth 2.1" must not be cited as a standard. I did not cover real session cookies, with HttpOnly, Secure and SameSite: you find them in Secure Authentication: Session Management and Cookies; nor key custody and asymmetric signatures, in Cryptographic Failures: Hashing, Encryption and Tokens. The previous article in the series is Web security, and the next, multi-environment CI/CD, closes the series.
Frequently asked questions
Is a JWT encrypted?
A signed JWT is not encrypted: the signature protects integrity, but the claims can be read with a simple base64url decoding. Do not put confidential data in it. Encryption is another specification (JWE) and does not concern the tokens of this article.
How do you revoke a JWT before expiry?
Only by reintroducing state: a list of revoked identifiers consulted at every verification, or very short access tokens with a refresh token that the server checks. In the test, after logout, the JWT stays valid for 800 seconds out of 900.
Is PKCE only for public apps?
RFC 9700 asks authorization servers to support it in general, and to prevent its downgrade when the client uses it. In the article's model the server rejects the verifier on a code issued without a challenge.
All the articles are collected on the series page.
Sources
- RFC 7515, JSON Web Signature (JWS) - integrity of the signature, difference between digital signature and MAC (section 10.5), HS256 test vector (appendix A.1), consulted on 2026-11-13
- RFC 7519, JSON Web Token (JWT) - meaning of exp and nbf, consulted on 2026-11-13
- RFC 8725, JSON Web Token Best Current Practices - attack with alg none (section 2.1) and verification of the algorithm (section 3.1), consulted on 2026-11-13
- RFC 9700, Best Current Practice for OAuth 2.0 Security - exact comparison of redirect URIs (2.1), PKCE, implicit flow, refresh token rotation (4.14.2), consulted on 2026-11-13
- RFC 7636, Proof Key for Code Exchange (PKCE) - S256 method and test vector (appendix B), consulted on 2026-11-13
- RFC 6749, The OAuth 2.0 Authorization Framework - short-lived, single-use authorization codes (section 10.5), consulted on 2026-11-13
- draft-ietf-oauth-v2-1, The OAuth 2.1 Authorization Framework - draft status, revision 17 of 2026-10-31 (read from the Datatracker API), consulted on 2026-11-13
- OpenID Connect Core 1.0 - definition of the identity layer and of the ID token as a JWT, consulted on 2026-11-13
- panva/jose - library used as a second implementation; version 6.2.17 from the npm registry, consulted on 2026-11-13
The listings were run with Node 24.11.0, Fastify 5.12.8 (inject(), no port opened) and jose 6.2.17, twice with identical output. The session, rotation and authorization server models are didactic, written for this article: they issue no real tokens and are not to be copied into production. No browser, identity provider or external service was used.
Commenti
Caricamento commenti...
Sign in to comment