Когда клиент отправляет запрос с токеном, сервис должен убедиться: этот токен настоящий, не просрочен и выдан именно тем центром авторизации, которому мы доверяем. Один пропущенный шаг — и злоумышленник подделывает токен, объявляет себя администратором и делает всё что хочет.
В NestJS эту задачу решает готовая связка библиотек: passport-jwt + jwks-rsa. Весь криптографический код уже написан и отлажен — вам остаётся только собрать конфигурацию правильно.
Что такое JWT и почему его надо проверять тщательно
JWT (JSON Web Token) — это строка из трёх частей: заголовок, тело с данными о пользователе, и цифровая подпись. Подпись создаётся центром авторизации (Identity Provider, IdP) его закрытым ключом. Ваш сервис проверяет подпись открытым ключом — если проверка прошла, значит токен не подделан.
Проблема в том, что «проверить токен» — это не один шаг, а несколько:
- Проверить подпись — токен не изменён и подписан нужным ключом.
- Проверить
exp— токен не просрочен. - Проверить
iss— токен выдан именно нашим IdP, а не чужим. - Проверить
aud— токен предназначен именно этому сервису.
Пропустить любой из этих шагов — значит открыть уязвимость. Именно поэтому писать проверку токена самостоятельно опасно: легко забыть что-то одно.
JwtStrategy: как это работает в NestJS
NestJS использует библиотеку Passport для аутентификации. Для JWT есть готовая стратегия — passport-jwt. А для загрузки открытых ключей из IdP — jwks-rsa.
Вот как выглядит стандартная конфигурация:
// 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 { AppConfig } from '../../../../config/app.config';
import { Principal } from '../../../../core/domain/principal';
import { JwtClaims } from './jwt-claims';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: AppConfig) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
algorithms: ['RS256'],
audience: config.auth.audience,
issuer: config.auth.issuer,
secretOrKeyProvider: passportJwtSecret({
jwksUri: config.auth.jwksUri,
cache: true,
cacheMaxAge: 300_000, // кеш на 5 минут
rateLimit: true,
}),
});
}
validate(claims: JwtClaims): Principal {
// сюда попадаем только если подпись, exp, iss, aud уже проверены
return {
sub: claims.sub,
roles: extractRoles(claims),
};
}
}
Что происходит при каждом запросе:
passport-jwtизвлекает токен из заголовкаAuthorization: Bearer <token>.jwks-rsaзагружает открытые ключи IdP по адресуjwksUri(из кеша, если они там уже есть).- Проверяется подпись,
exp,iss,aud. - Только если всё в порядке — вызывается ваш метод
validate().
Метод validate() получает уже проверенные данные. Дублировать в нём проверку подписи или сроков не нужно.
Почему нельзя писать проверку вручную
Иногда разработчики пишут собственный Guard с jwt.decode(). Выглядит просто, но в нём скрыто несколько критических ошибок:
// НЕПРАВИЛЬНО — так делать нельзя
const token = req.headers.authorization?.split(' ')[1];
const decoded = jwt.decode(token); // decode не проверяет подпись!
if (decoded.exp < Date.now() / 1000) return false;
req.user = decoded;
return true;
Что здесь сломано:
jwt.decode()только парсит токен, но не проверяет подпись. Злоумышленник подписывает токен своим ключом — и он проходит.- Нет проверки
iss— токен от другого IdP принят. - Нет проверки
aud— токен другого сервиса прошёл. - Нет учёта погрешности часов (
clock skew) — у клиентов с часами ±30 секундexpломается. - Уязвимость
alg: none— принят токен без подписи вообще.
Каждая из этих ошибок — серьёзная уязвимость. passport-jwt + jwks-rsa уже решили все эти проблемы в коде, который проходил аудит безопасности.
Кеш открытых ключей
IdP публикует открытые ключи в формате JWK Set по стандартному адресу. При каждой проверке токена тянуть ключи напрямую с IdP — плохая идея: это лишняя задержка на каждый запрос и нагрузка на IdP.
jwks-rsa автоматически кеширует ключи. Настройка cacheMaxAge: 300_000 держит их в кеше пять минут. Если IdP сменил ключи (ротация), а в кеше ещё старые — jwks-rsa заметит незнакомый kid в токене и запросит свежие ключи автоматически. rateLimit: true защищает от ситуации, когда злоумышленник специально шлёт запросы с неизвестными kid, чтобы перегрузить IdP.
Хранить открытый ключ в переменной окружения или файле конфигурации не нужно — jwks-rsa загружает его сам по jwksUri. Это также означает, что ротация ключей у IdP не требует перезапуска сервиса.
Guards: аутентификация и авторизация — разные шаги
В NestJS проверка происходит в два слоя:
JwtAuthGuard — проверяет, что токен настоящий. Если нет — возвращает 401.
RolesGuard — проверяет, что у пользователя есть нужная роль. Если нет — возвращает 403.
Порядок важен: сначала аутентификация, потом авторизация.
// adapters/in/http/security/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(ctx: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
ctx.getHandler(),
ctx.getClass(),
]);
if (isPublic) return true;
return super.canActivate(ctx);
}
}
// adapters/in/http/security/roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
const required = this.reflector.get(Roles, ctx.getHandler());
if (!required) throw new ForbiddenException();
const { user } = ctx.switchToHttp().getRequest<{ user: Principal }>();
if (!required.some((r) => user.roles.includes(r))) {
throw new ForbiddenException();
}
return true;
}
}
Оба Guard регистрируются глобально в app.module.ts:
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, // 401 — аутентификация
{ provide: APP_GUARD, useClass: RolesGuard }, // 403 — авторизация
],
Публичные маршруты — например, GET /health — помечаются декоратором @Public(). Без него Guard будет требовать токен на каждом запросе.
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
В чём разница между 401 и 403
Это частая путаница, которая ломает клиентские приложения.
401 Unauthorized — токен невалидный. Либо подпись неверна, либо токен просрочен, либо заголовок Authorization вообще отсутствует. Клиент должен обновить токен (через refresh-token flow) или перенаправить пользователя на страницу входа.
403 Forbidden — токен валидный, пользователь известен, но у него нет прав на это действие. Обновлять токен бесполезно — роль не изменится. Клиент должен показать сообщение «доступ запрещён».
Типичная ошибка — переопределить handleRequest и возвращать 403 вместо 401:
// НЕПРАВИЛЬНО
handleRequest(err: unknown, user: Principal) {
if (err || !user) throw new ForbiddenException(); // маскирует 401 как 403
}
Клиент видит 403 на просроченном токене, думает «нет прав», не делает refresh, зависает. Правильное поведение — оставить JwtAuthGuard как есть: он сам кидает UnauthorizedException (401) при проблемах с токеном.
Роли из JWT-токена
Роли пользователя живут внутри JWT в виде claims. Keycloak кладёт их в realm_access.roles, OAuth2 — в поле scope. Маппинг в объект Principal делается один раз — в методе validate() стратегии:
// adapters/in/http/security/jwt-claims.ts
export interface JwtClaims {
sub: string;
realm_access?: { roles: string[] }; // Keycloak
scope?: string; // OAuth2 scope
}
// adapters/in/http/security/extract-roles.ts
const ALLOWED_ROLES = new Set(['customer', 'seller', 'admin', 'system']);
export function extractRoles(claims: JwtClaims): string[] {
const raw = claims.realm_access?.roles ?? claims.scope?.split(' ') ?? [];
return raw.filter((r) => ALLOWED_ROLES.has(r));
}
На конкретных маршрутах роли указываются через декоратор @Roles:
@Controller('orders')
export class OrderController {
@Post()
@Roles(['customer', 'seller'])
async createOrder(@Body() dto: CreateOrderDto, @Req() req: Request & { user: Principal }) {
return this.createOrderUseCase.execute(dto, req.user);
}
@Delete(':id')
@Roles(['customer', 'admin'])
async cancelOrder(@Param('id') id: string, @Req() req: Request & { user: Principal }) {
return this.cancelOrderUseCase.execute(id, req.user);
}
@Get('health')
@Public()
healthCheck(): string {
return 'ok';
}
}
Каждый маршрут должен явно объявлять либо @Roles(...), либо @Public(). Маршрут без обоих декораторов пройдёт аутентификацию, но упадёт на авторизации — это полезное ограничение: забытый маршрут не окажется случайно открытым.
Коротко
- JWT содержит цифровую подпись — её нужно проверять с помощью открытого ключа IdP.
jwt.decode()подпись не проверяет. passport-jwt+jwks-rsaпроверяют подпись, срок действия, издателя и аудиторию — всё автоматически.- Открытые ключи IdP хранятся в кеше (
cache: true, cacheMaxAge: 300_000) и обновляются при ротации без перезапуска сервиса. - В
validate()попадают уже проверенные данные — дублировать криптологику там не нужно. JwtAuthGuardдаёт 401 (невалидный токен),RolesGuardдаёт 403 (нет прав). Путать их нельзя — у клиента разные сценарии реакции.- Каждый маршрут явно помечается
@Roles(...)или@Public().
Что почитать дальше
- RBAC: маппинг ролей —
extractRoles, разрешённые значения,Rolesдекоратор. - ABAC: владение ресурсом — проверка
order.customerId === principal.subв обработчике. - Service-to-service — mTLS и Client Credentials для межсервисного общения.
- Хранение токенов на клиенте — HttpOnly cookie и ротация refresh-token.