Как делать11 октября 2026 г.12 мин чтения
Backend на Node.jsШаг 5 из 8

Аутентификация: сессии, cookies и JWT

Коротко

Для сайта с собственным 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 г.

Содержание
  1. 01Аутентификация и авторизация: в чём разница
  2. 02Сессия на сервере и cookie
  3. 03Атрибуты cookie: HttpOnly, Secure, SameSite и срок жизни
  4. 04JWT: структура, подпись и проверка
  5. 05Где хранить токен, refresh и отзыв
  6. 06CSRF: почему cookie нужно защищать от чужих сайтов
  7. 07Пример: вход на Express 5 с Argon2id и сессией
  8. 08Проверка тестами, вариант с JWT и что добавить для продакшна

Аутентификация и авторизация: в чём разница

Аутентификация отвечает на вопрос «кто это?»: пользователь доказывает, что он владелец аккаунта, — паролем, одноразовым кодом, ключом доступа. Авторизация отвечает на вопрос «что ему можно?»: видеть ли эту заявку, удалять ли пользователя. Это два разных шага, и ошибка в любом из них ведёт к утечке. Этот урок — про первый шаг, проверке прав посвящён следующий урок, «Авторизация: роли, права и проверка доступа на сервере».

HTTP не хранит состояние между запросами: сервер не помнит, что минуту назад этот же браузер ввёл верный пароль. Поэтому после входа клиент получает что-то, что будет предъявлять в каждом следующем запросе. Распространены два варианта: серверная сессия, когда в cookie лежит только случайный идентификатор, а данные хранятся на сервере, и самодостаточный токен, чаще всего JWT (JSON Web Token), в котором данные о пользователе подписаны и передаются вместе с запросом.

Сессия на сервере и cookie

Классическая схема хорошо понятна и легко отзывается. Сервер хранит запись сессии в памяти, Redis или базе данных, а браузер — только ключ к ней. Вот что происходит от ввода пароля до выхода:

  1. Проверка пароля

    Сервер ищет пользователя и сравнивает пароль с хешем Argon2id. На «нет такого email» и «неверный пароль» ответ одинаковый, чтобы нельзя было перебором узнать, кто зарегистрирован.

  2. Новый идентификатор сессии

    После успешного входа ID выдаётся заново. Иначе работает фиксация сессии (session fixation): злоумышленник заранее подсовывает жертве известный ему ID и после её входа оказывается внутри.

  3. Cookie с идентификатором

    Ответ содержит заголовок Set-Cookie со случайным значением. OWASP требует не меньше 64 бит энтропии из криптостойкого генератора; express-session генерирует ID сам.

  4. Каждый следующий запрос

    Браузер сам прикладывает cookie, сервер находит запись, проверяет тайм-ауты и узнаёт пользователя.

  5. Выход

    Запись сессии удаляется на сервере, 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

Что делает
Без Domain cookie получает только хост, который её поставил, без поддоменов
Что ставить для сессии
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:

bash
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
Зависимости; версии, на которых проверен пример, — в шапке урока
tsconfig.json
{
  "compilerOptions": {
    "noEmit": true,
    "target": "esnext",
    "module": "nodenext",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "strict": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
Настройки из документации Node.js для запуска .ts без сборки; tsc только проверяет типы

Пароли хешируем алгоритмом Argon2id — его OWASP Password Storage Cheat Sheet ставит первым. Минимальная конфигурация по OWASP — 19 MiB памяти, 2 прохода, 1 поток; параметры по умолчанию в пакете argon2 (64 MiB, 3 прохода, 4 потока) её превышают и записываются прямо в строку хеша. Bcrypt OWASP оставляет для старых систем: у него ограничение длины пароля в 72 байта. Подбор параметров, перехеширование и миграцию со старых алгоритмов разберём в отдельном рецепте о хранении паролей.

src/users.ts
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;
}
Пользователи и проверка пароля. Хеш-заглушка выравнивает время ответа для несуществующего email

Приложение собирает всё, о чём шла речь выше: проверку источника запроса, сессию с безопасной cookie, новый ID после входа, абсолютный тайм-аут и выход с удалением сессии на сервере. Тайм-аут бездействия даёт rolling: true вместе с maxAge, абсолютный проверяет requireAuth по времени входа.

src/app.ts
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;
}
Приложение: CSRF-проверка, сессия, вход, профиль и выход

