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,用点(.)分成了三部分。咱们拆开揉碎了看:

- 关键点:Payload 这部分是 Base64URL 编码,不是加密!不是加密!不是加密!重要的事情说三遍。任何人拿到 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 拆成两个:

值得留意的是,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 在敏感数据传输中越来越受关注。