前端Token最佳实践
本指南介绍前端应用程序中管理认证Token的现代最佳实践,重点涵盖安全性、性能和用户体验,特别针对前后端分离架构优化 httpOnly Cookie 的使用,结合 Nginx 反向代理简化跨域处理。内容包括Token概述、存储方式、安全策略、生命周期管理以及多标签页同步等高级功能。
1. Token概述
1.1 什么是Token?
Token 是一种用于身份验证和授权的加密字符串,代表用户身份或访问权限。JSON Web Token(JWT)是目前最常用的Token格式。
1.2 JWT结构
JWT 由三部分组成,用点号(.)分隔:
Header.Payload.Signature
- Header:定义算法和Token类型,例如:
{ "alg": "HS256", "typ": "JWT" }。 - Payload:包含用户信息和声明,例如:
{ "sub": "user123", "exp": 1697059200 }。 - Signature:用于验证Token的真实性和数据完整性。使用密钥对编码后的Header和Payload进行签名生成。
2. Token存储:选项与最佳实践
在前后端分离架构中,Token 存储需考虑跨域请求、CORS 配置和安全性。Nginx 作为反向代理可简化跨域处理,统一前后端域名或支持跨域 Cookie 传输。以下比较浏览器主要存储选项,并提供针对前后端分离的 httpOnly Cookie 最佳实践。
2.1 存储选项对比
| 属性 | Cookie | sessionStorage | localStorage |
|---|---|---|---|
| 存储容量 | 4KB(约50个/域名) | 5-10MB(依浏览器) | 5-10MB(依浏览器) |
| 生命周期 | 可配置过期时间,默认会话结束 | 标签页关闭清除 | 持久存储,需手动清除 |
| 作用域 | 同源+路径,可跨标签页 | 同源+同标签页 | 同源,可跨标签页 |
| 服务器交互 | 自动随HTTP请求发送 | 仅客户端存储 | 仅客户端存储 |
| 安全性 | ⭐⭐⭐⭐(启用httpOnly时) | ⭐⭐⭐ | ⭐⭐ |
| Token适用场景 | Refresh Token(httpOnly) | 临时Access Token | 加密后备用方案 |
| 主要风险 | CSRF攻击 | XSS+标签页关闭丢失 | XSS攻击 |
2.2 测试存储容量
以下代码用于验证 sessionStorage 和 localStorage 的实际容量限制:
function testStorageCapacity(storage) {
let data = '', size = 0;
try {
const oneMB = 'x'.repeat(1024 * 1024);
while (true) {
storage.setItem('test', data + oneMB);
data += oneMB;
size += 1;
}
} catch (e) {
storage.removeItem('test');
return `${size}MB`;
}
}
console.log('localStorage:', testStorageCapacity(localStorage));
console.log('sessionStorage:', testStorageCapacity(sessionStorage));
2.3 Token存储最佳实践
2.3.1 推荐策略:双Token机制
- Access Token:短期(15-30分钟),存储于内存,用于API调用。安全性高,无XSS/CSRF风险,但页面刷新会丢失。
- Refresh Token:长期(7-30天),存储于
httpOnlyCookie,用于刷新Access Token。防止XSS攻击,配合配置可防CSRF。
优势:
- Access Token 短期过期,降低泄露风险。
- Refresh Token 无法被 JavaScript 访问,安全性高。
- 自动刷新机制确保无缝用户体验。
2.3.2 存储实现
-
httpOnly Cookie(Refresh Token) :
-
原因:阻止 JavaScript 访问(防XSS),自动随 HTTP 请求发送,支持
secure和SameSite属性防 CSRF。 -
前后端分离实现:
在前后端分离场景中,前端(如frontend.example.com)和后端(如api.example.com)通常位于不同域名,涉及跨域请求。Nginx 反向代理可统一域名或支持跨域 Cookie 传输。方案1:Nginx 统一域名(推荐) :
使用 Nginx 反向代理将前端和后端请求路由到同一顶级域名(如example.com),避免跨域问题:server { listen 443 ssl; server_name example.com; ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; # 前端路由 location / { proxy_pass https://frontend.example.com; proxy_set_header Host frontend.example.com; proxy_set_header X-Real-IP $remote_addr; } # API 路由 location /api/ { proxy_pass https://api.example.com/; proxy_set_header Host api.example.com; proxy_set_header X-Real-IP $remote_addr; # 确保 Cookie 传输 proxy_set_header Cookie $http_cookie; proxy_pass_request_headers on; } }-
优势:前端和后端请求使用同一域名(如
example.com),无需跨域,sameSite: 'strict'可直接使用,增强 CSRF 保护。 -
Cookie 设置(服务端,Node.js/Express):
const express = require('express'); const cookieParser = require('cookie-parser'); const app = express(); app.use(cookieParser()); // 登录接口 app.post('/api/login', (req, res) => { const refreshToken = generateRefreshToken(); res.cookie('refreshToken', refreshToken, { httpOnly: true, // 防 XSS secure: true, // 仅 HTTPS sameSite: 'strict', // 防 CSRF(同一域名) maxAge: 7 * 24 * 60 * 60 * 1000, // 7天 path: '/' }); res.json({ accessToken: generateAccessToken() }); }); // 刷新接口 app.post('/api/refresh', (req, res) => { const refreshToken = req.cookies.refreshToken; if (!refreshToken || !verifyRefreshToken(refreshToken)) { return res.status(401).json({ error: '无效的Refresh Token' }); } const newAccessToken = generateAccessToken(); const newRefreshToken = generateRefreshToken(); res.cookie('refreshToken', newRefreshToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000, path: '/' }); res.json({ accessToken: newAccessToken }); }); -
客户端请求:
// 登录请求 async function login(credentials) { const response = await fetch('https://example.com/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), credentials: 'include' // 携带 Cookie }); const { accessToken } = await response.json(); setAccessToken(accessToken); } // 刷新请求 async function refreshToken() { const response = await fetch('https://example.com/api/refresh', { method: 'POST', credentials: 'include' }); const { accessToken } = await response.json(); setAccessToken(accessToken); broadcastToken(accessToken); return accessToken; }
方案2:Nginx 支持跨域:
如果无法统一域名,Nginx 可通过添加 CORS 头支持跨域 Cookie 传输:server { listen 443 ssl; server_name api.example.com; ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; location / { proxy_pass http://backend:3000; # 后端服务 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # 添加 CORS 头 add_header 'Access-Control-Allow-Origin' 'https://frontend.example.com'; add_header 'Access-Control-Allow-Credentials' 'true'; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; add_header 'Access-Control-Allow-Headers' 'X-CSRF-Token, Content-Type'; # 处理 OPTIONS 预检请求 if ($request_method = 'OPTIONS') { add_header 'Access-Control-Max-Age' 1728000; return 204; } } }-
服务端设置(Node.js/Express,结合 Nginx):
const express = require('express'); const cookieParser = require('cookie-parser'); const app = express(); app.use(cookieParser()); // 登录接口 app.post('/login', (req, res) => { const refreshToken = generateRefreshToken(); res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: true, sameSite: 'none', // 跨域场景 maxAge: 7 * 24 * 60 * 60 * 1000, path: '/', domain: '.example.com' }); res.json({ accessToken: generateAccessToken(), csrfToken: generateCSRFToken() }); }); // 刷新接口 app.post('/refresh', (req, res) => { const refreshToken = req.cookies.refreshToken; if (!refreshToken || !verifyRefreshToken(refreshToken)) { return res.status(401).json({ error: '无效的Refresh Token' }); } const newAccessToken = generateAccessToken(); const newRefreshToken = generateRefreshToken(); res.cookie('refreshToken', newRefreshToken, { httpOnly: true, secure: true, sameSite: 'none', maxAge: 7 * 24 * 60 * 60 * 1000, path: '/', domain: '.example.com' }); res.json({ accessToken: newAccessToken }); }); -
CSRF 防护:
// 服务端设置 CSRF Token res.cookie('csrfToken', csrfToken, { httpOnly: false, // 客户端可读 secure: true, sameSite: 'none' }); // 客户端携带 CSRF Token fetch('https://api.example.com/protected', { method: 'POST', headers: { 'X-CSRF-Token': getCookie('csrfToken'), 'Content-Type': 'application/json' }, credentials: 'include' });
-
-
-
内存存储(Access Token) :
-
原因:最高安全性,无磁盘操作,适合短期 Token。
-
实现:
let accessToken = null; function setAccessToken(token) { accessToken = token; } function getAccessToken() { return accessToken; }
-
-
sessionStorage(临时Access Token) :
-
原因:标签页隔离,适合临时存储。
-
实现:
sessionStorage.setItem('accessToken', token); const token = sessionStorage.getItem('accessToken'); -
注意:易受 XSS 攻击,标签页关闭丢失。
-
-
localStorage(加密备用-谨慎使用) :
-
原因:仅作为一种满足“Token不应明文存储”的合规性要求的折中方案,或对存储内容进行轻度混淆。请注意,由于加密密钥必然暴露于前端代码中,它无法有效防御具有XSS能力的攻击者。
-
实现(AES 加密):
import CryptoJS from 'crypto-js'; const secureStorage = { setItem(key, value) { const encrypted = CryptoJS.AES.encrypt(JSON.stringify(value), 'secret-key').toString(); localStorage.setItem(key, encrypted); }, getItem(key) { const encrypted = localStorage.getItem(key); if (!encrypted) return null; const bytes = CryptoJS.AES.decrypt(encrypted, 'secret-key'); return JSON.parse(bytes.toString(CryptoJS.enc.Utf8)); } }; -
注意:首选方案永远是将敏感Token存储在
httpOnly Cookie或内存中。此方案仅在充分评估风险后使用。
-
2.3.3 前后端分离中的注意事项
-
Nginx 统一域名:
- 将前端和后端路由到同一顶级域名(如
example.com),避免跨域,简化 CORS 和sameSite配置。 - 使用
sameSite: 'strict'增强 CSRF 保护,无需额外 CSRF Token。
- 将前端和后端路由到同一顶级域名(如
-
Nginx 跨域支持:
- 配置 CORS 头,明确允许前端域名,启用
Access-Control-Allow-Credentials。 - 使用
sameSite: 'none'和secure: true支持跨域 Cookie 传输。 - 结合 CSRF Token 防止跨站请求伪造。
- 配置 CORS 头,明确允许前端域名,启用
-
安全性:
- 强制 HTTPS,确保
secure: true。 - 定期轮换 Refresh Token,设置合理过期时间(如 7 天)。
- 后端维护 Token 黑名单,防止被盗用。
- 强制 HTTPS,确保
-
性能:
- 避免频繁刷新,合理设置 Access Token 过期时间(15-30 分钟)。
- 使用内存存储 Access Token,减少磁盘操作。
-
替代方案:
- 大数据存储使用
IndexedDB。 - 跨标签页通信使用
BroadcastChannel API。
- 大数据存储使用
2.3.4 指导原则
- 优先级:
httpOnlyCookie(Refresh Token) > 内存(Access Token) >sessionStorage(临时) >localStorage(加密备用)。 - 避免:未加密存储敏感 Token 于
localStorage。 - 双Token机制:Access Token 短期,Refresh Token 长期,结合自动刷新提升体验。
3. Token管理系统
3.1 自动Token刷新
-
策略:在 Access Token 过期前 5 分钟自动刷新,确保无缝用户体验。
-
功能:
- 请求队列控制并发刷新。
- 静默刷新,无用户干扰。
- 刷新失败跳转登录页。
-
实现:
let isRefreshing = false; const refreshQueue = []; async function refreshToken() { if (isRefreshing) { return new Promise(resolve => refreshQueue.push(resolve)); } isRefreshing = true; try { const response = await fetch('https://example.com/api/refresh', { method: 'POST', credentials: 'include' }); const { accessToken } = await response.json(); setAccessToken(accessToken); broadcastToken(accessToken); // 同步多标签页 refreshQueue.forEach(resolve => resolve(accessToken)); refreshQueue.length = 0; return accessToken; } catch (error) { redirectToLogin(); throw error; } finally { isRefreshing = false; } } // 调度刷新 function scheduleRefresh(expiryTime) { const refreshTime = expiryTime - Date.now() - 5 * 60 * 1000; setTimeout(refreshToken, refreshTime); }
3.2 HTTP请求拦截器
拦截器自动处理 Token 添加和未授权响应。
import axios from 'axios';
const http = axios.create({ baseURL: 'https://example.com/api' });
http.interceptors.request.use(config => {
const token = getAccessToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
http.interceptors.response.use(
response => response,
async error => {
if (error.response?.status === 401) {
const newToken = await refreshToken();
if (newToken) {
error.config.headers.Authorization = `Bearer ${newToken}`;
return http(error.config); // 重试原始请求
}
redirectToLogin();
}
throw error;
}
);
3.3 多标签页同步
使用 BroadcastChannel 实现多标签页 Token 状态同步。
const channel = new BroadcastChannel('token_sync');
channel.onmessage = (event) => {
setAccessToken(event.data.token); // 更新其他标签页 Token
};
// 刷新后广播
function broadcastToken(token) {
channel.postMessage({ token });
}
4. Token生命周期
Token 生命周期确保安全认证和流畅用户体验。
flowchart TD
A[用户登录] --> B[服务端验证]
B --> C{验证成功?}
C -->|是| D[生成Access Token + Refresh Token]
C -->|否| E[返回错误信息]
D --> F[设置httpOnly Cookie存储Refresh Token]
F --> G[返回Access Token给客户端]
G --> H[Access Token存储到内存]
H --> I[调度自动刷新任务]
I --> J[监听Token过期]
J --> K{Token即将过期?}
K -->|是| L[自动刷新Token]
K -->|否| M[继续监听]
L --> N[使用Refresh Token请求新Token]
N --> O{刷新成功?}
O -->|是| P[更新Access Token]
O -->|否| Q[清除所有Token]
P --> R[重新调度刷新任务]
Q --> S[跳转登录页]
M --> T[用户发起API请求]
T --> U[请求拦截器添加Token]
U --> V[发送请求到服务端]
V --> W{Token有效?}
W -->|是| X[返回数据]
W -->|否| Y[返回401错误]
Y --> Z[响应拦截器捕获401]
Z --> L
X --> AA[请求完成]
R --> J
多标签页同步机制
sequenceDiagram
participant Tab1 as 标签页1
participant BroadcastChannel as BroadcastChannel
participant Tab2 as 标签页2
participant Tab3 as 标签页3
Tab1->>Tab1: Token刷新成功
Tab1->>BroadcastChannel: 广播Token更新消息
BroadcastChannel->>Tab2: 接收Token更新
BroadcastChannel->>Tab3: 接收Token更新
Tab2->>Tab2: 更新本地Token状态
Tab3->>Tab3: 更新本地Token状态
Note over Tab1,Tab3: 所有标签页Token状态同步
5. 服务端配置
5.1 登录接口
- 验证用户凭证。
- 生成 Access Token(15分钟过期)和 Refresh Token(7天过期)。
- 设置
httpOnlyCookie 存储 Refresh Token。 - 返回 Access Token 和用户信息。
5.2 刷新接口
- 验证 Refresh Token 有效性。
- 检查 Token 黑名单。
- 生成新的 Access 和 Refresh Token。
- 更新
httpOnlyCookie。 - 返回新 Token。
5.3 安全配置
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict', // 统一域名场景
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/'
});
6. 错误处理与性能优化
6.1 错误处理
- 网络错误:最多重试 3 次,采用指数退避策略。
- Token过期:捕获 401 错误,自动刷新。
- Refresh Token失效:清除 Token,跳转登录。
- 并发刷新:使用请求队列避免重复刷新。
6.2 性能优化
- 缓存:Access Token 存储于内存,减少磁盘 I/O。
- 并发控制:防抖机制处理高频 Token 验证。
- 刷新时机:过期前 5 分钟刷新,减少不必要网络请求。
7. 最佳实践总结
-
安全性:
- Access Token 存储于内存,Refresh Token 使用
httpOnlyCookie。 - 使用 Nginx 统一域名,启用
sameSite: 'strict',或通过 CORS 支持跨域并结合 CSRF Token。 localStorage仅用于加密后备用,并明确其安全局限性。
- Access Token 存储于内存,Refresh Token 使用
-
用户体验:
- 静默刷新,无用户干扰。
- 多标签页同步通过
BroadcastChannel。 - 网络异常自动重试。
-
系统健壮性:
- 完善的错误处理和 Token 撤销机制。
- 请求队列控制并发刷新。
-
性能优化:
- 内存缓存 Access Token。
- 优化刷新调度。
- 防抖验证降低开销。