app.set("trust proxy", 1) означает, что перед приложением стоит один обратный прокси, который принимает HTTPS и передаёт протокол в X-Forwarded-Proto. Документация Express предупреждает: настройка должна совпадать с реальной схемой развёртывания, иначе клиент сможет подставить эти заголовки сам. Если приложение смотрит в интернет напрямую, trust proxy включать нельзя.

src/server.ts
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}`));
Запуск: секрет только из переменной окружения
bash
npx tsc -p .
export SESSION_SECRET=$(node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))")
PORT=3917 node src/server.ts
Проверка типов и запуск сервера
terminal
$ 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
Ручная проверка через curl, вывод сокращён. X-Forwarded-Proto имитирует HTTPS-прокси; запрос без Origin отклонён CSRF-проверкой

Частая ловушка: вход отвечает 204, но заголовка Set-Cookie нет. Так express-session ведёт себя, когда cookie помечена Secure, а запрос пришёл по обычному HTTP, — мы воспроизвели это, отправив тот же запрос без X-Forwarded-Proto. Проверьте HTTPS на прокси и настройку trust proxy, а не отключайте Secure.

Проверка тестами, вариант с JWT и что добавить для продакшна

Тесты на supertest отправляют запросы прямо в приложение Express без запуска сервера. Помощник имитирует браузер на своём сайте за HTTPS-прокси и достаёт сессионную cookie из ответа. Cookie передаём вручную, а не через agent: так тест заодно проверяет её атрибуты.

test/helpers.ts
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) };
}
Общие помощники для тестов
test/auth.test.ts
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);
  });
});
Неверный пароль, доступ без cookie, атрибуты cookie, новый ID после входа, выход, CSRF и абсолютный тайм-аут

Каждый тест мы проверили от обратного: убрали session.destroy из выхода, regenerate из входа, CSRF-проверку, абсолютный тайм-аут и флаг Secure — и каждый раз падал ровно тот тест, который за это отвечает. Тест, который не падает на сломанном коде, ничего не защищает.

Вариант с JWT нужен, когда клиент — мобильное приложение или другой сервис и токен приходит в заголовке Authorization. Модуль ниже выдаёт access-токен на 10 минут и проверяет его по правилам из раздела о JWT. При отказе он отвечает 401 с заголовком WWW-Authenticate, как требует RFC 9110 (схема Bearer — из RFC 6750). Для входа по cookie зарегистрированной схемы в реестре IANA нет, поэтому наши 401 для сессии идут без этого заголовка — это осознанное отступление, которое стоит описать в документации API.

src/tokens.ts
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" });
    }
  };
}
JWT на библиотеке jose: выпуск, строгая проверка и middleware для Bearer
test/tokens.test.ts
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);
  });
});
Подделанная подпись, alg none, истёкший срок и чужой aud отклоняются
terminal
$ npx vitest run test/
 RUN  v5.0.3 ~/auth-demo

 Test Files  2 passed (2)
      Tests  14 passed (14)
Все 14 тестов проходят

Чего не хватает до продакшна. Рекомендации OWASP и документации Express:

  • Ограничение попыток входа: документация Express предлагает считать неудачи по паре «аккаунт + IP» и по IP за длительный период (например, пакетом rate-limiter-flexible); OWASP — блокировку с привязкой к аккаунту, а не только к IP
  • Хранилище сессий в Redis или базе данных вместо MemoryStore; список совместимых хранилищ — в README express-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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.