Пошук уроків, статей та іншого контенту
Налаштуєте пару access і refresh токенів, їхнє оновлення, відкликання та безпечне зберігання.
У схемі з двома токенами клієнт отримує:
access token — короткоживучий токен для доступу до API;
refresh token — довгоживучий токен для отримання нової пари токенів.
Access token передається в заголовку:
Authorization: Bearer <access-token>Зазвичай він живе від кількох хвилин до години. Якщо його викрадуть, час використання буде обмежений терміном дії токена.
Refresh token використовується лише для endpoint оновлення, наприклад:
POST /auth/refreshВін може жити кілька днів або тижнів, але має зберігатися безпечніше. У браузері найкращий варіант — cookie з параметрами HttpOnly, Secure і SameSite.
Типовий сценарій виглядає так:
Користувач надсилає логін і пароль.
Сервер перевіряє облікові дані.
Сервер повертає access token і встановлює refresh token у cookie.
Клієнт надсилає access token до захищених endpoint.
Коли access token завершується, клієнт викликає /auth/refresh.
Сервер перевіряє refresh token і видає нову пару токенів.
Старий refresh token відкликається.
Під час logout refresh token видаляється та відкликається на сервері.
Важливо: refresh token не повинен бути єдиним доказом того, що токен дійсний. Сервер також має перевірити, чи не був він відкликаний.
Для прикладу використаємо @nestjs/jwt і cookie-parser:
npm install @nestjs/jwt cookie-parser
npm install -D @types/cookie-parserУ main.ts потрібно підключити middleware для читання cookies:
import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(cookieParser());
await app.listen(3000);
}
bootstrap();Access і refresh токени повинні мати різні секрети та різний час життя.
Наприклад:
access token — 15m;
refresh token — 7d.
У payload варто зберігати ідентифікатор користувача sub, а для refresh token — додатковий унікальний ідентифікатор jti.
jti потрібен для відкликання конкретного refresh token.
import {
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
type StoredRefreshToken = {
userId: string;
tokenHash: string;
expiresAt: number;
};
@Injectable()
export class AuthService {
private readonly accessSecret =
process.env.JWT_ACCESS_SECRET ?? 'development-access-secret';
private readonly refreshSecret =
process.env.JWT_REFRESH_SECRET ?? 'development-refresh-secret';
/*
* Це демонстраційне сховище.
* У production його потрібно замінити на базу даних або Redis.
*/
private readonly refreshTokens = new Map<string, StoredRefreshToken>();
constructor(private readonly jwtService: JwtService) {}
async login(email: string, password: string) {
const user = await this.validateUser(email, password);
if (!user) {
throw new UnauthorizedException('Неправильний email або пароль');
}
return this.issueTokenPair(user.id, user.email);
}
async refresh(refreshToken: string) {
let payload: {
sub: string;
email: string;
type: string;
jti: string;
};
try {
payload = await this.jwtService.verifyAsync(refreshToken, {
secret: this.refreshSecret,
});
} catch {
throw new UnauthorizedException('Недійсний refresh token');
}
if (payload.type !== 'refresh' || !payload.jti) {
throw new UnauthorizedException('Некоректний refresh token');
}
const storedToken = this.refreshTokens.get(payload.jti);
if (
!storedToken ||
storedToken.userId !== payload.sub ||
storedToken.expiresAt <= Date.now() ||
!this.isSameHash(storedToken.tokenHash, this.hash(refreshToken))
) {
throw new UnauthorizedException(
'Refresh token відкликаний або недійсний',
);
}
/*
* Rotation: старий refresh token стає недійсним
* ще до створення нового.
*/
this.refreshTokens.delete(payload.jti);
return this.issueTokenPair(payload.sub, payload.email);
}
logout(refreshToken?: string) {
if (!refreshToken) {
return;
}
try {
const payload = this.jwtService.verify<{
jti?: string;
}>(refreshToken, {
secret: this.refreshSecret,
ignoreExpiration: true,
});
if (payload.jti) {
this.refreshTokens.delete(payload.jti);
}
} catch {
/*
* Logout має бути ідемпотентним:
* навіть недійсний токен не повинен спричиняти помилку.
*/
}
}
revokeAllUserTokens(userId: string) {
for (const [jti, storedToken] of this.refreshTokens) {
if (storedToken.userId === userId) {
this.refreshTokens.delete(jti);
}
}
}
private async issueTokenPair(userId: string, email: string) {
const refreshJti = randomUUID();
const accessToken = await this.jwtService.signAsync(
{
sub: userId,
email,
type: 'access',
},
{
secret: this.accessSecret,
expiresIn: '15m',
},
);
const refreshToken = await this.jwtService.signAsync(
{
sub: userId,
email,
type: 'refresh',
jti: refreshJti,
},
{
secret: this.refreshSecret,
expiresIn: '7d',
},
);
this.refreshTokens.set(refreshJti, {
userId,
tokenHash: this.hash(refreshToken),
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
});
return {
accessToken,
user: {
id: userId,
email,
},
};
}
private hash(token: string) {
return createHash('sha256').update(token).digest('hex');
}
private isSameHash(first: string, second: string) {
const firstBuffer = Buffer.from(first);
const secondBuffer = Buffer.from(second);
return (
firstBuffer.length === secondBuffer.length &&
timingSafeEqual(firstBuffer, secondBuffer)
);
}
private async validateUser(email: string, password: string) {
/*
* Для прикладу використано умовного користувача.
* У реальному застосунку тут буде пошук у базі
* та перевірка хешу пароля.
*/
if (email === 'user@example.com' && password === 'correct-password') {
return {
id: 'user-1',
email,
};
}
return null;
}
}Refresh token є секретом. Якщо зберігати його у відкритому вигляді в базі даних, витік бази дозволить одразу використовувати всі активні сесії.
Тому сервер:
створює refresh token;
повертає сам токен клієнту;
зберігає в базі лише хеш токена;
під час оновлення хешує отриманий токен і порівнює результати.
У прикладі використано SHA-256. Для випадкового токена з великою ентропією цього достатньо для перевірки відповідності. Сам refresh token має бути довгим і створеним криптографічно безпечним способом.
Refresh token не потрібно повертати в JSON-відповіді. Замість цього встановимо його в HttpOnly cookie.
import {
Body,
Controller,
Post,
Req,
Res,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { AuthService } from './auth.service';
type LoginDto = {
email: string;
password: string;
};
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
async login(
@Body() body: LoginDto,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.authService.login(
body.email,
body.password,
);
/*
* Refresh token не потрапляє до JavaScript-коду браузера
* завдяки прапорцю HttpOnly.
*/
const refreshToken = await this.createRefreshTokenForCookie(
body.email,
body.password,
);
response.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/auth',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return {
accessToken: result.accessToken,
user: result.user,
};
}
@Post('refresh')
async refresh(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
const oldRefreshToken = request.cookies?.refresh_token as
| string
| undefined;
if (!oldRefreshToken) {
return response.status(401).json({
message: 'Refresh token відсутній',
});
}
const result = await this.authService.refresh(oldRefreshToken);
/*
* У прикладі сервіс повертає access token,
* а новий refresh token потрібно також встановити в cookie.
*/
const newRefreshToken = await this.createRefreshTokenForCookie(
result.user.email,
'correct-password',
);
response.cookie('refresh_token', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/auth',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return {
accessToken: result.accessToken,
user: result.user,
};
}
@Post('logout')
logout(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
const refreshToken = request.cookies?.refresh_token as
| string
| undefined;
this.authService.logout(refreshToken);
response.clearCookie('refresh_token', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/auth',
});
return {
message: 'Вихід виконано',
};
}
private async createRefreshTokenForCookie(
email: string,
password: string,
) {
/*
* У production не потрібно повторно перевіряти пароль таким способом.
* Метод показує ідею встановлення refresh cookie.
*
* Практична реалізація має повертати refresh token разом з access token
* із сервісу, а не створювати його вдруге.
*/
const user = await this.authService['validateUser'](email, password);
if (!user) {
throw new Error('Користувача не знайдено');
}
const result = await this.authService['issueTokenPair'](
user.id,
user.email,
);
return result.refreshToken;
}
}У цьому фрагменті приватні методи викликаються через індексний доступ лише для стислості демонстрації. У справжньому коді це потрібно виправити: issueTokenPair має повертати і access token, і refresh token, а controller — встановлювати отриманий refresh token у cookie.
Практичніша версія сервісу повертає обидва токени:
return {
accessToken,
refreshToken,
user: {
id: userId,
email,
},
};Тоді controller після login встановлює result.refreshToken у cookie, а в JSON повертає лише accessToken і дані користувача.
Так само під час refresh сервіс повинен повертати новий refresh token після rotation:
const result = await this.issueTokenPair(payload.sub, payload.email);
return result;Цей підхід гарантує, що новий refresh token пов’язаний з новим jti, а старий токен видалений зі сховища.
Access token використовується для захисту звичайних API endpoint.
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { Request } from 'express';
type AccessPayload = {
sub: string;
email: string;
type: 'access';
};
@Injectable()
export class AccessTokenGuard implements CanActivate {
private readonly accessSecret =
process.env.JWT_ACCESS_SECRET ?? 'development-access-secret';
constructor(private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<Request>();
const authorization = request.headers.authorization;
if (!authorization?.startsWith('Bearer ')) {
throw new UnauthorizedException('Access token відсутній');
}
const token = authorization.slice('Bearer '.length);
try {
const payload = await this.jwtService.verifyAsync<AccessPayload>(
token,
{
secret: this.accessSecret,
},
);
if (payload.type !== 'access') {
throw new UnauthorizedException('Очікувався access token');
}
request.user = {
id: payload.sub,
email: payload.email,
};
return true;
} catch {
throw new UnauthorizedException('Недійсний access token');
}
}
}Підключення guard до endpoint:
import {
Controller,
Get,
Req,
UseGuards,
} from '@nestjs/common';
import type { Request } from 'express';
import { AccessTokenGuard } from './access-token.guard';
@Controller('profile')
export class ProfileController {
@Get()
@UseGuards(AccessTokenGuard)
getProfile(@Req() request: Request) {
return {
userId: request.user.id,
email: request.user.email,
};
}
}Для TypeScript може знадобитися розширити тип Express.Request, щоб властивість user була відома компілятору:
declare global {
namespace Express {
interface Request {
user?: {
id: string;
email: string;
};
}
}
}Під час кожного оновлення потрібно видавати новий refresh token і відкликати старий. Це називається refresh token rotation.
Без rotation один викрадений refresh token може працювати до завершення його терміну дії. З rotation після легітимного оновлення старий токен більше не приймається.
Послідовність така:
Перевірити підпис і термін дії старого токена.
Знайти його jti у сховищі.
Порівняти хеш отриманого токена з хешем у сховищі.
Видалити старий запис.
Створити нові access і refresh tokens.
Зберегти хеш нового refresh token.
Встановити новий refresh token у cookie.
Якщо rotation-токен уже був видалений, але хтось повторно надсилає його, це може означати викрадення токена.
У production у такій ситуації часто відкликають усі refresh tokens користувача:
if (!storedToken) {
this.revokeAllUserTokens(payload.sub);
throw new UnauthorizedException(
'Виявлено повторне використання refresh token',
);
}Це захищає від сценарію, коли зловмисник і справжній клієнт одночасно намагаються оновити одну сесію.
Map з прикладу підходить лише для демонстрації. Його вміст:
зникне після перезапуску застосунку;
не буде спільним для кількох екземплярів NestJS;
не матиме автоматичного очищення прострочених записів.
У production таблиця refresh tokens може містити:
id або jti;
userId;
tokenHash;
expiresAt;
revokedAt;
createdAt;
інформацію про пристрій або сесію.
Під час перевірки потрібно шукати токен за jti, перевіряти revokedAt і expiresAt, а також порівнювати хеш.
Для відкликання всіх сесій користувача достатньо позначити всі його записи як відкликані або видалити їх.
Основні атрибути refresh cookie:
HttpOnly — JavaScript не може прочитати cookie через document.cookie;
Secure — cookie передається лише через HTTPS;
SameSite=Lax або SameSite=Strict — зменшує ризик міжсайтових запитів;
Path=/auth — cookie надсилається лише до auth endpoint;
Max-Age — час життя cookie має відповідати терміну refresh token.
У production потрібно використовувати:
{
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/auth',
}Якщо frontend і backend знаходяться на різних сайтах, може знадобитися SameSite=None. У такому разі обов’язковим є Secure, а endpoint оновлення потрібно додатково захищати від CSRF.
HttpOnly захищає cookie від читання JavaScript, але не усуває всі CSRF-ризики. Cookie автоматично додається браузером до відповідного запиту, тому політика SameSite, перевірка походження запиту та CSRF-захист залишаються важливими.
Для браузерного застосунку типовий варіант:
access token зберігати лише в пам’яті JavaScript;
refresh token зберігати в HttpOnly cookie;
після перезавантаження сторінки викликати /auth/refresh;
отриманий access token знову тримати в пам’яті.
Не варто без необхідності зберігати access token у localStorage: код, виконаний через XSS, зможе його прочитати.
Клієнт може діяти так:
Надіслати запит із access token.
Отримати 401 Unauthorized.
Один раз викликати /auth/refresh.
Повторити початковий запит з новим access token.
Якщо refresh також повернув 401, перенаправити користувача на login.
Потрібно обмежити кількість повторних спроб. Інакше помилка авторизації може спричинити нескінченний цикл запитів.
XSS-уразливість може дозволити прочитати токен. Для браузера краще використовувати HttpOnly cookie.
Тоді він потрапляє у JavaScript-код клієнта та може бути випадково записаний у логи, стан застосунку або інструменти аналітики.
Окремі секрети зменшують наслідки компрометації та допомагають відрізнити типи токенів.
Access token і refresh token повинні мати різні значення type. Інакше endpoint може випадково прийняти токен не того призначення.
Видалення cookie на клієнті не відкликає токен, який уже був скопійований. Logout має також видалити або позначити токен як відкликаний на сервері.
Довгоживучий refresh token без rotation залишається дійсним навіть після його використання. Rotation обмежує можливість повторного застосування старого токена.
У базі потрібно зберігати хеш, а не оригінальний секрет.
Map втрачає дані після перезапуску та не працює узгоджено між кількома екземплярами застосунку. Для production потрібне спільне постійне сховище.
Access token має короткий термін життя та використовується для API-запитів.
Refresh token має довший термін життя та використовується лише для оновлення сесії.
Refresh token бажано зберігати в HttpOnly, Secure, SameSite cookie.
На сервері потрібно зберігати хеш refresh token і його jti.
Під час оновлення слід використовувати rotation: старий refresh token відкликається, новий створюється.
Logout має відкликати refresh token на сервері та очистити cookie.
Access token перевіряється guard-ом через заголовок Authorization.
In-memory сховище придатне лише для навчального прикладу; у production потрібні база даних або спільне сховище.