JWT是什么?彻底搞懂无状态认证原理与结构
很多刚入行的同学听到 JWT(JSON Web Token)就头大,觉得这玩意儿玄乎。打个比方,它就是一串长得像乱码一样的字符串,用来在前后端之间证明“你是谁”。咱们不搞那些虚头巴脑的定义,直接看它解决了什么痛点。
在以前,咱们做登录认证,玩的是 Session。用户登录成功,服务端往内存里塞个 Session,给用户个 SessionID 存 Cookie。下次请求,服务端拿着 SessionID 去内存里翻,找到了就放行。这套逻辑在单机时代没毛病,但一旦你上了分布式架构,或者做了前后端分离,SPA 应用满天飞,Session 的毛病就出来了:服务端得存东西,压力大;跨域麻烦;多台服务器之间还得同步 Session,简直是运维噩梦。
这时候 JWT 就登场了,它主打一个 无状态(Stateless)。根据 RFC 7519(2015年发布,这标准稳得一批,这么多年没大变过)的定义,JWT 的核心思想是:服务端不存任何会话数据,用户的信息直接打包在 Token 里发给客户端。每次请求,客户端带上这个 Token,服务端自己解开验证一下,没毛病就放行。
一个标准的 JWT 长这样:xxxxx.yyyyy.zzzzz,用点(.)分成了三部分。咱们拆开揉碎了看:
- Header(头部):这里放的是元数据,比如 Token 类型(
typ: "JWT")和用的签名算法(比如 alg: "HS256")。换个角度看,就是告诉服务端:“嘿,我是 JWT,我用 HMAC SHA256 签的名,你待会儿验签的时候注意点。”
- Payload(载荷):这是核心,存放实际的数据。除了官方规定的几个字段(比如
exp 过期时间、iat 签发时间),你可以往里塞东西,比如用户 ID、用户名、角色。
- 关键点:Payload 这部分是 Base64URL 编码,不是加密!不是加密!不是加密!重要的事情说三遍。任何人拿到 Token 都能解码看到里面的内容。所以,千万别往里放密码、银行卡号这种敏感信息。
- Signature(签名):这是安全的关键。服务端会用密钥(Secret),把 Header 和 Payload 再加上一个算法,算出一个签名串。如果有人篡改了 Payload 里的用户 ID,服务端用密钥一算,发现签名对不上,立马就知道这 Token 是假的。
现在的趋势是,大家都在往微服务、零信任架构(Zero Trust)上靠,JWT 作为身份凭证的标准载体,那是相当吃香。特别是在单点登录(SSO)场景,一个 Token 到处跑,爽得不行。
💡 经验总结:别把 JWT 当保险箱
虽然 JWT 有签名防篡改,但 Payload 是明文。如果你在 Payload 里放了用户的邮箱或者手机号,虽然别人改不了,但能看见。如果你的业务对隐私要求极高,或者需要传输极度敏感的数据,别光用 JWS(签名),得考虑上 JWE(JSON Web Encryption)把整个 Token 加密了。不过对于大多数后台管理系统或普通 API,JWS 足够了。
---
5分钟上手:Node.js + jsonwebtoken 9.x 签发Token
理论听多了容易困,咱们直接撸代码。在 Node.js 生态里,处理 JWT 最主流的库就是 jsonwebtoken。写这篇教程的时候,我特意去查了下,目前最新的稳定版是 9.0.2(2023年6月发布的),这个版本修复了一些安全漏洞,大家用的时候记得 npm install jsonwebtoken@9 锁定这个版本,别用太老的。
咱们先搭个最简单的环境,模拟用户登录成功后,服务端签发 Token 的过程。
首先,初始化项目并安装依赖:
mkdir jwt-demo && cd jwt-demo
npm init -y
npm install jsonwebtoken@9.0.2 express@4
然后,新建一个 app.js,咱们写个登录接口:
const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();
// 中间件:用来解析 POST 请求发来的 JSON 数据
app.use(express.json());
// 定义一个超级密钥,实际项目中请务必放在环境变量里,别硬编码!
// 关键点:这个密钥一旦泄露,别人就能伪造你的 Token,服务器就裸奔了。
const JWT_SECRET = 'your-super-secret-key-keep-it-safe-123456';
// 模拟数据库里的用户
const mockUser = {
id: 101,
username: 'coder_buddy',
password: '123456' // 实际项目中密码肯定是加密存储的,这里只是演示
};
// 登录接口
app.post('/login', (req, res) => {
const { username, password } = req.body;
// 1. 验证用户名和密码(这里简单模拟一下)
if (username === mockUser.username && password === mockUser.password) {
// 2. 准备 Payload(别放敏感信息!)
const payload = {
userId: mockUser.id,
role: 'admin'
// 这里可以加个 exp,但咱们用 jwt.sign 的 options 控制更方便
};
// 3. 签发 Token
// 参数:payload, 密钥, 配置项(过期时间)
const token = jwt.sign(
payload,
JWT_SECRET,
{
expiresIn: '1h' // 1小时后过期,支持 '2d', '7d', '120s' 等写法
}
);
// 4. 返回给前端
res.json({
message: '登录成功!',
token: token
});
} else {
res.status(401).json({ message: '用户名或密码错误' });
}
});
app.listen(3000, () => {
console.log('服务器跑在 http://localhost:3000');
});
跑起来之后,你用 Postman 或者 curl 发个 POST 请求到 http://localhost:3000/login,带上 {"username": "coder_buddy", "password": "123456"},就能拿到一个很长很长的字符串。
可以这么理解,jwt.sign 就是个打包机。它把你的数据(Payload)、你的密钥、还有过期时间搅在一起,算出一个签名,拼成那三段式的字符串扔给你。
🔧 实战技巧:区分 AccessToken 和 RefreshToken
现在的开发趋势,尤其是移动端和 SPA,都在推 双 Token 机制。上面代码里我们签发的其实叫 AccessToken,通常有效期很短(比如 15分钟),为了安全。但老让用户重新登录太烦了,所以一般还会签发一个有效期长(比如 7天)的 RefreshToken。AccessToken 过期了,拿 RefreshToken 去换一个新的,这叫 Refresh Token 轮换策略,能大大降低 Token 被盗用后的风险。别一上来就给 AccessToken 设置 7 天有效期,那是给自己挖坑。
---
实战鉴权:Express中间件验证JWT与错误处理
拿到了 Token,前端怎么用?通常是放在 HTTP 请求头的 Authorization 字段里,格式一般是 Bearer 。那后端怎么验证呢?咱们不能在每个接口里都写一遍验证代码,太蠢了。这时候就得用上 Express 的中间件(Middleware)了。
咱们接着上一节的代码写。验证 JWT 的核心逻辑是:拿出 Token -> 用密钥解开 -> 看有没有过期 -> 把用户信息挂到 req 对象上。
看代码,咱们写一个中间件函数:
const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();
app.use(express.json());
const JWT_SECRET = 'your-super-secret-key-keep-it-safe-123456';
// JWT 验证中间件
const authenticateToken = (req, res, next) => {
// 1. 从请求头里取 Authorization
const authHeader = req.headers['authorization'];
// 2. 提取 Bearer Token
// 格式通常是 "Bearer xxxxxx.yyyy.zzzz"
const token = authHeader && authHeader.split(' ')[1];
// 3. 没传 Token?直接 401 没权限
if (token == null) {
return res.status(401).json({ message: '未提供 Token,拒绝访问' });
}
// 4. 验证 Token
// jwt.verify 会自动检查过期时间 (exp),如果过期了会抛出 TokenExpiredError
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
// 这里处理具体的错误,比如过期或者签名无效
console.error('JWT 验证失败:', err.name);
if (err.name === 'TokenExpiredError') {
return res.status(403).json({ message: 'Token 已过期,请重新登录' });
}
if (err.name === 'JsonWebTokenError') {
return res.status(403).json({ message: 'Token 无效,可能已被篡改' });
}
// 其他错误
return res.status(403).json({ message: 'Token 验证失败' });
}
// 5. 验证成功,把解码出来的用户信息(payload)挂到 req.user 上
// 这样后面的路由处理函数就能直接用 req.user.userId 了
req.user = user;
next(); // 放行,进入下一个中间件或路由处理器
});
};
// 受保护的路由
app.get('/profile', authenticateToken, (req, res) => {
// 这里的 req.user 就是中间件里挂上去的
res.json({
message: '欢迎来到个人中心,你是管理员!',
userInfo: req.user // 把解析出来的用户信息返回去,方便前端展示
});
});
// 公共路由(不需要鉴权)
app.get('/', (req, res) => {
res.send('这是一个公共接口,谁都能看。');
});
app.listen(3000, () => {
console.log('服务器跑在 http://localhost:3000');
});
跑起来后,你去访问 GET /profile,如果不带 Token,或者带个过期的、假的 Token,服务端都会精准地拦截下来,返回对应的错误信息。
经验之谈提醒:很多新手会忽略 jwt.verify 的回调错误处理。特别是那个 TokenExpiredError,如果你不捕获它,前端收到 403 也不知道是过期了还是咋了。现在的前端框架(React/Vue)通常会拦截 401/403 状态码,如果是过期,就自动去调刷新 Token 的接口,这就是咱们前面说的那个趋势。
⚡ 效率提示:防范算法混淆攻击
这是一个在开发者社区里讨论很多的漏洞点。有些服务端代码写得很烂,直接用了 jwt.verify(token, secret),没有指定算法。黑客可能会拿到一个 Token,把 Header 里的 alg 改成 none,或者利用公私钥混淆(比如你用 RS256 签发,但他拿公钥当密钥用 HS256 去验)。最佳实践是在签发和验证时显式指定算法。虽然 jsonwebtoken 9.x 版本在这方面默认行为比较安全,但咱们写代码时,还是要在 jwt.verify 的 options 里加上 algorithms: ['HS256'](如果你用的是 HS256),这样更严谨,防止黑客利用算法漏洞伪造 Token。
4. 安全进阶:双Token机制(Refresh Token)与算法混淆防护
其实,单用 JWT 做登录就像是把家门钥匙直接交给用户,这把钥匙虽然带防伪签名,但它有一个致命缺点:一旦签发,在过期前始终有效。如果你把有效期设得太长(比如7天),用户一旦丢了 Token,黑客就能拿着它横行霸道7天;如果你设得太短(比如15分钟),用户就得每15分钟重新登录一次,体验极差。
这就是为啥现在业界(包括咱们参考资料里提到的 2024-2026 趋势)都在推双 Token 机制。
双Token机制:AccessToken 与 RefreshToken
核心思路很简单,把 Token 拆成两个:
- AccessToken:短命鬼,比如15分钟过期。用来请求接口,就算被偷了,损失也有限。
- RefreshToken:长命百岁,比如7天或30天过期。专门用来换新的 AccessToken,而且通常只存在 HttpOnly Cookie 里,防 XSS。
值得留意的是,RefreshToken 轮换(Rotation)。每次用 RefreshToken 换新 Token 的时候,旧的 RefreshToken 立马作废,发一个新的给你。这样如果有人偷了你的 RefreshToken 去用,你下次正常刷新时就会发现 Token 失效,从而意识到账号可能被盗了。
下面咱们用 jsonwebtoken@9.0.2 来实现一个简单的双 Token 刷新逻辑。
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
// 模拟数据库存储,实际项目用 Redis
const refreshTokenDB = new Map();
const ACCESS_TOKEN_SECRET = 'access-secret-key-123';
const REFRESH_TOKEN_SECRET = 'refresh-secret-key-456';
// 1. 登录时签发双Token
function login(req, res) {
const user = { id: 1, username: 'coder_buddy' };
// 生成 Access Token (短有效期)
const accessToken = jwt.sign(
{ userId: user.id, role: 'admin' },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' } // 15分钟
);
// 生成 Refresh Token (长有效期)
const refreshToken = jwt.sign(
{ userId: user.id },
REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' } // 7天
);
// 把 RefreshToken 存起来,这里用 Map 模拟,实际存 Redis 并设置相同的过期时间
// 这里顺便生成一个指纹,用于后续验证 RefreshToken 是否被盗用
const tokenFamily = crypto.randomBytes(16).toString('hex');
refreshTokenDB.set(refreshToken, { userId: user.id, family: tokenFamily });
// 把 RefreshToken 放在 HttpOnly Cookie 里,防止 JS 读取(防XSS)
res.cookie('jid', refreshToken, {
httpOnly: true,
path: '/refresh_token', // 限制路径,只有刷新接口能带上这个 Cookie
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken });
}
// 2. 刷新 Token 接口
function refreshToken(req, res) {
const token = req.cookies.jid;
if (!token) return res.sendStatus(401);
try {
const payload = jwt.verify(token, REFRESH_TOKEN_SECRET);
const savedToken = refreshTokenDB.get(token);
// 验证 Token 是否在数据库(或Redis)里,以及是否属于该用户
if (!savedToken || savedToken.userId !== payload.userId) {
return res.sendStatus(403); // Forbidden
}
// --- 核心:RefreshToken 轮换 ---
// 删掉旧的 RefreshToken
refreshTokenDB.delete(token);
// 生成新的 AccessToken
const newAccessToken = jwt.sign(
{ userId: payload.userId },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
// 生成新的 RefreshToken
const newRefreshToken = jwt.sign(
{ userId: payload.userId },
REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
// 存新的
refreshTokenDB.set(newRefreshToken, { userId: payload.userId });
// 更新 Cookie
res.cookie('jid', newRefreshToken, { httpOnly: true, path: '/refresh_token', maxAge: 7 * 24 * 60 * 60 * 1000 });
return res.json({ accessToken: newAccessToken });
} catch (err) {
return res.sendStatus(403);
}
}
算法混淆攻击:别让你的JWT变裸奔
除了双 Token,还有一个老生常谈但依然很多人实战经验的安全漏洞——算法混淆攻击。
打个比方,JWT 的 Header 里有个 alg 字段。有些库在早期版本(或者是配置不当)时,服务端会信任客户端传来的 alg。黑客可以把 alg 改成 none,然后去掉签名部分,服务端可能就直接放行了。或者更骚的操作:把算法从 RS256(非对称)改成 HS256(对称)。
如果服务端用 RS256 的公钥去验证 HS256 的签名,而黑客知道这个公钥(通常公钥是公开的),他就能伪造签名。
📖 学习建议:在 jsonwebtoken@9.0.2 这种现代库里,咱们一定要显式指定算法。
// 错误示范:不指定算法,或者信任 Header
// jwt.verify(token, secret); // 危险!
// 正确示范:强制指定算法
jwt.verify(token, SECRET_KEY, { algorithms: ['HS256'] }, (err, decoded) => {
if (err) {
console.error('Token 验证失败,可能是算法被篡改!', err);
return;
}
console.log('解析成功', decoded);
});
另外,参考资料里提到未来的趋势是集成 Web Crypto API。虽然 jsonwebtoken 目前还是用 Node 自带的 crypto,但如果你追求极致安全或者环境限制,可以关注一下用标准的 Web Crypto API 来生成和验证签名,这能减少第三方库的依赖风险。
---
5. 深度对比:JWT vs Session 及 Token 撤销方案探讨
面试的时候,面试官特别爱问:“JWT 和 Session 到底有啥区别?我该用哪个?”
可以这么理解,这俩根本不是一个维度的东西。Session 是有状态的,JWT 是无状态的。咱们来掰扯掰扯。
JWT vs Session:一场“记性好”和“自带证”的对决
用户登录后,服务端在内存或 Redis 里存一份 Session,然后给用户一个 SessionID(通常放 Cookie)。下次用户来,服务端拿着 ID 去查“花名册”。好处是服务端完全掌控,想踢人下线(撤销)立马删掉记录就行。坏处是分布式系统里,你得搞个专门的 Redis 集群来共享 Session,扩缩容麻烦,而且服务端有存储压力。
用户登录后,服务端扔给用户一个加密的字符串。以后用户每次来,服务端只看这个字符串是不是真的、过期没,根本不查库。好处是无状态,服务端不用存东西,特别适合微服务架构和前后端分离(SPA)。坏处就是咱们刚才说的,一旦发出去,服务端管不住了,没法主动让它失效(除非等到过期)。
参考资料里提到,JWT 基于 RFC 7519 标准,天然支持跨域,因为它就是个字符串,可以放 Header 里到处跑。而 Session 依赖 Cookie,在跨域场景下处理起来比较蛋疼(虽然也能搞)。
痛点:JWT 的“撤销”难题
这是 JWT 最大的槽点。既然是无状态的,我怎么实现“用户点击退出登录”或者“管理员封禁账号”?
社区里主要有三种方案,咱们排个序:
虽然我不存 Session,但我存一个“黑名单”。用户退出时,我把 JWT 的 jti(唯一ID)或者整个 Token 丢进 Redis,设置过期时间(跟 Token 过期时间一致)。每次请求,我都去 Redis 里查一下这 Token 是不是被拉黑了。
* 优点:能实现即时撤销。
* 缺点:这就又变回“有状态”了,失去了 JWT 无状态的优势,还得依赖 Redis。
也就是咱们上一节讲的。把 AccessToken 设得极短(比如5分钟)。用户退出后,虽然 AccessToken 还能用5分钟,但风险可控。只要把 RefreshToken 作废(从数据库删掉),用户就没法续命了。
* 🔧 实战技巧:对于大多数应用,这是性价比最高的方案。
如果用户被盗号或者需要全局强制下线,直接把签发 JWT 的密钥换了。所有旧的 Token 立马全部失效。
* 缺点:太粗暴了,会踢掉所有在线用户。
代码示例:基于 Redis 的黑名单撤销
咱们来看看怎么用 Redis 实现黑名单。这里假设你已经装好了 ioredis。
const jwt = require('jsonwebtoken');
const Redis = require('ioredis');
const redis = new Redis();
const SECRET = 'my-super-secret-key';
// 中间件:验证 Token 并检查黑名单
async function authMiddleware(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer <token>
if (!token) return res.sendStatus(401);
try {
// 1. 先验证签名和有效期
const decoded = jwt.verify(token, SECRET);
// 2. 检查黑名单
// 咱们用 Token 的 jti 或者如果没 jti 就用整个 token 作为 key
// 这里为了简单,假设我们存的是 token 本身
const isBlacklisted = await redis.get(`bl_${token}`);
if (isBlacklisted) {
return res.status(403).json({ message: 'Token 已被注销,请重新登录' });
}
req.user = decoded;
next();
} catch (err) {
return res.sendStatus(403);
}
}
// 登出接口:把 Token 加入黑名单
async function logout(req, res) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (token) {
// 解析 Token 获取过期时间,让黑名单里的记录自动过期,省空间
const decoded = jwt.decode(token);
const expiresIn = decoded.exp - Math.floor(Date.now() / 1000);
if (expiresIn > 0) {
// 把 Token 存入 Redis,设置相同的 TTL
// 这样 Token 过期后,Redis 里的黑名单记录也会自动消失
await redis.set(`bl_${token}`, '1', 'EX', expiresIn);
}
}
res.status(200).json({ message: '退出成功' });
}
敏感信息警告
最后划个重点,也是常见面试问题:JWT 为什么不适合存敏感信息?
因为 JWT 的 Payload 部分是 Base64URL 编码,不是加密!换个角度看,谁都能解码看。你如果在 Payload 里放了用户的密码或者身份证号,哪怕签名再安全,只要别人拿到 Token,一眼就能看到明文。
⚡ 效率提示:Payload 里只放非敏感的唯一标识(如 userId)和过期时间。敏感数据老老实实去数据库查,或者如果非要传,考虑用 JWE (JSON Web Encryption),也就是把整个 Token 加密了,而不仅仅是签名。参考资料里也提到了,JWE 在敏感数据传输中越来越受关注。