# Access і refresh токени в Node.js: як влаштована авторизація без плутанини

> Навіщо потрібні два токени, як виглядає повний потік логін – оновлення – логаут, де зберігати access і refresh, типові помилки з localStorage і ротацією. З прикладами коду на TypeScript.

- Автор: Юра Скиба (https://cookiesoftware.io)
- Опубліковано: 2026-09-24
- Категорія: Backend і System Design
- Canonical: https://cookiesoftware.io/blog/access-refresh-tokens-nodejs

---

**Коротка відповідь.** Access-токен – короткоживучий пропуск, який клієнт додає до кожного запиту; refresh-токен – довгоживучий, зберігається в httpOnly cookie і використовується лише для отримання нового access-токена. Два токени потрібні, щоб поєднати дві протилежні вимоги: перевіряти запити швидко (без походу в базу) і мати змогу відкликати доступ. Нижче – повний потік, робочий код і помилки, на яких сипляться кандидати.

## Навіщо взагалі два токени

Уяви, що є лише один довгоживучий токен. Тоді або він самодостатній (JWT) – і його неможливо відкликати до закінчення строку дії, або сервер перевіряє його в базі на кожен запит – і ти втрачаєш головну перевагу токенів: stateless-перевірку без додаткового I/O.

Розділення обов'язків знімає конфлікт:

- **Access-токен** живе хвилини (типово 5–15). Його крадіжка неприємна, але вікно атаки коротке. Сервер перевіряє лише підпис – швидко і без бази.
- **Refresh-токен** живе дні або тижні, але використовується рідко – лише на ендпоінті оновлення. Саме там можна дозволити собі перевірку в базі, ротацію і відкликання.

## Повний потік крок за кроком

1. **Логін.** Клієнт надсилає credentials. Сервер перевіряє їх, генерує пару токенів. Access повертається в тілі відповіді, refresh – у `Set-Cookie` з прапорцями `httpOnly`, `Secure`, `SameSite`.
2. **Звичайний запит.** Клієнт тримає access у пам'яті процесу (змінна, стан застосунку) і додає заголовок `Authorization: Bearer <token>`. Сервер перевіряє підпис і строк дії – без бази.
3. **Access прострочився.** Сервер відповідає 401. Клієнт викликає `POST /auth/refresh`; браузер сам додає refresh-cookie.
4. **Оновлення.** Сервер знаходить refresh-токен у базі, перевіряє, що він не відкликаний, видає нову пару і **інвалідовує старий refresh** (ротація). Клієнт повторює початковий запит.
5. **Логаут.** Сервер видаляє refresh-токен із бази й чистить cookie. Access доживає свої хвилини і стає марним.

## Код: видача пари токенів

```ts
import jwt from 'jsonwebtoken';
import { randomUUID } from 'node:crypto';

const ACCESS_TTL = '10m';
const REFRESH_TTL_DAYS = 14;

type AccessPayload = { userId: string };

function issueAccessToken(userId: string): string {
  const payload: AccessPayload = { userId };
  return jwt.sign(payload, process.env.ACCESS_SECRET!, {
    expiresIn: ACCESS_TTL,
  });
}

async function issueRefreshToken(userId: string): Promise<string> {
  const token = randomUUID();
  const expiresAt = new Date(
    Date.now() + REFRESH_TTL_DAYS * 24 * 60 * 60 * 1000
  );
  // Зберігаємо ХЕШ, а не сам токен: витік бази не дає готових refresh-токенів
  await db.refreshToken.create({
    data: { tokenHash: sha256(token), userId, expiresAt },
  });
  return token;
}
```

Зверни увагу: refresh тут – не JWT, а непрозорий (opaque) ідентифікатор. Йому не потрібен підпис: сервер і так звіряє його з базою.

## Код: middleware перевірки access

```ts
import type { Request, Response, NextFunction } from 'express';

export function requireAuth(req: Request, res: Response, next: NextFunction) {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'no token' });
  }

  try {
    const payload = jwt.verify(
      header.slice('Bearer '.length),
      process.env.ACCESS_SECRET!
    ) as AccessPayload;
    req.userId = payload.userId;
    next();
  } catch {
    // І прострочений, і підроблений токен – однакова відповідь
    return res.status(401).json({ error: 'invalid token' });
  }
}
```

Жодного запиту в базу – в цьому й сенс короткоживучого access-токена.

## Код: ендпоінт оновлення з ротацією

```ts
app.post('/auth/refresh', async (req, res) => {
  const token = req.cookies.refreshToken;
  if (!token) return res.status(401).json({ error: 'no refresh token' });

  const stored = await db.refreshToken.findUnique({
    where: { tokenHash: sha256(token) },
  });

  if (!stored || stored.expiresAt < new Date()) {
    return res.status(401).json({ error: 'refresh expired' });
  }

  // Ротація: старий токен більше не дійсний
  await db.refreshToken.delete({ where: { id: stored.id } });

  const access = issueAccessToken(stored.userId);
  const refresh = await issueRefreshToken(stored.userId);

  res
    .cookie('refreshToken', refresh, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      path: '/auth',
      maxAge: REFRESH_TTL_DAYS * 24 * 60 * 60 * 1000,
    })
    .json({ accessToken: access });
});
```

Ротація дає бонус для безпеки: якщо вкрадений refresh-токен уже використано, легітимний користувач при наступному оновленні отримає 401 – і це сигнал інвалідувати всі сесії акаунта.

## Типові помилки

- **Access-токен у localStorage.** Будь-який XSS читає localStorage повністю. Тримай access у пам'яті, refresh – у httpOnly cookie, яку скрипт прочитати не може. Про XSS докладно пояснює [MDN](https://developer.mozilla.org/en-US/docs/Web/Security).
- **Refresh без ротації.** Один викрадений токен = тижні доступу. Ротація звужує вікно до одного використання.
- **Довгий TTL access-токена.** Година чи доба «щоб рідше рефрешити» знецінює всю схему: відкликати такий токен неможливо.
- **Зберігання refresh-токенів у чистому вигляді.** База витекла – всі сесії скомпрометовані. Зберігай хеш.
- **Спільний `path` для refresh-cookie.** Cookie з `path: '/'` їде з кожним запитом. Обмеж її `/auth` – менше поверхні для CSRF.

## JWT чи opaque – коротко

Для access зазвичай JWT: перевірка без бази – головна вигода. Для refresh краще opaque-рядок: він і так перевіряється в базі, а JWT-формат лише додає спокусу «перевіряти без бази» там, де це небезпечно. Якщо на співбесіді питають «а можна все зробити сесіями?» – чесна відповідь: так, для одного класичного вебзастосунку серверні сесії простіші; токени виграють, коли клієнтів кілька (SPA, мобільний) або сервісів більше одного.

## Питання, які реально ставлять на співбесіді

1. **Чому не можна просто видати один довгий JWT?** – конфлікт «швидка перевірка vs відкликання» (див. початок статті).
2. **Що станеться, якщо вкрадуть access-токен?** – атакуючий має доступ до закінчення TTL; тому TTL короткий, а критичні операції вимагають повторної автентифікації.
3. **Де зберігати токени в браузері й чому?** – access у пам'яті, refresh у httpOnly cookie; localStorage – ні (XSS).
4. **Як розлогінити користувача з усіх пристроїв?** – видалити всі його refresh-токени; access-токени доживуть лічені хвилини.
5. **Навіщо ротація refresh-токенів?** – одноразовість + детекція крадіжки при повторному використанні.

Auth – одна з найчастіших тем на [backend-співбесідах із Node.js](/blog/nodejs-backend-interview), і відповідь «ну, JWT, він безпечний» одразу видає завчену поверхневість. Якщо готуєшся системно – подивись [план підготовки за 30 днів](/blog/pidhotovka-do-tekhnichnoi-spivbesidy); а про те, де в цій схемі місце кешу сесійних даних, є окремий розбір [Redis](/blog/redis-koly-potriben-kesh).

## Практичне завдання

Реалізуй мінімальний auth-модуль на Express або Fastify: `/auth/login`, `/auth/refresh`, `/auth/logout`, один захищений `/me`. Потім проведи власний «пентест»: спробуй скористатися простроченим access, повторно використаним refresh, cookie без `Secure`. Кожен сценарій, який несподівано спрацював, – твоя наступна тема для розбору.
