Головна / Статті

Реалізація токенів доступу та оновлення разом у Node.js

Короткострокові токени доступу та обертові токени оновлення в Node.js: процес JWT, де зберігати кожен токен, та як обертання запобігає використанню вкрадених токенів оновлення.

Видавець: REACTAPP.TOP11 хв читання2600 слів

Джерела: Access Tokens vs Refresh Tokens in Node.js: Why Real Applications Need Both

Коли ви створюєте свій перший процес автентифікації в Node.js, типовим підходом є використання одного JSON Web Token (JWT). Користувач надсилає електронну пошту та пароль, сервер перевіряє їх, підписує токен та повертає його. З цього моменту фронтенд включає цей токен у заголовок Authorization у кожному запиті.

Така конфігурація здається прийнятною під час тестування на localhost. Але як тільки справжні користувачі починають користуватися додатком, з’являється структурне протиріччя.

Встановіть термін дії токена на короткий період, наприклад 15 хвилин, і користувачі будуть викинуті під час виконання завдання — можливо, під час написання коментаря або на середині заповнення форми. Якщо встановити довгий термін, наприклад 30 днів, користувачі залишатимуться усередині системи, але це створює серйозну проблему з безпекою: якщо токен буде вкрадений, не буде способу скасувати його без анулювання всіх токенів усіх користувачів у всій системі.

Саме цей конфлікт між зручністю для користувачів та безпекою є причиною того, чому системи продакшн-рівня не покладаються на один токен — вони використовують два: токен доступу та токен оновлення.

Розгляньмо, чому існує ця модель з двома токенами, яку роль виконує кожен з них та як правильно її реалізувати у бекенді на Node.js.

Основна проблема автентифікації з одним токеном

Перш ніж пояснити, чому потрібні два токени, корисно знову згадати, що робить JWT унікальними: вони є безстановими.

Коли сервер Node.js отримує JWT, йому не потрібно звертатися до бази даних, щоб перевірити наявність сеансу. Він просто порівнює криптографічний підпис токена з секретним ключем:

Token payload + Secret key = Signature verified?
├── Yes -> Request allowed
└── No  -> 401 Unauthorized

Саме безстановість робить API, засновані на JWT, швидкими та легкими для масштабування, оскільки база даних не запитується з кожним надходячим запитом. Але у цієї ж властивості є недолік: як тільки токен видається, у сервера немає простого способу швидко його анулювати.

