Для сайта с собственным backend надёжнее всего серверная сессия: после проверки пароля сервер создаёт запись, а браузер получает случайный идентификатор в cookie с атрибутами HttpOnly, Secure и SameSite. JWT удобен, когда токен проверяют другие сервисы или мобильный клиент, но его трудно отозвать до истечения срока, поэтому живёт он минуты, а для выхода нужен refresh-токен или список отозванных. Токены не хранят в localStorage, а запросы с cookie защищают от CSRF.
После материала вы сможете
- сравнить серверные сессии и JWT по отзыву, хранению и рискам и выбрать подходящий вариант для проекта
- настроить сессионную cookie с
HttpOnly,Secure,SameSite, префиксом__Host-и тайм-аутами - проверить JWT на сервере: разрешённый
alg, подпись,exp,iss,audи тип токена - написать тесты на supertest: неверный пароль, доступ без cookie, выход и запрос с чужого сайта
Что знать заранее
Проверено на
Node.js 24 LTS (24.18)Express 5.3express-session 1.19argon2 0.45jose 6.2TypeScript 7.0Vitest 5.0supertest 7.3
Сверено с документацией: 11 октября 2026 г.
Содержание
- Аутентификация и авторизация: в чём разница
- Сессия на сервере и cookie
- Атрибуты cookie: HttpOnly, Secure, SameSite и срок жизни
- JWT: структура, подпись и проверка
- Где хранить токен, refresh и отзыв
- CSRF: почему cookie нужно защищать от чужих сайтов
- Пример: вход на Express 5 с Argon2id и сессией
- Проверка тестами, вариант с JWT и что добавить для продакшна
Аутентификация и авторизация: в чём разница
Аутентификация отвечает на вопрос «кто это?»: пользователь доказывает, что он владелец аккаунта, — паролем, одноразовым кодом, ключом доступа. Авторизация отвечает на вопрос «что ему можно?»: видеть ли эту заявку, удалять ли пользователя. Это два разных шага, и ошибка в любом из них ведёт к утечке. Этот урок — про первый шаг, проверке прав посвящён следующий урок, «Авторизация: роли, права и проверка доступа на сервере».
HTTP не хранит состояние между запросами: сервер не помнит, что минуту назад этот же браузер ввёл верный пароль. Поэтому после входа клиент получает что-то, что будет предъявлять в каждом следующем запросе. Распространены два варианта: серверная сессия, когда в cookie лежит только случайный идентификатор, а данные хранятся на сервере, и самодостаточный токен, чаще всего JWT (JSON Web Token), в котором данные о пользователе подписаны и передаются вместе с запросом.
Сессия на сервере и cookie
Классическая схема хорошо понятна и легко отзывается. Сервер хранит запись сессии в памяти, Redis или базе данных, а браузер — только ключ к ней. Вот что происходит от ввода пароля до выхода:
Проверка пароля
Сервер ищет пользователя и сравнивает пароль с хешем Argon2id. На «нет такого email» и «неверный пароль» ответ одинаковый, чтобы нельзя было перебором узнать, кто зарегистрирован.
Новый идентификатор сессии
После успешного входа ID выдаётся заново. Иначе работает фиксация сессии (session fixation): злоумышленник заранее подсовывает жертве известный ему ID и после её входа оказывается внутри.
Cookie с идентификатором
Ответ содержит заголовок
Set-Cookieсо случайным значением. OWASP требует не меньше 64 бит энтропии из криптостойкого генератора;express-sessionгенерирует ID сам.Каждый следующий запрос
Браузер сам прикладывает cookie, сервер находит запись, проверяет тайм-ауты и узнаёт пользователя.
Выход
Запись сессии удаляется на сервере, cookie стирается в браузере. После этого даже украденная копия идентификатора бесполезна.
У сессии два срока. Тайм-аут бездействия (idle timeout) закрывает её, если пользователь давно ничего не делал; абсолютный тайм-аут — через фиксированное время после входа, даже при постоянной активности. OWASP приводит ориентиры: 15–30 минут бездействия для приложений с низким риском и 2–5 минут для особо ценных данных, абсолютный срок для рабочего инструмента офиса — 4–8 часов. Точные значения зависят от того, что защищает приложение.
Атрибуты cookie: HttpOnly, Secure, SameSite и срок жизни
Сессия защищена ровно настолько, насколько защищена её cookie. Атрибуты задаются в заголовке Set-Cookie, и для сессионной cookie набор почти всегда один и тот же:
HttpOnly
- Что делает
- Запрещает JavaScript читать cookie через
document.cookie. Запросы черезfetchеё по-прежнему отправляют - Что ставить для сессии
- Всегда: так XSS не вытащит идентификатор
Secure
- Что делает
- Cookie уходит только по HTTPS. Браузеры делают исключение для
localhost - Что ставить для сессии
- Всегда
SameSite
- Что делает
Strict— только запросы со своего сайта;Lax— плюс переходы по ссылкам с чужих сайтов безопасными методами;None— всегда и только вместе сSecure- Что ставить для сессии
StrictилиLax, явно: значение по умолчанию у браузеров отличается
Expires / Max-Age
- Что делает
- Срок жизни. Без них cookie «сеансовая», но браузеры с восстановлением вкладок возвращают и её
- Что ставить для сессии
- Срок плюс обязательная проверка тайм-аутов на сервере
Domain / Path
- Что делает
- Без
Domaincookie получает только хост, который её поставил, без поддоменов - Что ставить для сессии
Path=/, безDomain
Префикс __Host-
- Что делает
- Браузер примет cookie, только если есть
Secure,Path=/и нетDomain - Что ставить для сессии
- Рекомендован OWASP для идентификатора сессии
Выбор между Strict и Lax — инженерный компромисс. Strict не отправит cookie, даже когда пользователь переходит на ваш сайт по ссылке из письма: он увидит страницу как гость, хотя вошёл. Lax отправит cookie при таком переходе, но не при POST-запросе с чужого сайта. Для личного кабинета обычно хватает Lax, для банковских операций разумнее Strict. В обоих случаях SameSite — дополнительный слой, а не полноценная защита от CSRF; об этом ниже.
JWT: структура, подпись и проверка
RFC 7519 описывает JWT как компактный способ передать набор утверждений (claims) в формате JSON. Подписанный токен состоит из трёх частей через точку: заголовок, полезная нагрузка и подпись, каждая закодирована в base64url. Подпись — HMAC с общим секретом (например, HS256) или цифровая подпись закрытым ключом (ES256, EdDSA) — гарантирует, что содержимое не меняли. Но base64url — это не шифрование: любой, у кого есть токен, прочитает все claims. Паспортные данные и телефоны в JWT не кладут.
sub— кому выдан токен (ID пользователя)iss— кто выдал;aud— для какого сервиса предназначенexp— момент, начиная с которого токен принимать нельзя;iat— когда выданjti— уникальный ID токена: нужен для журнала и списка отозванных
Большинство атак на JWT — не взлом криптографии, а слишком доверчивая проверка. RFC 8725 и OWASP JSON Web Token Cheat Sheet сводят её к нескольким правилам:
- Явно перечислите разрешённые алгоритмы. Токен с
"alg": "none"не принимается никогда, а HMAC и подписи открытым ключом не смешиваются — иначе возможна подмена алгоритма (algorithm confusion) - Проверяйте
expи обязательность нужных claims: вjoseдля этого есть опцияrequiredClaims - Сверяйте
issиaud: токен, выданный для другого сервиса того же издателя, должен отклоняться - Задайте явный тип в заголовке
typи проверяйте его, чтобы токен сброса пароля нельзя было предъявить вместо токена доступа (RFC 8725, §3.11) - Секрет для
HS256— не меньше 256 бит из криптостойкого генератора, а не пароль и не строка в репозитории - Не берите ключ проверки из самого токена (
jwk,jku,x5u): заголовок контролирует атакующий
Где хранить токен, refresh и отзыв
Самое частое решение в учебных примерах — положить JWT в localStorage — OWASP прямо запрещает. Хранилища localStorage и sessionStorage доступны любому скрипту на странице, поэтому одна XSS-уязвимость раскрывает все токены сразу. Рекомендация — cookie с HttpOnly; Secure; SameSite=Strict или схема BFF (Backend-for-Frontend), при которой токены хранит ваш сервер, а браузер получает обычную сессию.
Где состояние
- Серверная сессия
- В хранилище сессий на сервере
- JWT
- В самом токене; сервер проверяет подпись
Выход и блокировка
- Серверная сессия
- Удалить запись — доступ закрыт сразу
- JWT
- Токен действует до
exp; нужен список отозванных или короткий срок
Что видит клиент
- Серверная сессия
- Случайную строку без смысла
- JWT
- Все claims в открытом виде
Проверка другими сервисами
- Серверная сессия
- Нужен доступ к общему хранилищу
- JWT
- Достаточно ключа проверки
Типичный клиент
- Серверная сессия
- Сайт и веб-приложение с собственным backend
- JWT
- Мобильное приложение, обмен между сервисами, OAuth / OpenID Connect
Из-за сложности отзыва access-токен делают короткоживущим — минуты, а для продления выдают refresh-токен. RFC 9700 (текущие рекомендации по безопасности OAuth 2.0) требует для публичных клиентов, то есть приложений без защищённого секрета, обнаруживать повторное использование refresh-токена. Один из способов — ротация: при каждом обновлении выдаётся новый refresh-токен, старый становится недействительным, а попытка предъявить старый ещё раз приводит к отзыву всей цепочки. Это сигнал, что токен украден.
Если отзывать access-токены всё же нужно (выход, смена пароля, блокировка), OWASP предлагает список отозванных по jti и iss с хранением до истечения exp. Ключом не должен быть сам токен или его хеш: из-за неоднозначности кодирования атакующий может получить другую строку с той же действительной подписью. Для издателей, обслуживающих много сервисов, существует стандартизованный механизм Token Status List.
CSRF: почему cookie нужно защищать от чужих сайтов
Браузер прикладывает cookie к запросам на ваш сайт автоматически — даже если запрос отправила страница злоумышленника. На этом построен CSRF (Cross-Site Request Forgery): скрытая форма на чужом сайте отправляет POST на ваш /transfer, и браузер вошедшего пользователя добавляет сессионную cookie. Заголовок Authorization: Bearer браузер сам не добавляет, поэтому CSRF — прежде всего проблема аутентификации через cookie.
- Встроенная защита фреймворка, если она есть, — первый выбор по OWASP
- CSRF-токены: синхронизирующий токен (synchronizer token) для приложений с сессиями, подписанная двойная cookie (signed double-submit) — без серверного состояния
- Заголовки Fetch Metadata: по
Sec-Fetch-Siteсервер видит, пришёл запрос со своего сайта (same-origin) или с чужого (cross-site). OWASP отмечает поддержку во всех основных браузерах с марта 2023 года и требует запасную проверкуOriginдля клиентов без этих заголовков SameSiteу сессионной cookie — дополнительный слой, но не единственная защита: у него есть ограничения, например общий домен с чужими поддоменами- Никаких изменений данных по GET:
SameSite=Laxпропускает GET-переходы с чужих сайтов
Отдельный случай — CSRF на форме входа: злоумышленник незаметно авторизует жертву в своём аккаунте, и та вводит туда данные карты. Поэтому проверка источника запроса в нашем примере стоит перед всеми изменяющими маршрутами, включая /login.
Пример: вход на Express 5 с Argon2id и сессией
Нужен Node.js 24: он запускает TypeScript-файлы напрямую, отбрасывая типы (type stripping), поэтому сборка не требуется. Проект — обычная папка с 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"]
}Пароли хешируем алгоритмом Argon2id — его OWASP Password Storage Cheat Sheet ставит первым. Минимальная конфигурация по OWASP — 19 MiB памяти, 2 прохода, 1 поток; параметры по умолчанию в пакете argon2 (64 MiB, 3 прохода, 4 потока) её превышают и записываются прямо в строку хеша. Bcrypt OWASP оставляет для старых систем: у него ограничение длины пароля в 72 байта. Подбор параметров, перехеширование и миграцию со старых алгоритмов разберём в отдельном рецепте о хранении паролей.
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;
}Приложение собирает всё, о чём шла речь выше: проверку источника запроса, сессию с безопасной cookie, новый ID после входа, абсолютный тайм-аут и выход с удалением сессии на сервере. Тайм-аут бездействия даёт rolling: true вместе с maxAge, абсолютный проверяет requireAuth по времени входа.
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) означает, что перед приложением стоит один обратный прокси, который принимает HTTPS и передаёт протокол в X-Forwarded-Proto. Документация Express предупреждает: настройка должна совпадать с реальной схемой развёртывания, иначе клиент сможет подставить эти заголовки сам. Если приложение смотрит в интернет напрямую, 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 ForbiddenЧастая ловушка: вход отвечает 204, но заголовка Set-Cookie нет. Так express-session ведёт себя, когда cookie помечена Secure, а запрос пришёл по обычному HTTP, — мы воспроизвели это, отправив тот же запрос без X-Forwarded-Proto. Проверьте HTTPS на прокси и настройку trust proxy, а не отключайте Secure.
Проверка тестами, вариант с JWT и что добавить для продакшна
Тесты на supertest отправляют запросы прямо в приложение Express без запуска сервера. Помощник имитирует браузер на своём сайте за HTTPS-прокси и достаёт сессионную cookie из ответа. Cookie передаём вручную, а не через agent: так тест заодно проверяет её атрибуты.
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);
});
});Каждый тест мы проверили от обратного: убрали session.destroy из выхода, regenerate из входа, CSRF-проверку, абсолютный тайм-аут и флаг Secure — и каждый раз падал ровно тот тест, который за это отвечает. Тест, который не падает на сломанном коде, ничего не защищает.
Вариант с JWT нужен, когда клиент — мобильное приложение или другой сервис и токен приходит в заголовке Authorization. Модуль ниже выдаёт access-токен на 10 минут и проверяет его по правилам из раздела о JWT. При отказе он отвечает 401 с заголовком WWW-Authenticate, как требует RFC 9110 (схема Bearer — из RFC 6750). Для входа по cookie зарегистрированной схемы в реестре IANA нет, поэтому наши 401 для сессии идут без этого заголовка — это осознанное отступление, которое стоит описать в документации API.
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)Чего не хватает до продакшна. Рекомендации OWASP и документации Express:
- Ограничение попыток входа: документация Express предлагает считать неудачи по паре «аккаунт + IP» и по IP за длительный период (например, пакетом
rate-limiter-flexible); OWASP — блокировку с привязкой к аккаунту, а не только к IP - Хранилище сессий в Redis или базе данных вместо
MemoryStore; список совместимых хранилищ — в READMEexpress-session - Ротация секрета сессии:
secretпринимает массив, новый ключ ставится первым, старые проверяют уже выданные cookie - HTTPS на всём пути и
trust proxy, совпадающий с реальной схемой прокси - Требования к паролю: по OWASP со ссылкой на NIST SP 800-63B пароль короче 15 символов без MFA считается слабым, максимальная длина — не меньше 64 символов
- MFA и повторный ввод пароля перед чувствительными действиями: смена email, пароля, платёжных данных
- Новый ID сессии при любом изменении привилегий, а не только при входе
- Журнал событий: входы, неудачи, выходы, блокировки — без паролей и идентификаторов сессий
Как проверено5 · пройденосверено 11 октября 2026 г.
- запущено, работает
src/users.ts, src/app.ts, src/tokens.ts, src/server.ts, test/helpers.ts, test/auth.test.ts, test/tokens.test.ts, tsconfig.json
временный проект в scratchpad: tsc -p . (TypeScript 7.0.2) без ошибок; vitest run test/ — 14/14 (auth 8, tokens 6); Node.js 24.18.0, Express 5.3.0, express-session 1.19.0, argon2 0.45.1 (prebuilt win32-x64, install-скрипт заблокирован npm 11.16 allow-scripts — пакет работает), jose 6.2.12, Vitest 5.0.3, supertest 7.3.1
- запущено, работает
проверка тестов «от обратного» (мутации)
по одной мутации: без session.destroy в /logout, без regenerate в /login, без rejectCrossSite, без абсолютного тайм-аута, без audience в jwtVerify, secure:false — каждый раз падал ровно 1 профильный тест (13 passed | 1 failed); после отката 14/14
- запущено, работает
node src/server.ts + curl (вывод в статье)
запуск через встроенный type stripping Node 24 на порту 3917: /login c X-Forwarded-Proto: https → 204 и Set-Cookie __Host-sid … HttpOnly; Secure; SameSite=Lax; /me с cookie → 200 JSON; POST /logout без Origin → 403; тот же /login без X-Forwarded-Proto → 204 без Set-Cookie (ловушка, описанная в статье)
- запущено, работает
тайм-аут бездействия (rolling + maxAge 30 мин), утверждение в тексте
отдельный временный тест с vi.useFakeTimers({ toFake: ['Date'] }): запрос через 29 мин после входа — 200, ещё через 31 мин без запросов — 401; в статью тест не вынесен
- запущено, работает
решения упражнений: revokeAccessToken + exercise/revoke.test.ts; SameSite=Strict
revoke: tsc без ошибок, тест проходит (15/15 вместе с основными); strict: замена в app.ts (2 места) и в регулярке теста — 15/15. Упражнение «сломайте выход» совпадает с мутацией №1
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.