Опирается на правила: 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: trueJavaScript не может читать cookie. Защита от XSS.
secure: trueCookie отправляется только по 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, а не в 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 в localStorageAUTH-20res.cookie(...) с httpOnly: true
Cookie без httpOnlyAUTH-20JS-доступ закрыт, кроме CSRF-cookie
Cookie без secureAUTH-20HTTPS only
Cookie без sameSiteAUTH-20минимум 'lax'
Refresh без rotationAUTH-21каждый refresh выдаёт новый RT, старый инвалидируется
Не инвалидировать цепочку при повторном использовании RTAUTH-21invalidate всё, force re-login
Refresh-cookie с path: '/'AUTH-20restrict path: '/auth/refresh'
maxAge отсутствуетAUTH-20явный срок истечения, не session cookie
cookieParser() не подключён, cookie не читаетсяAUTH-20app.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-действий.