Опирается на правила:
AUTH-20,AUTH-21из Auth Patterns Rules → раздел 9. Хранение токенов на клиенте.
Важно знать
- HttpOnly + Secure + SameSite=Lax cookie — единственный безопасный вариант хранения токенов в браузере.
localStorageзапрещён — уязвим к XSS: любой скрипт на странице (включая npm-зависимости) читает его содержимое.- Refresh-токены с rotation — при каждом обновлении старый токен инвалидируется.
- Повторное использование старого refresh-token = признак компрометации → инвалидируется вся цепочка пользователя.
- BFF pattern: NestJS-сервер хранит токены, клиент работает с session-cookie и никогда не видит JWT.
SameSite=Lax— защищает от CSRF на cross-site POST при приемлемом UX.Secure— cookie передаётся только по HTTPS.HttpOnly— cookie недоступна из JavaScript,document.cookieеё не возвращает.
Раздел важен для backend-команды: NestJS-контроллер настраивает Set-Cookie, refresh-endpoint, CORS — ошибка здесь немедленно влияет на безопасность всего фронтенда.
localStorage — запрещён
AUTH-20: классическая точка уязвимости SPA.
// ЗАПРЕЩЕНО — JWT в localStorage
localStorage.setItem('access_token', accessToken);
Что ломается:
- Любой
<script>на странице — сторонний CDN, npm-пакет с вредоносным кодом, XSS-инъекция — читаетlocalStorageбез ограничений. - Один скомпрометированный пакет в
node_modulesили одна XSS — все токены уходят на сервер атакёра. localStorageперсистентен: токен живёт в браузере после закрытия вкладки и между сессиями.
Правильно — HttpOnly cookie, выставляемая сервером через Set-Cookie.
HttpOnly + Secure + SameSite=Lax
AUTH-20: тройка атрибутов обязательна. В NestJS Set-Cookie выставляется через Response из @nestjs/common или стандартный res из express/fastify.
// adapters/in/http/auth/auth.controller.ts
import { Controller, Post, Body, Res } from '@nestjs/common';
import { Response } from 'express';
import { Public } from '../security/public.decorator';
import { LoginCommand } from '../../../../core/order/commands/login.command';
import { AuthApplicationService } from '../../../../core/auth/auth-application.service';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthApplicationService) {}
@Public()
@Post('login')
async login(
@Body() cmd: LoginCommand,
@Res({ passthrough: true }) res: Response,
): Promise<void> {
const tokens = await this.authService.login(cmd);
res.cookie('access_token', tokens.accessToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 15 * 60 * 1000, // 15 минут, AUTH-20
});
res.cookie('refresh_token', tokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/auth/refresh', // только refresh-endpoint
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней
});
}
}
Что даёт каждый атрибут:
| Атрибут | Защита |
|---|---|
httpOnly: true | JavaScript не может читать cookie. Защита от XSS. |
secure: true | Cookie отправляется только по HTTPS. Защита от перехвата в сети. |
sameSite: 'lax' | Cookie не отправляется на cross-site POST (CSRF protection). На cross-site GET — отправляется (приемлемый trade-off для UX). |
path: '/auth/refresh' | Refresh-cookie доступна только refresh-endpoint, не прикладывается к каждому API-запросу. |
maxAge | Авто-удаление после N миллисекунд. Без него — session cookie, которая живёт до закрытия браузера. |
sameSite: 'strict' строже, но ломает сценарий «открыл ссылку из email — попал на страницу неаутентифицированным». Для большинства сервисов 'lax' — правильный выбор.
Стратегия чтения cookie в passport-jwt
Когда токен хранится в cookie, а не в Authorization: Bearer заголовке, JwtStrategy нужно переключить источник извлечения:
// adapters/in/http/security/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';
import { Request } from 'express';
import { AppConfig } from '../../../../config/app.config';
import { Principal } from './principal';
import { extractRoles } from './roles.util';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: AppConfig) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(req: Request) => req?.cookies?.['access_token'] ?? null,
ExtractJwt.fromAuthHeaderAsBearerToken(), // s2s остаётся на Bearer
]),
algorithms: ['RS256'],
audience: config.auth.audience,
issuer: config.auth.issuer,
secretOrKeyProvider: passportJwtSecret({
jwksUri: config.auth.jwksUri,
cache: true,
cacheMaxAge: 300_000, // AUTH-5: кеш JWK ~5 мин
rateLimit: true,
}),
});
}
validate(claims: Record<string, unknown>): Principal {
return { sub: claims['sub'] as string, roles: extractRoles(claims) };
}
}
Порядок extractors важен: сначала cookie (SPA), потом Authorization (s2s, CLI). NestJS при cookie-режиме требует включить cookie-parser:
// main.ts
import * as cookieParser from 'cookie-parser';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(cookieParser());
await app.listen(3000);
}
BFF pattern
Альтернатива JWT-в-cookie — NestJS-BFF хранит токены на стороне сервера, клиент работает с session-cookie:
Browser (cookie: SESSION=abc123)
↓
NestJS BFF (session store: abc123 → { accessToken: ..., refreshToken: ... })
↓ BFF добавляет Authorization: Bearer <accessToken>
API сервисы (OrderService, ProductService, CustomerService)
Для хранения сессий — express-session с Redis-адаптером (connect-redis):
// app.module.ts (фрагмент)
import * as session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: config.redis.url });
redisClient.connect();
app.use(
session({
store: new RedisStore({ client: redisClient }),
secret: config.session.secret,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000 },
}),
);
Преимущества BFF:
- Токены никогда не попадают в браузер.
- Refresh-flow, force-logout, лимиты сессий — централизованно в BFF.
Недостатки:
- Сервер хранит состояние (Redis — обязателен).
- BFF — отдельный сервис с отдельной инфрой.
Для стандартных UCP-сервисов чаще применяется JWT-в-cookie (stateless), BFF — для сложных SPA с управляемыми сессиями или требованиями к force-logout.
Refresh token rotation
AUTH-21: rotation обязателен.
T=0 POST /auth/login → access_token (15 мин) + refresh_token RT-1 (7 дней)
T=15m POST /auth/refresh [cookie: RT-1] → access_token + refresh_token RT-2
IdP/BFF: RT-1 помечается USED, инвалидируется
T=30m POST /auth/refresh [cookie: RT-2] → access_token + RT-3
T=31m Атакёр нашёл старый RT-2 в логах → POST /auth/refresh [cookie: RT-2]
IdP/BFF: RT-2 уже USED → INVALIDATE ВСЯ ЦЕПОЧКА
Легитимный пользователь разлогинен, требуется повторная аутентификация
Endpoint refresh в NestJS:
// adapters/in/http/auth/auth.controller.ts
@Public()
@Post('refresh')
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<void> {
const oldRt: string | undefined = req.cookies?.['refresh_token'];
if (!oldRt) throw new UnauthorizedException();
const tokens = await this.authService.refresh(oldRt); // AUTH-21: rotation внутри
res.cookie('access_token', tokens.accessToken, {
httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 15 * 60 * 1000,
});
res.cookie('refresh_token', tokens.refreshToken, {
httpOnly: true, secure: true, sameSite: 'lax', path: '/auth/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
}
authService.refresh на стороне IdP (Keycloak) — один POST /realms/{realm}/protocol/openid-connect/token с grant_type=refresh_token. Keycloak поддерживает rotation из коробки; повторный вызов с уже использованным RT возвращает 400 invalid_grant.
CSRF protection
С HttpOnly cookie остаётся риск CSRF: вредоносный сайт делает POST /api/orders/cancel на наш домен, браузер автоматически прикладывает cookie.
Защита:
sameSite: 'lax'— браузер не отправит cookie на cross-site POST.- CSRF double-submit для критических действий: NestJS читает
X-XSRF-TOKENиз заголовка, сравнивает с токеном из отдельной (non-HttpOnly) cookie.
// adapters/in/http/security/csrf.guard.ts
@Injectable()
export class CsrfGuard implements CanActivate {
canActivate(ctx: ExecutionContext): boolean {
const req = ctx.switchToHttp().getRequest<Request>();
const cookieToken: string | undefined = req.cookies?.['XSRF-TOKEN'];
const headerToken = req.headers['x-xsrf-token'] as string | undefined;
if (!cookieToken || cookieToken !== headerToken) throw new ForbiddenException('invalid csrf');
return true;
}
}
CSRF-cookie выставляется без httpOnly, чтобы фронтенд мог читать и вставлять в заголовок. Атакёр не знает значение (SameSite=Lax блокирует чтение cookie), значит не может собрать правильный X-XSRF-TOKEN.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
JWT в localStorage | AUTH-20 | res.cookie(...) с httpOnly: true |
Cookie без httpOnly | AUTH-20 | JS-доступ закрыт, кроме CSRF-cookie |
Cookie без secure | AUTH-20 | HTTPS only |
Cookie без sameSite | AUTH-20 | минимум 'lax' |
| Refresh без rotation | AUTH-21 | каждый refresh выдаёт новый RT, старый инвалидируется |
| Не инвалидировать цепочку при повторном использовании RT | AUTH-21 | invalidate всё, force re-login |
Refresh-cookie с path: '/' | AUTH-20 | restrict path: '/auth/refresh' |
maxAge отсутствует | AUTH-20 | явный срок истечения, не session cookie |
cookieParser() не подключён, cookie не читается | AUTH-20 | app.use(cookieParser()) в main.ts |
Куда дальше
- JWT validation — как
passport-jwt+jwks-rsaпроверяют подпись и claims. - PII и секреты — токены = секрет, не в логах.
- Service-to-service — s2s не использует cookie, только Bearer или mTLS.
- Где какая проверка — Gateway получает cookie, конвертирует в Bearer для downstream.
- RBAC: маппинг ролей —
extractRolesиз JWT-claims вPrincipal.roles. - ABAC: владение ресурсом —
order.customerId === principal.subв Handler. - Идемпотентность —
Idempotency-Keyдля money-команд, не связанный с хранением токенов, но часть auth-контракта. - Аудит admin-команд — audit-interceptor для admin-действий.