Strona główna / Artykuły

Wdrażanie tokenów dostępu i odświeżania razem w Node.js

Krótkotrwałe tokeny dostępu w połączeniu z rotującymi tokenami odnowienia w Node.js: mechanizm JWT, gdzie przechowywać każdy token oraz jak rotacja zapobiega kradzieży tokenów odnowienia.

Wydawca: REACTAPP.TOP11 min czytania2600 słów

Źródła: Access Tokens vs Refresh Tokens in Node.js: Why Real Applications Need Both

Gdy tworzysz swój pierwszy proces autoryzacji w Node.js, typowym podejściem jest korzystanie z pojedynczego tokena JSON Web Token (JWT). Użytkownik podaje adres e-mail i hasło, serwer je sprawdza, podpisuje token i zwraca go. Od tego momentu frontend umieszcza ten token w nagłówku Authorization przy każdej żądaniu.

Taka konfiguracja sprawdza się podczas testowania na localhost. Jednak gdy tylko rzeczywiści użytkownicy zaczną polegać na aplikacji, pojawia się problem strukturalny.

Ustaw okres ważności tokena na krótki czas, na przykład 15 minut, wtedy użytkownicy będą wylogowywani w trakcie wykonywania zadań — być może podczas pisania komentarza lub w połowie wypełniania formularza. Jeśli ustawisz go na dłuższy czas, na przykład 30 dni, użytkownicy pozostaną zalogowani, ale powstanie poważna luka bezpieczeństwa: jeśli token zostanie skradziony, nie będzie możliwości jego cofnięcia bez unieważnienia wszystkich tokenów wszystkich użytkowników w całym systemie.

To napięcie pomiędzy wygodą dla użytkowników a bezpieczeństwem jest dokładnie powodem, dla którego systemy produkcyjne nie polegają na jednym tokenie — używają dwóch: tokena dostępu oraz tokena odnowienia.

Rozważmy, dlaczego istnieje ten model dwóch tokenów, jaką rolę pełni każdy z nich oraz jak prawidłowo go zaimplementować w backendzie Node.js.

Główny problem z autoryzacją opartą na jednym tokenie

Zanim wyjaśnimy, dlaczego potrzebne są dwa tokeny, przydatne jest ponowne omówienie tego, co sprawia, że JWT są wyjątkowe: są one bezstanowe.

Gdy serwer Node.js otrzymuje JWT, nie musi korzystać z bazy danych, aby sprawdzić, czy sesja istnieje. Wystarczy mu porównać kryptograficzną podpis tokena z tajnym kluczem:

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

Bezstanowość sprawia, że API oparte na JWT są szybkie i łatwe do skalowania, ponieważ baza danych nie jest sprawdzana przy każdej przychodzącej prośbie. Jednak ta sama cecha ma również wadę: gdy token zostanie wydany, serwer nie ma łatwego sposobu na jego wcześnie unieważnienie.