Розгляньмо послідовність подій:

  • Ваш єдиний JWT налаштований на термін дії 30 днів.
  • Зловмисник отримує до нього доступ — можливо, через атаку типу XSS або перехоплення трафіку в незахищеній мережі.
  • Власник облікового запису помічає, що щось не так, і змінює свій пароль.
  • Зловмисник продовжує діяти, ніби нічого не сталося. Вкрадений токен залишається дійсним формально до закінчення свого 30-денного терміну служби, незалежно від зміни пароля.
  • Що таке токени доступу та токени оновлення?

    +------------------+-----------------------------+-----------------------------+
    | Feature          | Access Token                | Refresh Token               |
    +------------------+-----------------------------+-----------------------------+
    | Purpose          | Authenticate API requests   | Obtain new access tokens    |
    | Lifetime         | Short (5 to 15 minutes)     | Long (7 to 30 days)         |
    | Storage          | Memory or short-lived state | HttpOnly, Secure Cookie     |
    | Database lookup  | No (purely stateless)       | Yes (checked on refresh)    |
    | Exposure risk    | High (sent on every request)| Low (sent only to /refresh) |
    +------------------+-----------------------------+-----------------------------+
    

    1. Токен доступу

    Уявіть собі токен доступу як короткостроковий пропуск. Клієнт додає його до заголовків звичайних запитів API — таких як GET /api/orders або POST /api/profile.

    Оскільки він дійсний лише протягом короткого часу (зазвичай 10–15 хвилин), будь-яка потенційна шкода від його витоку є обмеженою. Сервер може перевірити його виключно за допомогою перевірок підпису на основі CPU, не торкаючись бази даних, що забезпечує швидку відповідь API.

    2. Токен оновлення

    Токен оновлення функціонує як довгостроковий купон на поновлення. Його ніколи не надсилають разом із звичайними запитами API — його єдина функція — взаємодія з спеціалізованим кінцевим пунктом, наприклад POST /api/auth/refresh.

    Як тільки тимчасовий токен доступу закінчує свою дію, клієнт тихо викликає цей маршрут оновлення. Сервер потім запитує базу даних, щоб переконатися, що обліковий запис користувача все ще активний та має необхідні права, після чого видає новий токен доступу.

    Оскільки цей виклик оновлення відбувається приблизно кожні 15 хвилин, а не після кожної взаємодії користувача, додавання запиту до бази даних майже не впливає на продуктивність.

    Життєвий цикл запиту

    Ось як клієнт, сервер та база даних взаємодіють протягом часу:

    Client                             Server                           Database
      │                                  │                                 │
      ├─────── 1. Login (credentials) ──>│                                 │
      │                                  ├─── Verify credentials ─────────>│
      │                                  │<── User record valid ───────────┤
      │                                  ├─── Store Refresh Token Hash ───>│
      │<────── 2. Return Tokens ─────────┤                                 │
      │       (Access: 15m, Refresh: 7d) │                                 │
      │                                  │                                 │
      │─────── 3. GET /api/dashboard ───>│                                 │
      │       [Bearer Access Token]      ├─── Verify signature (Stateless) │
      │<────── 4. 200 OK + Data ─────────┤                                 │
      │                                  │                                 │
      │    ... 15 minutes pass ...       │                                 │
      │                                  │                                 │
      │─────── 5. GET /api/dashboard ───>│                                 │
      │       [Expired Access Token]     ├─── Token expired!               │
      │<────── 6. 401 Unauthorized ──────┤                                 │
      │                                  │                                 │
      │─────── 7. POST /api/auth/refresh>│                                 │
      │       [Cookie: Refresh Token]    ├─── Check if token in DB ───────>│
      │                                  │<── Token valid & not revoked ───┤
      │                                  ├─── Generate new Access Token    │
      │<────── 8. Return new Access Token┤                                 │
      │                                  │                                 │
      │─────── 9. Retry GET /dashboard ─>│                                 │
      │       [New Access Token]         ├─── Verify signature             │
      │<────── 10. 200 OK + Data ────────┤                                 │
    

    З точки зору користувача весь цей процес оновлення залишається непомітним. Його сесія продовжує працювати у фоновому режимі, без будь-яких різких спливних повідомлень про закінчення сесії, які б переривали активне використання.

    Практичне застосування з Node.js

    Тепер давайте створимо працюючу конфігурацію Express, яка покаже, як видаляти, перевіряти та оновлювати токени.

    Почніть з встановлення необхідних пакетів:

    npm install express jsonwebtoken cookie-parser dotenv
    

    1. Створення токенів

    Створіть допоміжний модуль, який генерує обидва токени, кожен з них підписаний власним секретом:

    // tokenService.js
    const jwt = require('jsonwebtoken');
    
    const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET || 'access_secret_123';
    const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || 'refresh_secret_456';
    
    function generateAccessToken(userId) {
      // Short lifespan: 15 minutes
      return jwt.sign({ sub: userId }, ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
    }
    
    function generateRefreshToken(userId) {
      // Long lifespan: 7 days
      return jwt.sign({ sub: userId }, REFRESH_TOKEN_SECRET, { expiresIn: '7d' });
    }
    
    module.exports = {
      generateAccessToken,
      generateRefreshToken,
      ACCESS_TOKEN_SECRET,
      REFRESH_TOKEN_SECRET
    };
    

    Ключовий момент: завжди надавайте токену доступу та токену оновлення окремі секретні ключі. Якщо вони будуть ділитися одним секретом, хтось може взяти прострочений токен оновлення та використати його там, де потрібен токен доступу, обійшовши таким чином запланований термін дії.

    2. Обробка входу та зберігання токена оновлення

    Маршрут входу має зберігати токен оновлення — а краще його хеш SHA-256 — у вашій базі даних, прив’язаний до цього користувача, щоб можна було його знайти пізніше:

    // server.js
    const express = require('express');
    const cookieParser = require('cookie-parser');
    const jwt = require('jsonwebtoken');
    const {
      generateAccessToken,
      generateRefreshToken,
      ACCESS_TOKEN_SECRET,
      REFRESH_TOKEN_SECRET
    } = require('./tokenService');
    
    const app = express();
    app.use(express.json());
    app.use(cookieParser());
    
    // Simulated database table for refresh tokens
    const refreshTokenDatabase = new Set();
    
    app.post('/api/auth/login', async (req, res) => {
      const { email, password } = req.body;
    
      // In a real app: verify email and hashed password from DB
      const mockUserId = 'usr_987654';
    
      const accessToken = generateAccessToken(mockUserId);
      const refreshToken = generateRefreshToken(mockUserId);
    
      // Save the refresh token to our database so we can revoke it later
      refreshTokenDatabase.add(refreshToken);
    
      // Send refresh token inside an HttpOnly cookie
      res.cookie('refreshToken', refreshToken, {
        httpOnly: true,     // Prevents JavaScript from reading the cookie (protects from XSS)
        secure: process.env.NODE_ENV === 'production', // Requires HTTPS
        sameSite: 'strict', // Protects against CSRF attacks
        maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
      });
    
      // Send access token in JSON body for frontend memory storage
      res.json({ accessToken });
    });
    

    3. Захист бізнес-кінцевих точок

    Функція мідлверу перевіряє токен доступу, отриманий з заголовка Authorization, перш ніж дозволити запит пройти:

    function authenticateAccessToken(req, res, next) {
      const authHeader = req.headers['authorization'];
      // The header format is "Bearer <token>"
      const token = authHeader && authHeader.split(' ')[1];
    
      if (!token) {
        return res.status(401).json({ error: 'Access token missing' });
      }
    
      jwt.verify(token, ACCESS_TOKEN_SECRET, (err, decoded) => {
        if (err) {
          // 403 signals to the client that the token is invalid or expired
          return res.status(403).json({ error: 'Invalid or expired access token' });
        }
        req.userId = decoded.sub;
        next();
      });
    }
    
    // Protected route
    app.get('/api/orders', authenticateAccessToken, (req, res) => {
      res.json({
        message: `Orders retrieved for user ${req.userId}`,
        data: [{ id: 1, item: 'Mechanical Keyboard' }]
      });
    });
    

    4. Створення маршруту оновлення

    Як тільки клієнт отримує код 403 від запиту на адресу /api/orders, він звертається до цієї кінцевої точки, щоб отримати новий токен доступу:

    app.post('/api/auth/refresh', (req, res) => {
      const refreshToken = req.cookies.refreshToken;
    
      if (!refreshToken) {
        return res.status(401).json({ error: 'Refresh token missing' });
      }
    
      // Check if token exists in the database
      if (!refreshTokenDatabase.has(refreshToken)) {
        return res.status(403).json({ error: 'Refresh token revoked or unknown' });
      }
    
      jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, decoded) => {
        if (err) {
          // Remove invalid token from database
          refreshTokenDatabase.delete(refreshToken);
          return res.status(403).json({ error: 'Refresh token expired' });
        }
    
        // Token is valid! Issue a new access token
        const newAccessToken = generateAccessToken(decoded.sub);
        res.json({ accessToken: newAccessToken });
      });
    });
    

    5. Виходження з облікового запису та скасування доступу

    Видалення користувача є простим: потрібно видалити токен оновлення з вашої бази даних та очистити куку, яка його зберігає. Токен доступу не потребує особливого оброблення — він просто закінчить свою дію через кілька хвилин.

    app.post('/api/auth/logout', (req, res) => {
      const refreshToken = req.cookies.refreshToken;
    
      if (refreshToken) {
        refreshTokenDatabase.delete(refreshToken);
      }
    
      res.clearCookie('refreshToken');
      res.json({ message: 'Logged out successfully' });
    });
    

    Де мають знаходитися токени на клієнті?

    Постійно виникає питання про те, де фронтенд має зберігати ці токени.

    Частою помилкою є зберігання обох токенів у localStorage.

    Проблема полягає у тому, що будь-який скрипт, який виконується на вашій сторінці — чи то від компрометованої залежності npm, чи від вставленого внутрішнього тега — може прочитати дані з localStorage за допомогою простої команди localStorage.getItem('token').

    Більш безпечний підхід виглядає так:

    • Зберігайте токен доступу лише в пам’яті. Зберігайте його у стані роботи вашого додатку — у React Context, магазині Pinia або навіть у звичайній змінній. Оскільки він швидко закінчується та зникає в момент закриття вкладки, навіть успішна ін’єкція скрипту створює лише короткий період ризику.
    • Зберігайте токен оновлення у куку HttpOnly. JavaScript зовсім не має доступу до кук HttpOnly. Браузер автоматично додає її кожного разу, коли клієнт викликає /api/auth/refresh, що означає, що ваші довготривалі облікові дані ніколи не потрапляють у код, який може контролювати зловмисник.

    Посилення безпеки за допомогою ротації токенів оновлення

    Навіть із вищеописаною налаштуванням проста схема токенів оновлення має серйозну слабкість: якщо зловмисник зможе вилучити токен оновлення — чи то зі зберігання куки, чи шляхом перехоплення трафіку — він зможе створювати нові токени доступу протягом усього терміну дії цього токена оновлення, що може тривати до 7 днів.

    Продакшн-системи усувають цю проблему за допомогою ротації токенів оновлення.

    При ротації:

    • Щоразу, коли токен оновлення використовується для отримання нового токена доступу, сервер знищує старий токен оновлення та надає абсолютно новий.
    • Токени доступу та оновлення замінюються разом як пара, у кожному запиті.
    Request 1: Send Refresh Token A -> Receive Access Token 2 + Refresh Token B (Token A invalidated)
    Request 2: Send Refresh Token B -> Receive Access Token 3 + Refresh Token C (Token B invalidated)
    

    Автоматичне виявлення порушень

    Ротація токенів оновлення дозволяє використовувати потужний механізм захисту під назвою виявлення повторного використання.

    Refresh Token A.

    • Справжній користувач спочатку використовує Refresh Token A, і сервер відповідає Refresh Token B.
    • Через деякий час зловмисник намагається використати вже прострочений Refresh Token A.
    • Сервер перевіряє свої записи, бачить, що Refresh Token A вже був використаний, і позначає це як ознаку порушення безпеки.
    • Він реагує шляхом скасування всієї послідовності токенів — включаючи Refresh Token B — що змушує як зловмисника, так і законного користувача повернутися на екран входу.

    Замість того, щоб зловмисник тихо продовжувати свої дії на фоні, система реагує миттєво та блокує доступ усім до підтвердження нової автентифікації.

    Помилки, яких варто уникати

    Уважно стежте за цими чотирма поширеними помилками:

    • Вбудовування конфіденційної інформації в токен доступу. Вміст JWT кодується лише у форматі Base64, а не шифрується, тому будь-хто, хто його перехопить, може безпосередньо прочитати його вміст. Уникайте включення паролів, номерів карток та інших приватних даних до вмісту токена.
    • Дозволення накопичуванню записів токенів оновлення без кінця. Без поля про термін дії та процедури очищення таблиця з токенами оновлення буде рости необмежено. Додайте поле expires_at та заплануйте завдання для періодичного видалення записів, які вже закінчили термін дії.
  • Ігнорування ситуацій конкуренції на клієнтській стороні. Коли кілька викликів API відбуваються одночасно, а токен доступу вже закінчив свою дію, усі вони одночасно отримають код 403. Якщо ваш фронтенд у відповідь надсилає кілька паралельних викликів /refresh, логіка обробки буде сприймати це як повторне використання токена та завершить сесію. Щоб уникнути цього, розмістіть всі зовнішні запити у черзі після одного активного виклику оновлення токена.
  • Ситуації, коли один токен може бути достатнім

    Цей підхід із двома токенами є стандартним вибором для сучасних веб- та мобільних клієнтів, але він не є обов’язковим у всіх випадках:

    • Внутрішній трафік між сервісами: пристрої, які спілкуються між собою, зазвичай використовують процеси автентифікації клієнта, токени короткого терміну дії або механізми взаємного TLS, жоден з яких не потребує інтерактивного циклу оновлення токена.
    • Базові додатки, які генеруються на сервері: у випадку традиційного додатку, що генерується на серверній стороні — наприклад, створеного за допомогою класичних шаблонів Express — простий куки сеансу з зберіганням у оперативній пам’яті, такій як Redis, зазвичай простіший у керуванні та забезпечує подібний рівень безпеки.

    Використовуйте паттерн access-and-refresh-token саме тоді, коли створюєте односторінкові додатки (React, Vue чи мобільні клієнти), які взаємодіють із безстановими бекендами REST чи GraphQL.

    Підсумок

    Використання одного JWT для всього змушує робити непотрібний компроміс: або тримати сеанси короткими та дратувати користувачів частими входами, або тримати їх довгими та піддавати себе серйозним ризикам для безпеки.

    Розділення автентифікації на два токени усуває цю проблему:

    • Токени доступу роблять кожен запит швидким та без стану, захищаючи вашу базу даних від постійних пошуків.
    • Токени оновлення безпечно забезпечують довгострокову стабільність сеансів, даючи можливість миттєво скасувати компрометовані сеанси.

    Інвестування зусиль у правильна налаштування обох типів токенів принесе вам плавний досвід користувачів та систему, яка ефективно протистоїть безпековим проблемам, про які йдеться тут.