很多开发者在做需要身份登录的Web应用时,都会用到OpenID Connect加JWT的组合,但不少人踩了Token存储的坑,导致应用被攻击者趁虚而入。
一、搞懂:OpenID Connect和JWT为啥要绑一起?
1.1 分开用的时候各自干嘛?
举个生活化的例子:OpenID Connect就像学校的学生证,能直接证明你是本校的合法学生,是身份认证的核心;JWT更像你去图书馆借书记的专用借书卡,上面写着你的名字、借书的权限范围,还有图书馆盖的专用章,能快速验证你能不能借某本书,是授权的凭证。两个绑在一起用,相当于既有能证明身份的学生证,又有能快速使用权限的借书卡,不用反复验证身份。
1.2 绑一起后的Token是个啥?
简单说,就是两者的结合体:这个Token里既有来自OpenID的身份信息,又有来自JWT的权限信息,相当于一张“带身份的临时权限卡”,不用每次请求都带着用户的密码,就能快速完成身份和权限的验证。
二、常见的Token存储坑,新手几乎都踩过
2.1 把Token存在前端的localStorage里
很多新手登录后,直接把JWT存在浏览器的localStorage里,觉得是前端的存储,很方便。但实际上,localStorage是前端JS可读可写的,如果网页有XSS漏洞(比如有人偷偷注入恶意脚本),这个脚本就能直接读取localStorage里的Token,然后发给黑客,黑客就能用这个Token冒充你的身份去操作应用,比如修改信息、删除数据等。这就像你把家门钥匙放在门口鞋架上,任何人都能拿走开门,风险极高。
2.2 存在Cookie但没设安全属性
有些开发者把Token存在Cookie里,但只填了内容,没设置安全属性,比如没开HttpOnly、Secure、SameSite。这种情况下,如果有XSS漏洞,前端脚本能直接读到Cookie里的Token;如果有人诱导你点击了伪造的链接(CSRF攻击),这个链接的请求会自动带上你的Cookie,黑客就能用你的权限操作账号,就像有人拿着你落在包里的钥匙,偷偷用你的家门开门。
三、安全的Token存储方案,亲测靠谱
3.1 核心方案:HttpOnly + Secure Cookie 存Refresh Token
Refresh Token是用来刷新过期的Access Token的,有效期通常设为7天左右,属于长期凭证,要放在最安全的地方——HttpOnly + Secure的Cookie里。
- HttpOnly属性:禁止前端JS读取这个Cookie,彻底堵死XSS偷凭证的路子;
- Secure属性:只有HTTPS加密环境下才会传输这个Cookie,避免被中间人窃听;
- SameSite属性:只允许同网站的请求携带这个Cookie,阻止跨站请求伪造(CSRF)。 下面是Node.js+Express的实现示例,完整且注释清晰:
// 技术栈:Node.js + Express.js
const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();
const PORT = 3000;
// 模拟用户登录成功,生成双Token
app.post('/login', (req, res) => {
// 假设数据库验证后返回的用户信息
const userInfo = { userId: 123, username: 'test_user' };
// 1. 生成Access Token:有效期15分钟,短有效期降低泄露风险
const accessToken = jwt.sign(userInfo, process.env.JWT_ACCESS_SECRET, { expiresIn: '15m' });
// 2. 生成Refresh Token:有效期7天,仅用于刷新Access Token
const refreshToken = jwt.sign({ userId: userInfo.userId }, process.env.JWT_REFRESH_SECRET, { expiresIn: '7d' });
// 核心安全设置:Refresh Token存入Cookie,严格限制访问规则
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // 前端JS无法读取,防XSS
secure: process.env.NODE_ENV === 'production', // 生产环境才启用HTTPS校验
sameSite: 'strict', // 仅同站请求携带Cookie,防CSRF
maxAge: 7 * 24 * 60 * 60 * 1000, // 7天有效期
path: '/api/auth' // 仅认证相关接口可携带该Cookie,缩小风险范围
});
// 仅把Access Token返回给前端,前端自行处理存储
res.json({ accessToken });
});
app.listen(PORT, () => console.log('服务运行在http://localhost:3000'));
3.2 双Token配套:Access Token存前端内存,不碰本地存储
Access Token有效期短(比如15分钟),就算被偷,危害也很小。前端应该把Access Token存在内存里(比如Vue的响应式变量、React的State),不要存到localStorage或sessionStorage里。这样就算有XSS漏洞,内存里的Token在页面刷新后就会消失,进一步降低风险。 下面是前端JavaScript+Axios的请求拦截示例,自动处理Token过期:
// 技术栈:前端JavaScript + Axios
import axios from 'axios';
let currentAccessToken = null; // 存在内存里的Access Token
// 初始化Axios实例,配置基础请求地址
const apiClient = axios.create({
baseURL: 'http://localhost:3000/api',
timeout: 5000
});
// 请求拦截:每次自动带上当前有效的Access Token
apiClient.interceptors.request.use(config => {
if (currentAccessToken) {
config.headers.Authorization = `Bearer ${currentAccessToken}`;
}
return config;
}, error => Promise.reject(error));
// 响应拦截:处理Token过期的情况
apiClient.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
// 如果返回401(Token过期),且没尝试过刷新,就用Refresh Token换新的Access Token
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 刷新接口会自动带上HttpOnly Cookie里的Refresh Token,无需前端手动传
const refreshRes = await apiClient.post('/auth/refresh-token');
// 更新内存里的Access Token
currentAccessToken = refreshRes.data.accessToken;
// 重新发起原来的请求
originalRequest.headers.Authorization = `Bearer ${currentAccessToken}`;
return apiClient(originalRequest);
} catch (refreshErr) {
// 刷新失败,说明Refresh Token也过期了,跳转到登录页
window.location.href = '/login';
return Promise.reject(refreshErr);
}
}
return Promise.reject(error);
}
);
3.3 不推荐的方案:Access Token加密后存本地
有些开发者会把Access Token加密后存在localStorage里,觉得这样就安全,但实际上,前端的加密逻辑是公开的,黑客只要拿到加密的Token,就能破解获取明文,不如直接存在内存里更安全,还能避免加密带来的性能消耗。
四、这个方案适合哪些场景?
4.1 前后端分离的Web应用
比如用Vue、React做的单页应用(SPA),前端和后端完全分开,没有后端渲染的页面,这个方案既能保证Token的存储安全,又能实现无感知刷新Token,不用让用户频繁登录,体验和安全兼顾。
4.2 移动端H5页面(混合App内的内嵌页面)
很多混合App会用内嵌H5页面做功能,比如电商App里的会员中心,用这个方案的话,HttpOnly Cookie在WebView里,不容易被App内的其他脚本偷取,同时Refresh Token的有效期长,不用频繁让用户登录。
4.3 第三方授权登录场景
比如你的应用支持用微信、QQ账号登录,用OpenID Connect和JWT结合的这个存储方案,能保证你的应用生成的Token不被第三方窃取,同时符合OAuth2.0的安全规范,避免授权过程中的风险。
五、优缺点和注意事项
5.1 方案的核心优点
- 防XSS攻击:Refresh Token存在HttpOnly Cookie,前端JS完全读不到,就算网页有XSS漏洞,也偷不到长期凭证;
- 防CSRF攻击:SameSite属性阻止跨站请求携带Cookie,避免被别人模仿用户的操作;
- 危害可控:Access Token有效期短(15分钟),就算被偷,最多15分钟就失效,不会造成长期损失;
- 符合规范:是OpenID Connect和OAuth2.0官方推荐的双Token机制,兼容性好。
5.2 必须注意的细节
- 生产环境必须用HTTPS:不然Cookie的Secure属性不会生效,Refresh Token会被明文传输,等于没设;
- Refresh Token有效期不要太长:最多7天,超过的话就算安全措施到位,也会增加泄露的风险,必要时可以让用户定期重新登录;
- 不要在Refresh Token里存敏感信息:只存用户ID就行,其他权限信息放在Access Token里,因为Access Token会频繁传给后端,暴露的概率更高;
- 缩小Cookie的作用范围:给Refresh Token的Cookie设置path和domain属性,只让需要的接口能携带这个Cookie,减少风险的影响范围。
六、总结
OpenID Connect和JWT的组合是目前主流的身份认证方案,而Token存储的安全性是整个认证体系的核心。本文介绍的HttpOnly+Secure Cookie存Refresh Token、内存存短有效期Access Token的方案,是经过大量实际项目验证的安全方案,能有效抵御XSS和CSRF等常见攻击,同时保证用户的使用体验。开发者在落地这个方案时,一定要严格设置Cookie的安全属性,避免踩新手常犯的存储坑,构建可靠的身份认证机制。
评论
围绕“OpenID Connect与JWT结合使用时的Token存储安全方案”参与讨论