Rozważmy następującą sekwencję zdarzeń:

  • Jeden token JWT jest skonfigurowany tak, aby był ważny przez 30 dni.
  • Złoczyńca dostaje go – być może poprzez atak XSS lub przechwytywanie ruchu w niezaszyfrowanej sieci.
  • Właściciel konta zauważa, że coś jest nie tak, i zmienia swoje hasło.
  • Złoczyńca kontynuuje działania, jakby nic się nie stało. Skradziony token pozostaje ważny teoretycznie do upływu swojego 30-dniowego okresu ważności, niezależnie od zmiany hasła.
  • Jednym z rozwiązań jest utrzymywanie czarnej listy anulowanych tokenów w systemie takim jak Redis, ale sprawdzanie tej listy przy każdej wywołanej funkcji API faktycznie zamienia Twój system tokenów „bez stanu” z powrotem w system ze stanem.

    To właśnie jest problem, który ma rozwiązać podejście z dwoma tokenami.

    Czym są tokeny dostępu i tokeny odnowienia?

    Zamiast powierzać jeden token zarządzanie zarówno rutynowym dostępem do API, jak i długoterminową persistencją logowania, obciążenie jest rozdzielone pomiędzy dwa tokeny:

    +------------------+-----------------------------+-----------------------------+
    | 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. Token dostępu

    Traktuj token dostępu jako krótkoterminowy przepustek. Klient dołącza go do nagłówków zwykłych żądań API — takich jak GET /api/orders lub POST /api/profile.

    Ponieważ jest ważny tylko przez krótki czas (zazwyczaj 10 do 15 minut), wszelkie potencjalne szkody spowodowane wyciekiem są ograniczone. Serwer może go zweryfikować wyłącznie poprzez sprawdzanie podpisów opartych na procesorze, bez konieczności dostępu do bazy danych, co zapewnia responsywność API.

    2. Token odnowienia

    Token odnowienia funkcjonuje jak długoterminowy vouchery na odnowienie. Nigdy nie jest wysyłany wraz z zwykłymi żądaniami API — jego jedynym zadaniem jest komunikacja z dedykowanym punktem końcowym, na przykład POST /api/auth/refresh.

    Gdy krótkotrwały token dostępu wygaśnie, klient cicho wywołuje tę trasę odświeżania. Serwer następnie sprawdza bazę danych, aby upewnić się, że konto użytkownika jest nadal aktywne i upoważnione, po czym wydaje nowy token dostępu.

    Ponieważ to wywołanie odświeżania ma miejsce mniej więcej co 15 minut, a nie przy każdej interakcji użytkownika, dodanie sprawdzenia w bazie danych prawie nie wpływa na wydajność.

    Życiowy cykl żądania

    Oto jak trzy strony – klient, serwer i baza danych – współpracują z upływem czasu:

    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 ────────┤                                 │
    

    Z punktu widzenia użytkownika cały ten proces odnowienia jest niewidoczny. Jego sesja po prostu nadal działa w tle, bez irytujących komunikatów „Session Expired”, które przerywałyby aktywne korzystanie.

    Zastosowanie w praktyce z Node.js

    A teraz stworzymy działającą konfigurację Express, która pokazuje, jak wydawać, sprawdzać i odnawiać tokeny.

    Najpierw zainstaluj wymagane pakiety:

    npm install express jsonwebtoken cookie-parser dotenv
    

    1. Tworzenie tokenów

    Stwórz moduł pomocniczy, który generuje oba tokeny, z których każdy jest podpisany własnym sekretem:

    // 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
    };
    

    Kluczowy punkt: zawsze przypisuj tokenowi dostępu oraz tokenowi odnowienia oddzielne klucze sekretne. Jeśli będą dzielić jeden sekret, ktoś może użyć wygasłego tokena odnowienia w miejscu, gdzie oczekiwany jest token dostępu, omijając w ten sposób okres ważności, który chciałeś wprowadzić.

    2. Obsługa logowania i przechowywanie tokena odnowienia

    Trasa logowania musi przechowywać token odnowienia – a jeszcze lepiej jego hash SHA-256 – w bazie danych, powiązany z danym użytkownikiem, aby można go było później odnaleźć:

    // 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. Ochrona punktów końcowych aplikacji

    Funkcja middleware sprawdza token dostępu pobrany z nagłówka Authorization, zanim pozwoli na przesłanie żądania:

    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. Tworzenie trasy odnowienia

    Gdy klient otrzyma błąd 403 po wywołaniu takiego adresu jak /api/orders, kieruje się do tego punktu końcowego, aby uzyskać nowy token dostępu:

    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. Wylogowanie i cofnięcie dostępu

    Wylogowanie użytkownika jest proste: należy usunąć token odświeżania z bazy danych oraz wymazać plik cookie, który go przechowuje. Token dostępu nie wymaga żadnego specjalnego traktowania – po prostu wygaśnie sam z siebie w ciągu kilku minut.

    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' });
    });
    

    Gdzie powinny znajdować się tokeny na stronie klienta?

    Często pojawia się pytanie, gdzie frontend powinien przechowywać te tokeny.

    Częstym błędem jest przechowywanie obu tokenów w localStorage.

    Problem polega na tym, że każdy skrypt uruchamiany na stronie – niezależnie od tego, czy pochodzi z kompromitowanej zależności npm, czy z wstrzykniętego tagu wewnętrznego – może odczytać zawartość localStorage za pomocą prostego wyrażenia localStorage.getItem('token').

    Bardziej bezpieczny model wygląda następująco:

    • Zachowuj token dostępu wyłącznie w pamięci. Przechowuj go w stanie czasu wykonywania aplikacji — w React Context, sklepie Pinia lub nawet zwykłej zmiennej. Ponieważ szybko wygasa i znika w momencie zamknięcia karty, nawet udana iniekcja skryptu stwarza jedynie krótki okres ryzyka.
    • Zachowuj token odnowienia w ciasteczku HttpOnly. JavaScript nie ma żadnego dostępu do ciasteczek HttpOnly. Przeglądarka automatycznie je dołącza za każdym razem, gdy klient wywołuje /api/auth/refresh, co oznacza, że twoje długotrwałe dane uwierzytelniające nigdy nie trafiają do kodu, który mógłby kontrolować atakujący.

    Zmniejszanie ryzyka poprzez rotację tokenów odnowienia

    Nawet przy powyższej konfiguracji zwykły schemat tokenów odświeżania ma poważną słabość: jeśli atakujący zdoła przejąć token odświeżania – czy to ze storage plików cookie, czy poprzez podsłuchiwanie ruchu sieciowego – może nadal tworzyć nowe tokeny dostępu tak długo, jak długo ten token odświeżania pozostaje ważny, potencjalnie przez całe 7 dni.

    Systemy produkcyjne eliminują tę lukę dzięki rotacji tokenów odświeżania.

    W ramach rotacji:

    • Za każdym razem, gdy token odświeżania jest wykorzystywany do uzyskania nowego tokena dostępu, serwer niszczy stary token odświeżania i przekazuje zupełnie nowy.
    • Tokeny dostępu i odświeżania są wymieniane razem, jako para, przy każdej prośbie.
    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)
    

    Automatyczne wykrywanie naruszeń

    Rotacja tokenów odświeżania umożliwia skuteczną ochronę zwana wykrywaniem ponownego użycia.

    Załóżmy scenariusz, w którym atakujący udaje mu się skopiować Refresh Token A.

    • Prawdziwy użytkownik najpierw uzupełnia Refresh Token A, a serwer odpowiada Refresh Token B.
    • Jakikolwiek czas później atakujący próbuje uzupełnić już przeterminowany Refresh Token A.
    • Serwer sprawdza swoje zapisy, widzi, że Refresh Token A został już wykorzystany, i oznacza to jako objaw naruszenia bezpieczeństwa.
    • Odpowiada poprzez cofnięcie całej łańcuchu tokenów – włączając Refresh Token B – co zmusza zarówno atakującego, jak i prawdziwego użytkownika do powrotu na ekran logowania.

    Zamiast atakujący działałby po cichu w tle, system reaguje natychmiast i blokuje dostęp wszystkim, dopóki nie nastąpi ponowna autoryzacja.

    Pułapki, których należy unikać

    Uważaj na te cztery często popełniane błędy:

    • Zawieranie wrażliwych informacji w tokenie dostępu. Treść JWT jest jedynie kodowana w formacie Base64, a nie szyfrowana, więc każdy, kto ją przechwyci, może bezpośrednio odczytać jej zawartość. Unikaj umieszczania haseł, numerów kart i innych poufnych danych w tej treści.
    • Pozwalanie na nieograniczone gromadzenie rekordów tokenów odnowienia. Bez pola określającego termin ważności oraz procedury czyszczenia tabela tokenów odnowienia będzie rosła bez końca. Dodaj pole expires_at i ustaw zadanie, które okresowo usuwa wygasłe wpisy.
  • Niezauważanie sytuacji konkurencyjnych po stronie klienta. Gdy kilka wywołań API zostanie wysłanych jednocześnie, a token dostępu już wygasł, wszystkie one jednocześnie otrzymają błąd 403. Jeśli frontend w odpowiedzi wyśle kilka równoległych żądań /refresh, logika rotacji zinterpretuje to jako ponowne użycie tokena i zakończy sesję. Aby temu zapobiec, należy umieścić wszystkie żądania wyjściowe w kolejce za jednym bieżącym żądaniem odnowienia.
  • Sytuacje, w których jeden token może wystarczyć

    To podejście z dwoma tokenami jest standardowym wyborem dla nowoczesnych klientów internetowych i mobilnych, ale nie jest obowiązkowe we wszystkich przypadkach:

    • Wewnętrzny ruch między usługami: maszyny komunikujące się ze sobą zazwyczaj korzystają z mechanizmów uwierzytelniania opartych na danych klienta, krótkotrwałych tokenów lub mutual TLS – żaden z tych rozwiązań nie wymaga interaktywnego cyklu odnowienia.
    • Bazowe aplikacje renderowane na serwerze: w przypadku tradycyjnej aplikacji renderowanej po stronie serwera – na przykład takiej zbudowanej za pomocą klasycznych szablonów Express – prosty plik cookie sesji przechowywany w pamięci, jak Redis, zazwyczaj jest łatwiejszy w zarządzaniu i oferuje porównywalny poziom bezpieczeństwa.

    Należy użyć wzorca access-and-refresh-token szczególnie przy tworzeniu aplikacji jednostronicowych (React, Vue lub klientów mobilnych), które komunikują się z bezstanowymi backendami typu REST lub GraphQL.

    Podsumowanie

    Używanie jednego tokena JWT do wszystkiego zmusza do dokonania kompromisu, którego nie powinno się podejmować: albo trzymać sesje krótkie i irytować użytkowników częstymi logowaniami, albo trzymać je długie i narażać się na poważne zagrożenia bezpieczeństwa.

    Rozdzielenie autoryzacji na dwa tokeny rozwiązuje ten konflikt:

    • Tokeny dostępu sprawiają, że każda prośba jest szybka i bezstanowa, chroniąc twoją bazę danych przed ciągłymi wyszukiwaniami.
    • Tokeny odnowienia bezpiecznie zarządzają długoterminowym przechowywaniem sesji, umożliwiając natychmiastowe cofnięcie uprawnień do sesji, które zostały skompromitowane.

    Inwestycja w prawidłowe skonfigurowanie obu tokenów przynosi korzyści w postaci płynnego doświadczenia dla użytkowników oraz systemu dobrze chronionego przed problemami bezpieczeństwa omawianymi tutaj.