For a website with its own backend, a server-side session is the most reliable option: after the password check the server creates a record, and the browser gets a random identifier in a cookie with HttpOnly, Secure and SameSite. JWT fits when other services or a mobile client verify the token, but it is hard to revoke before it expires, so it lives for minutes and logout needs refresh tokens or a revocation list. Tokens do not belong in localStorage, and cookie-based requests need CSRF protection.
After this you will be able to
- compare server-side sessions and JWT on revocation, storage and risk, and pick the right one for a project
- configure a session cookie with
HttpOnly,Secure,SameSite, the__Host-prefix and timeouts - verify a JWT on the server: allowed
alg, signature,exp,iss,audand token type - write supertest tests for a wrong password, access without a cookie, logout and a cross-site request
Know beforehand
Verified with
Node.js 24 LTS (24.18)Express 5.3express-session 1.19argon2 0.45jose 6.2TypeScript 7.0Vitest 5.0supertest 7.3
Checked against docs: October 11, 2026
Contents
- Authentication vs authorization
- Server-side sessions and cookies
- Cookie attributes: HttpOnly, Secure, SameSite and lifetime
- JWT: structure, signature and verification
- Where to store the token, refresh and revocation
- CSRF: why cookies need protection from other sites
- Example: Express 5 login with Argon2id and a session
- Testing, the JWT variant and what production needs
Authentication vs authorization
Authentication answers “who is this?”: the user proves they own the account with a password, a one-time code or a passkey. Authorization answers “what are they allowed to do?”: may they see this lead, may they delete that user. These are two separate steps, and a mistake in either one leaks data. This lesson covers the first step; permissions are the topic of the next lesson, “Authorization: roles, permissions and server-side access checks”.
HTTP keeps no state between requests: the server does not remember that the same browser entered the right password a minute ago. So after login the client receives something it presents with every following request. There are two common options: a server-side session, where the cookie holds only a random identifier and the data stays on the server, and a self-contained token, most often a JWT (JSON Web Token), where the user data is signed and travels with the request.
Server-side sessions and cookies
The classic scheme is easy to reason about and easy to revoke. The server keeps the session record in memory, Redis or a database, and the browser holds only the key to it. Here is what happens from the password form to logout:
Password check
The server looks up the user and compares the password with an Argon2id hash. “No such email” and “wrong password” get the same response, so nobody can enumerate registered accounts.
New session identifier
After a successful login the ID is issued anew. Otherwise session fixation works: an attacker plants a known ID in the victim’s browser and is inside once the victim logs in.
Cookie with the identifier
The response carries a
Set-Cookieheader with a random value. OWASP requires at least 64 bits of entropy from a cryptographically secure generator;express-sessiongenerates the ID for you.Every following request
The browser attaches the cookie, the server finds the record, checks the timeouts and knows the user.
Logout
The session record is deleted on the server and the cookie is cleared in the browser. After that even a stolen copy of the identifier is useless.
A session has two deadlines. The idle timeout closes it when the user has been inactive for a while; the absolute timeout closes it a fixed time after login, even with constant activity. OWASP gives reference points: 15–30 minutes of inactivity for low-risk applications and 2–5 minutes for high-value ones, and 4–8 hours of absolute lifetime for an office tool used all day. The right values depend on what the application protects.
Cookie attributes: HttpOnly, Secure, SameSite and lifetime
A session is only as safe as its cookie. Attributes are set in the Set-Cookie header, and for a session cookie the set is almost always the same:
HttpOnly
- What it does
- Prevents JavaScript from reading the cookie via
document.cookie. Requests made withfetchstill send it - What to set for a session
- Always: XSS can’t steal the identifier
Secure
- What it does
- The cookie is sent over HTTPS only. Browsers make an exception for
localhost - What to set for a session
- Always
SameSite
- What it does
Strict— same-site requests only;Lax— plus top-level navigations from other sites with safe methods;None— always, and only together withSecure- What to set for a session
StrictorLax, explicitly: browser defaults differ
Expires / Max-Age
- What it does
- Lifetime. Without them the cookie is a “session cookie”, but browsers with session restore bring it back too
- What to set for a session
- A lifetime plus mandatory timeout checks on the server
Domain / Path
- What it does
- Without
Domainonly the host that set the cookie receives it, not subdomains - What to set for a session
Path=/, noDomain
__Host- prefix
- What it does
- The browser accepts the cookie only with
Secure,Path=/and noDomain - What to set for a session
- Recommended by OWASP for session IDs
Choosing between Strict and Lax is an engineering trade-off. Strict won’t send the cookie even when the user follows a link to your site from an email: they see the page as a guest although they are logged in. Lax sends the cookie on such a navigation but not on a cross-site POST. Lax is usually enough for a customer account; Strict makes sense for banking operations. Either way SameSite is an extra layer, not a complete CSRF defense; more on that below.
JWT: structure, signature and verification
RFC 7519 defines JWT as a compact way to transmit a set of claims as JSON. A signed token has three dot-separated parts — header, payload and signature — each base64url-encoded. The signature, either an HMAC with a shared secret (e.g. HS256) or a private-key signature (ES256, EdDSA), guarantees the content was not changed. But base64url is not encryption: anyone holding the token can read every claim. Keep ID documents and phone numbers out of a JWT.
sub— who the token was issued to (user ID)iss— who issued it;aud— which service it is meant forexp— the time from which the token must be rejected;iat— when it was issuedjti— a unique token ID, needed for logs and revocation lists
Most attacks on JWT don’t break the cryptography; they exploit lenient validation. RFC 8725 and the OWASP JSON Web Token Cheat Sheet boil it down to a few rules:
- List the allowed algorithms explicitly. A token with
"alg": "none"is never accepted, and HMAC and public-key algorithms are never mixed — otherwise algorithm confusion becomes possible - Check
expand require the claims you rely on:josehas arequiredClaimsoption for that - Validate
issandaud: a token issued for another service of the same issuer must be rejected - Set an explicit
typheader and check it, so a password-reset token can’t be presented as an access token (RFC 8725, §3.11) - An
HS256secret has at least 256 bits from a cryptographically secure generator — not a password and not a string in the repository - Never take the verification key from the token itself (
jwk,jku,x5u): the attacker controls the header
Where to store the token, refresh and revocation
The most common choice in tutorials — putting the JWT into localStorage — is something OWASP explicitly forbids. localStorage and sessionStorage are readable by any script on the page, so a single XSS flaw discloses every token at once. The recommendation is a cookie with HttpOnly; Secure; SameSite=Strict or a BFF (Backend-for-Frontend) pattern, where your server keeps the tokens and the browser gets a regular session.
Where state lives
- Server-side session
- In the session store on the server
- JWT
- In the token itself; the server checks the signature
Logout and blocking
- Server-side session
- Delete the record and access ends at once
- JWT
- The token is valid until
exp; you need a revocation list or a short lifetime
What the client sees
- Server-side session
- A meaningless random string
- JWT
- All claims in plain text
Verification by other services
- Server-side session
- Requires access to a shared store
- JWT
- The verification key is enough
Typical client
- Server-side session
- A website or web app with its own backend
- JWT
- A mobile app, service-to-service calls, OAuth / OpenID Connect
Because revocation is hard, access tokens are short-lived — minutes — and a refresh token is issued to extend the session. RFC 9700, the current OAuth 2.0 security best practice, requires detecting refresh token replay for public clients, i.e. applications that can’t keep a secret. One way is rotation: every refresh returns a new refresh token, the old one becomes invalid, and presenting the old one again revokes the whole chain. That is the signal the token was stolen.
If access tokens must be revoked anyway (logout, password change, blocking), OWASP suggests a denylist keyed by jti and iss, kept until exp. The key must not be the token itself or its hash: because of encoding ambiguity an attacker may produce a different string with the same valid signature. Issuers serving many services have a standardized mechanism, the Token Status List.
CSRF: why cookies need protection from other sites
The browser attaches cookies to requests to your site automatically — even when an attacker’s page sent the request. That is the basis of CSRF (Cross-Site Request Forgery): a hidden form on another site POSTs to your /transfer, and the logged-in user’s browser adds the session cookie. The browser never adds an Authorization: Bearer header on its own, so CSRF is primarily a problem of cookie-based authentication.
- Your framework’s built-in protection, if it has one, is OWASP’s first choice
- CSRF tokens: the synchronizer token pattern for stateful apps, the signed double-submit cookie for stateless ones
- Fetch Metadata headers: with
Sec-Fetch-Sitethe server sees whether a request came from its own origin (same-origin) or another site (cross-site). OWASP notes support in all major browsers since March 2023 and requires anOriginfallback for clients without these headers SameSiteon the session cookie is an extra layer but not the only defense: it has limits, such as a registrable domain shared with subdomains you don’t control- No state changes via GET:
SameSite=Laxlets cross-site GET navigations through
A special case is login CSRF: the attacker silently logs the victim into the attacker’s account, and the victim types card details into it. That is why the request-origin check in our example sits in front of every state-changing route, /login included.
Example: Express 5 login with Argon2id and a session
You need Node.js 24: it runs TypeScript files directly by stripping the types, so there is no build step. The project is a plain folder with package.json:
npm init -y
npm pkg set type=module
npm install express@5 express-session@1 argon2@0.45 jose@6
npm install -D typescript @types/node@24 @types/express@5 @types/express-session vitest supertest @types/supertest{
"compilerOptions": {
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src", "test"]
}Passwords are hashed with Argon2id, the first choice in the OWASP Password Storage Cheat Sheet. The OWASP minimum is 19 MiB of memory, 2 passes and 1 lane; the argon2 package defaults (64 MiB, 3 passes, 4 lanes) exceed it and are stored right in the hash string. OWASP keeps bcrypt for legacy systems: it limits passwords to 72 bytes. Parameter tuning, rehashing and migrating from old algorithms will be covered in a separate password storage recipe.
import * as argon2 from "argon2";
import { randomUUID } from "node:crypto";
export type Role = "admin" | "manager" | "analyst";
export type User = {
id: string;
email: string;
role: Role;
passwordHash: string;
};
// Учебное хранилище в памяти. В реальном проекте — таблица в базе данных.
const usersByEmail = new Map<string, User>();
export async function createUser(email: string, password: string, role: Role): Promise<User> {
const user: User = {
id: randomUUID(),
email: email.toLowerCase(),
role,
// Argon2id с параметрами библиотеки по умолчанию; соль генерируется сама
passwordHash: await argon2.hash(password),
};
usersByEmail.set(user.email, user);
return user;
}
export function findUserById(id: string): User | undefined {
for (const user of usersByEmail.values()) {
if (user.id === id) return user;
}
return undefined;
}
// Хеш-заглушка: для несуществующего email проверка идёт так же долго,
// и время ответа не выдаёт, зарегистрирован ли адрес
const dummyHash = argon2.hash("timing-equalizer-not-a-real-password");
export async function verifyCredentials(email: string, password: string): Promise<User | null> {
const user = usersByEmail.get(email.toLowerCase());
const valid = await argon2.verify(user?.passwordHash ?? (await dummyHash), password);
return user && valid ? user : null;
}The app puts together everything discussed above: the request-origin check, a session with a secure cookie, a new ID after login, an absolute timeout and logout that deletes the session on the server. rolling: true together with maxAge gives the idle timeout; requireAuth enforces the absolute one based on the login time.
import express, { type NextFunction, type Request, type Response } from "express";
import session from "express-session";
import { findUserById, verifyCredentials, type User } from "./users.ts";
declare module "express-session" {
interface SessionData {
userId: string;
authenticatedAt: number;
}
}
declare global {
namespace Express {
interface Locals {
user?: User;
}
}
}
export const SESSION_COOKIE = "__Host-sid";
const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 минут без запросов
const ABSOLUTE_TIMEOUT_MS = 8 * 60 * 60 * 1000; // 8 часов с момента входа
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
// CSRF: изменяющие запросы принимаем только со своего сайта.
// Основной сигнал — Sec-Fetch-Site, запасной — Origin; без обоих — отказ.
function rejectCrossSite(appOrigin: string) {
return (req: Request, res: Response, next: NextFunction) => {
if (SAFE_METHODS.has(req.method)) return next();
const site = req.get("Sec-Fetch-Site");
const sameOrigin = site ? site === "same-origin" : req.get("Origin") === appOrigin;
if (!sameOrigin) {
res.status(403).json({ error: "cross_site_request" });
return;
}
next();
};
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const { userId, authenticatedAt } = req.session;
const user = userId ? findUserById(userId) : undefined;
const expired = !authenticatedAt || Date.now() - authenticatedAt > ABSOLUTE_TIMEOUT_MS;
if (!user || expired) {
req.session.destroy(() => res.status(401).json({ error: "unauthenticated" }));
return;
}
res.locals.user = user;
next();
}
export function createApp(options: { sessionSecret: string; appOrigin: string }) {
if (options.sessionSecret.length < 32) throw new Error("SESSION_SECRET is too short");
const app = express();
app.set("trust proxy", 1); // HTTPS завершается на прокси (Nginx, балансировщик)
app.use(express.json({ limit: "10kb" }));
app.use(rejectCrossSite(options.appOrigin));
app.use(
session({
name: SESSION_COOKIE,
secret: options.sessionSecret,
resave: false,
saveUninitialized: false, // не создаём сессию до входа
rolling: true, // продлеваем cookie при каждом ответе: тайм-аут бездействия
cookie: {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: IDLE_TIMEOUT_MS,
},
}),
);
app.post("/login", async (req, res, next) => {
const { email, password } = req.body ?? {};
if (typeof email !== "string" || typeof password !== "string" || password.length > 256) {
res.status(400).json({ error: "invalid_request" });
return;
}
const user = await verifyCredentials(email, password);
if (!user) {
// Один и тот же ответ для «нет такого email» и «неверный пароль»
res.status(401).json({ error: "invalid_credentials" });
return;
}
// Новый ID сессии после входа — защита от фиксации сессии
req.session.regenerate((err) => {
if (err) return next(err);
req.session.userId = user.id;
req.session.authenticatedAt = Date.now();
req.session.save((err) => (err ? next(err) : res.status(204).end()));
});
});
app.get("/me", requireAuth, (req, res) => {
const { id, email, role } = res.locals.user!;
res.json({ id, email, role });
});
app.post("/logout", (req, res, next) => {
// Сессия удаляется на сервере: украденная копия cookie тоже перестаёт работать
req.session.destroy((err) => {
if (err) return next(err);
res.clearCookie(SESSION_COOKIE, { path: "/", httpOnly: true, secure: true, sameSite: "lax" });
res.status(204).end();
});
});
return app;
}app.set("trust proxy", 1) says there is exactly one reverse proxy in front of the app that terminates HTTPS and passes the protocol in X-Forwarded-Proto. The Express documentation warns that the setting must match the real deployment, otherwise clients can supply those headers themselves. If the app faces the internet directly, don’t enable trust proxy.
import { createApp } from "./app.ts";
import { createUser } from "./users.ts";
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) throw new Error("SESSION_SECRET is not set");
const port = Number(process.env.PORT ?? 3000);
const app = createApp({
sessionSecret,
appOrigin: process.env.APP_ORIGIN ?? `http://localhost:${port}`,
});
if (process.env.NODE_ENV !== "production") {
// Демо-пользователь только для локального запуска
await createUser("admin@example.com", "correct horse battery staple", "admin");
}
app.listen(port, () => console.log(`http://localhost:${port}`));npx tsc -p .
export SESSION_SECRET=$(node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))")
PORT=3917 node src/server.ts$ curl -i -X POST http://127.0.0.1:3917/login \
-H "Content-Type: application/json" -H "Origin: http://localhost:3917" \
-H "X-Forwarded-Proto: https" \
-d '{"email":"admin@example.com","password":"correct horse battery staple"}'
HTTP/1.1 204 No Content
Set-Cookie: __Host-sid=s%3AfuTZ…; Path=/; Expires=Sun, 11 Oct 2026 23:47:55 GMT; HttpOnly; Secure; SameSite=Lax
$ curl http://127.0.0.1:3917/me -H "Cookie: __Host-sid=s%3AfuTZ…"
{"id":"42c620b1-f267-4410-8b36-bf939acd4128","email":"admin@example.com","role":"admin"}
$ curl -i -X POST http://127.0.0.1:3917/logout -H "Cookie: __Host-sid=s%3AfuTZ…"
HTTP/1.1 403 ForbiddenA common trap: login returns 204 but there is no Set-Cookie header. That is how express-session behaves when the cookie is marked Secure and the request arrived over plain HTTP — we reproduced it by sending the same request without X-Forwarded-Proto. Fix HTTPS on the proxy and the trust proxy setting instead of dropping Secure.
Testing, the JWT variant and what production needs
supertest sends requests straight into the Express app without starting a server. The helper imitates a browser on your own site behind an HTTPS proxy and extracts the session cookie from the response. We pass the cookie by hand instead of using an agent, so the test checks its attributes along the way.
import type { Express } from "express";
import request from "supertest";
import { SESSION_COOKIE } from "../src/app.ts";
// Так выглядит запрос со своего сайта, прошедший через HTTPS-прокси
export const SAME_ORIGIN = { "X-Forwarded-Proto": "https", "Sec-Fetch-Site": "same-origin" };
export function sessionCookie(res: request.Response): string | undefined {
const cookies = res.get("Set-Cookie") ?? [];
return cookies.find((c) => c.startsWith(`${SESSION_COOKIE}=`))?.split(";")[0];
}
export async function login(app: Express, email: string, password: string) {
const res = await request(app).post("/login").set(SAME_ORIGIN).send({ email, password });
return { res, cookie: sessionCookie(res) };
}import { randomBytes } from "node:crypto";
import request from "supertest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { createApp } from "../src/app.ts";
import { createUser } from "../src/users.ts";
import { login, SAME_ORIGIN, sessionCookie } from "./helpers.ts";
const app = createApp({
sessionSecret: randomBytes(32).toString("hex"),
appOrigin: "https://app.example.com",
});
const EMAIL = "anna@example.com";
const PASSWORD = "long-and-unique-passphrase";
beforeAll(async () => {
await createUser(EMAIL, PASSWORD, "manager");
});
afterEach(() => {
vi.useRealTimers();
});
describe("вход по сессии", () => {
it("неверный пароль: 401 и никакой cookie", async () => {
const { res, cookie } = await login(app, EMAIL, "wrong-password");
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "invalid_credentials" });
expect(cookie).toBeUndefined();
});
it("неизвестный email: тот же ответ, что и при неверном пароле", async () => {
const { res } = await login(app, "nobody@example.com", PASSWORD);
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "invalid_credentials" });
});
it("без cookie защищённый маршрут отвечает 401", async () => {
const res = await request(app).get("/me").set(SAME_ORIGIN);
expect(res.status).toBe(401);
});
it("успешный вход ставит защищённую cookie и открывает /me", async () => {
const { res, cookie } = await login(app, EMAIL, PASSWORD);
expect(res.status).toBe(204);
const setCookie = res.get("Set-Cookie")!.join("; ");
expect(setCookie).toMatch(/^__Host-sid=/);
expect(setCookie).toMatch(/; Path=\//);
expect(setCookie).toMatch(/; HttpOnly/);
expect(setCookie).toMatch(/; Secure/);
expect(setCookie).toMatch(/; SameSite=Lax/);
const me = await request(app).get("/me").set(SAME_ORIGIN).set("Cookie", cookie!);
expect(me.status).toBe(200);
expect(me.body).toMatchObject({ email: EMAIL, role: "manager" });
});
it("вход выдаёт новый ID сессии, старый перестаёт работать", async () => {
const first = await login(app, EMAIL, PASSWORD);
const res = await request(app)
.post("/login").set(SAME_ORIGIN).set("Cookie", first.cookie!)
.send({ email: EMAIL, password: PASSWORD });
const second = sessionCookie(res);
expect(second).toBeDefined();
expect(second).not.toBe(first.cookie);
const old = await request(app).get("/me").set(SAME_ORIGIN).set("Cookie", first.cookie!);
expect(old.status).toBe(401);
});
it("после выхода старая cookie больше не работает", async () => {
const { cookie } = await login(app, EMAIL, PASSWORD);
const out = await request(app).post("/logout").set(SAME_ORIGIN).set("Cookie", cookie!);
expect(out.status).toBe(204);
const me = await request(app).get("/me").set(SAME_ORIGIN).set("Cookie", cookie!);
expect(me.status).toBe(401);
});
it("POST с чужого сайта отклоняется, сессия остаётся", async () => {
const { cookie } = await login(app, EMAIL, PASSWORD);
const out = await request(app)
.post("/logout")
.set({ ...SAME_ORIGIN, "Sec-Fetch-Site": "cross-site" })
.set("Cookie", cookie!);
expect(out.status).toBe(403);
const me = await request(app).get("/me").set(SAME_ORIGIN).set("Cookie", cookie!);
expect(me.status).toBe(200);
});
it("сессия заканчивается через 8 часов даже при активности", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
const start = Date.now();
const { cookie } = await login(app, EMAIL, PASSWORD);
const step = 20 * 60 * 1000; // запрос каждые 20 минут — idle-тайм-аут не срабатывает
for (let i = 1; i <= 24; i++) {
vi.setSystemTime(start + i * step);
const me = await request(app).get("/me").set(SAME_ORIGIN).set("Cookie", cookie!);
expect(me.status).toBe(200);
}
vi.setSystemTime(start + 25 * step); // 8 ч 20 мин после входа
const late = await request(app).get("/me").set(SAME_ORIGIN).set("Cookie", cookie!);
expect(late.status).toBe(401);
});
});We verified every test the other way round: we removed session.destroy from logout, regenerate from login, the CSRF check, the absolute timeout and the Secure flag — and each time exactly the test responsible for it failed. A test that doesn’t fail on broken code protects nothing.
The JWT variant is for a mobile app or another service that sends the token in the Authorization header. The module below issues a 10-minute access token and verifies it by the rules from the JWT section. On failure it answers 401 with a WWW-Authenticate header, as RFC 9110 requires (the Bearer scheme comes from RFC 6750). The IANA registry has no scheme for cookie-based login, so our session 401 responses go without that header — a deliberate deviation worth documenting in your API docs.
import type { NextFunction, Request, Response } from "express";
import { errors, jwtVerify, SignJWT, type JWTPayload } from "jose";
import { randomUUID } from "node:crypto";
declare global {
namespace Express {
interface Locals {
tokenSubject?: string;
}
}
}
export const ALG = "HS256";
export const ISSUER = "https://api.example.com";
export const AUDIENCE = "https://api.example.com/v1";
export const TYP = "app-access+jwt"; // явный тип токена (RFC 8725, §3.11)
export function loadJwtSecret(hex: string | undefined): Uint8Array {
// HS256: ключ не короче 256 бит из криптостойкого генератора, а не пароль
if (!hex || !/^(?:[0-9a-f]{2}){32,}$/i.test(hex)) throw new Error("JWT_SECRET must be at least 32 random bytes in hex");
return Buffer.from(hex, "hex");
}
export async function issueAccessToken(userId: string, secret: Uint8Array): Promise<string> {
return new SignJWT()
.setProtectedHeader({ alg: ALG, typ: TYP })
.setSubject(userId)
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setIssuedAt()
.setExpirationTime("10m") // короткая жизнь: отозвать JWT до exp сложно
.setJti(randomUUID())
.sign(secret);
}
export async function verifyAccessToken(token: string, secret: Uint8Array): Promise<JWTPayload> {
const { payload } = await jwtVerify(token, secret, {
algorithms: [ALG], // только ожидаемый алгоритм
issuer: ISSUER,
audience: AUDIENCE,
typ: TYP,
requiredClaims: ["sub", "exp", "jti"],
});
return payload;
}
export function requireBearer(secret: Uint8Array) {
return async (req: Request, res: Response, next: NextFunction) => {
const match = /^Bearer (\S+)$/i.exec(req.get("Authorization") ?? "");
if (!match) {
res.set("WWW-Authenticate", "Bearer").status(401).json({ error: "unauthenticated" });
return;
}
try {
const payload = await verifyAccessToken(match[1], secret);
res.locals.tokenSubject = payload.sub;
next();
} catch (err) {
if (!(err instanceof errors.JOSEError)) throw err;
res.set("WWW-Authenticate", 'Bearer error="invalid_token"').status(401).json({ error: "invalid_token" });
}
};
}import express from "express";
import { errors, SignJWT, UnsecuredJWT } from "jose";
import { randomBytes } from "node:crypto";
import request from "supertest";
import { describe, expect, it } from "vitest";
import {
ALG, AUDIENCE, ISSUER, TYP,
issueAccessToken, loadJwtSecret, requireBearer, verifyAccessToken,
} from "../src/tokens.ts";
const secret = loadJwtSecret(randomBytes(32).toString("hex"));
const app = express();
app.get("/api/me", requireBearer(secret), (req, res) => {
res.json({ sub: res.locals.tokenSubject });
});
describe("JWT access-токен", () => {
it("валидный токен открывает маршрут", async () => {
const token = await issueAccessToken("user-42", secret);
const res = await request(app).get("/api/me").set("Authorization", `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ sub: "user-42" });
});
it("без токена: 401 и заголовок WWW-Authenticate", async () => {
const res = await request(app).get("/api/me");
expect(res.status).toBe(401);
expect(res.get("WWW-Authenticate")).toBe("Bearer");
});
it("подменённый payload не проходит проверку подписи", async () => {
const [header, , signature] = (await issueAccessToken("user-42", secret)).split(".");
const payload = Buffer.from(JSON.stringify({ sub: "admin" })).toString("base64url");
const forged = `${header}.${payload}.${signature}`;
await expect(verifyAccessToken(forged, secret)).rejects.toThrow(errors.JWSSignatureVerificationFailed);
const res = await request(app).get("/api/me").set("Authorization", `Bearer ${forged}`);
expect(res.status).toBe(401);
expect(res.get("WWW-Authenticate")).toBe('Bearer error="invalid_token"');
});
it('токен с alg "none" отклоняется', async () => {
const unsigned = new UnsecuredJWT({})
.setSubject("admin").setIssuer(ISSUER).setAudience(AUDIENCE).setExpirationTime("10m").encode();
await expect(verifyAccessToken(unsigned, secret)).rejects.toThrow(errors.JOSEError);
});
it("просроченный токен отклоняется", async () => {
const hourAgo = Math.floor(Date.now() / 1000) - 3600;
const expired = await new SignJWT()
.setProtectedHeader({ alg: ALG, typ: TYP })
.setSubject("user-42").setIssuer(ISSUER).setAudience(AUDIENCE).setJti("t-1")
.setIssuedAt(hourAgo).setExpirationTime(hourAgo + 600)
.sign(secret);
await expect(verifyAccessToken(expired, secret)).rejects.toThrow(errors.JWTExpired);
});
it("токен для другого сервиса (aud) отклоняется", async () => {
const foreign = await new SignJWT()
.setProtectedHeader({ alg: ALG, typ: TYP })
.setSubject("user-42").setIssuer(ISSUER).setAudience("https://billing.example.com").setJti("t-2")
.setIssuedAt().setExpirationTime("10m")
.sign(secret);
await expect(verifyAccessToken(foreign, secret)).rejects.toThrow(errors.JWTClaimValidationFailed);
});
});$ npx vitest run test/
RUN v5.0.3 ~/auth-demo
Test Files 2 passed (2)
Tests 14 passed (14)What is missing for production, based on OWASP and the Express documentation:
- Login throttling: the Express docs suggest counting failures per username + IP and per IP over a long period (for example with
rate-limiter-flexible); OWASP suggests lockout tied to the account, not just the IP - A session store in Redis or a database instead of
MemoryStore; compatible stores are listed in theexpress-sessionREADME - Session secret rotation:
secretaccepts an array, the new key goes first and the old ones still verify issued cookies - HTTPS end to end and a
trust proxyvalue that matches the real proxy setup - Password rules: per OWASP, citing NIST SP 800-63B, passwords shorter than 15 characters without MFA are weak, and the maximum length should be at least 64 characters
- MFA and re-authentication before sensitive actions: changing the email, password or payment details
- A new session ID on every privilege change, not only at login
- An audit log of logins, failures, logouts and lockouts — without passwords or session IDs
How this was verified5 · 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.