彻底搞懂 JWT 鉴权:一条 token 从签发到守卫的完整链路
登录成功的那一刻,后端丢给你一长串乱码。你有没有想过:这段乱码里到底装了什么?为什么后端在「完全不认识你、也没存你的任何信息」的情况下,只凭这串乱码就能判断出你是谁?
这篇文章不教你怎么调 API,而是带你彻底搞懂 JWT 鉴权的整条链路——从 token 是怎么被「签」出来的,到它最终怎么帮你「守」住页面。搞懂了这条链路,你以后遇到任何鉴权相关的代码,都能一眼看出它在哪一环、为什么这么写。
先看全貌:一条 token 的一生
在拆细节之前,先把整条链路装进脑子里。一条 token 从诞生到退役,要经过 5 个环节:
① 签发 sign ② 存储 ③ 携带
后端 jwt.sign ──────▶ token 落到浏览器 ──────▶ 拦截器自动加头
localStorage + store Authorization: Bearer
│
▼
⑤ 守卫 RequireAuth ◀────── ④ 校验 verify ◀──────────┘
没 token 踢回登录 后端 jwt.verify 解码身份
记住这张图。 后面每一节,都是这张图里的一个圆圈。五个圆圈环环相扣,拆开每一个,你就能拼出完整的鉴权全貌。
① 签发:token 到底是怎么「造」出来的
用户输对账号密码,后端要发给他一个「凭证」。这个 demo 用 jsonwebtoken 库,一行 sign 就产出了那个凭证:
import jwt from 'jsonwebtoken';
const secret = 'secret819!$';
const token = jwt.sign(
{ user: 'admin', role: 'admin' }, // 想放进 token 的身份信息
secret, // 加盐的密钥
{ expiresIn: 86400 } // 有效期:一天(秒)
);
我把它拆开跑了一遍,jwt.sign 到底产出个什么东西:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3ODcxNTUwMjEsImV4cCI6MTc4NzI0MTQyMX0.erdXGrnDQ9zbuW-IUqoe43nNpYFsKYytUuVSDSvP05c
这一长串用 . 切成三段,就是 JWT 的全部结构:
| 段 | 解码后是什么 | 作用 |
|---|---|---|
| header | {"alg":"HS256","typ":"JWT"} | 声明「用 HS256 算法签名」 |
| payload | {"user":"admin","role":"admin","iat":...,"exp":...} | 你的身份信息 |
| signature | 一段 hash | 用 secret 对前两段做的签名,防篡改 |
解码 payload 那一截,得到的确实是明文:
{
"user": "admin",
"role": "admin",
"iat": 1787155021,
"exp": 1787241421
}
最容易被误解的一点:payload 不是「加密」,只是「编码」
这句话值得单独拎出来,因为 90% 的人第一次接触 JWT 都会搞错。
JWT 的 payload 用的是 Base64 编码,不是加密。Base64 是「把二进制数据换成 64 个可打印字符」的编码规则,是可逆、且不需要密钥的——任何人都能拿这段乱码还原出原文。上面那个「解码 payload」的例子就是直接解出来的,没用到任何密钥。
那么问题来了:如果谁都能解开看,JWT 的安全性到底靠什么?
靠第三段 signature(签名)。签名的生成过程大概是:
signature = HMAC-SHA256(
base64(header) + "." + base64(payload),
secret // 只有后端知道的密钥
)
它的精妙之处在于:
- 验证真伪:后端拿到 token 后,用同一个
secret把前两段重新算一遍签名,和 token 里带的第三段比对。一致 → 没被改过;不一致 → 被篡改。 - 防篡改:哪怕你把 payload 里的
role从"user"改成"admin",签名立刻对不上。因为签名是「内容 + 密钥」一起算出来的,内容变了,签名必然变。 - 不需要查库:后端不需要去数据库里找「这个 token 是谁发的」,只要手里有
secret,就能自己验出真伪。
这就是为什么说「payload 明文没关系」——它的安全性不靠藏信息,而靠防伪造。
一句话定义:JWT 是一张「盖了防伪章的身份卡」——身份信息明文写在卡上,但后端用 secret 盖了个章。任何持有同一个 secret 的服务,都能验出这张卡是不是伪造的。
进阶:为什么非要用 JWT,而不是传统的 session?
「搞懂」和「会用」的分水岭,就在这一层——你得知道 JWT 解决了 session 的什么问题。
HTTP 本身无状态:你请求一次,服务器响应一次,然后它就「忘了」你。下次再来,服务器不认识你。那怎么让服务器「记住你是谁」?历史上有一套经典方案叫 session:
session 方案的流程:
1. 登录成功 → 服务器在【自己的内存】里存一份 sessionId → 用户信息
2. 把 sessionId 通过 cookie 塞给浏览器
3. 浏览器以后每次请求自动带上 cookie 里的 sessionId
4. 服务器根据 sessionId 去内存里查「这个人是谁」
这套方案本身没毛病,单机跑得挺好。但一到分布式场景就露怯了:
session 的问题:请求落在哪台机器,哪台机器得认识这个 sessionId
用户 ──▶ 负载均衡 ──▶ 服务器 A(内存里有这个 session)✅
──▶ 服务器 B(内存里没有)❌ 不认识!
解决办法:把 session 挪到一个共享存储(比如 Redis)里,所有服务器都去那里查。
但这又多了一个「必须始终可用」的中心化组件。
JWT 的思路完全相反——它把「身份」直接塞进 token 里:
JWT 的方案:token 自带身份,任何服务器只要有同一个 secret 就能验
用户 ──▶ 负载均衡 ──▶ 服务器 A(用 secret verify 成功)✅
──▶ 服务器 B(用 secret verify 成功)✅
不依赖任何共享存储,签发的机器和校验的机器可以是完全不同的两台。
所以 JWT 的核心价值,浓缩成三个字就是:无状态(stateless)。后端不存任何东西,token 本身就是状态,谁都能验、验完即忘。这也是它在微服务、多机房、前后端分离场景下大行其道的根本原因。
② 存储:token 为什么被同时塞进两个地方
登录成功,后端把 token 发回来了。前端拿到之后,得把它存起来。这个 demo 里,它被同时存进了两个地方:
import { create } from 'zustand';
export const useAuthStore = create(set => ({
token: localStorage.getItem('token') || '', // 从 localStorage 读初始值
user: JSON.parse(localStorage.getItem('user')) || null,
setAuth: ({ token, user }) => {
localStorage.setItem('token', token); // 🔑 写 localStorage:持久化
localStorage.setItem('user', JSON.stringify(user));
set({ token, user }); // 🔑 写 zustand:响应式
},
logout: () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
set({ token: '', user: null });
}
}));
为什么要存两个地方?因为它们解决的是两个完全不同的问题:
| 存储位置 | 解决什么问题 | 如果只靠它一个会怎样 |
|---|---|---|
| localStorage | 持久化:刷新页面内存清空,但 token 得还在 | 刷新一次就得重新登录 |
| zustand store | 响应式:token 变了,订阅它的组件要自动重渲染 | UI 不跟着 token 变,登录了按钮还是「登录」 |
- 只存 localStorage、不存 store:token 确实还在,但界面是死的——
Nav组件里的「登录」按钮不会因为你登录了而自动变成「退出」,得手动刷新。 - 只存 store、不存 localStorage:界面响应了,但刷新就丢——因为 store 存在内存里,页面一刷就没了。
所以「两个都存」不是冗余,而是「一个管数据在不在,一个管界面变不变」。这两个职责分开,正是前端状态管理里最核心的一个心智模型:持久化和响应式是两码事。
⚠️ 顺带提一个安全层面的取舍:token 存 localStorage 有个代价——任何被注入页面的恶意脚本(XSS)都能
localStorage.getItem('token')把它偷走。更安全的做法是存httpOnlycookie(JS 读不到),但那样又有 CSRF 和移动端适配的代价。这是另一个话题,这里先记住「存 localStorage 是有代价的」即可。
③ 携带:拦截器怎么做到「每个请求自动带上 token」
token 存好了,但光存着没用。之后用户每发一个需要鉴权的请求(比如拉取数据),都得让后端知道「我是谁」。这就是 HTTP 的 Authorization 头要干的活:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
问题来了:如果每个请求都手动加这个头,你的代码会变成这样:
// 每个请求都要重复写一遍 Authorization
const res1 = await axios.get('/repo', { headers: { Authorization: `Bearer ${token}` } });
const res2 = await axios.get('/user', { headers: { Authorization: `Bearer ${token}` } });
const res3 = await axios.get('/order', { headers: { Authorization: `Bearer ${token}` } });
于是有了拦截器——在 axios 里挂一个钩子,在「每个请求发出之前」统一加工一次:
import axios from 'axios';
const instance = axios.create({
baseURL: '/api',
timeout: 5000
});
// 🔑 request 拦截器:每个请求发出前都会被「拦一道」
instance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`; // 统一加头,一处配置处处生效
}
return config;
});
export default instance;
为什么是「拦截器」这种机制?
因为它解决的是一个横切关注点(cross-cutting concern):加 token 这件事,跟「请求数据」这个业务无关,却横跨所有请求。如果不用拦截器,这段逻辑就会散落在每个请求调用处——漏一个,那个请求就 401。
拦截器背后的思想叫 面向切面(AOP):把「横切所有请求的公共逻辑」从业务代码里抽出来,集中到一个地方管理。加 token 只是最常见的用法,你还能往里面塞:统一加公共参数、埋点、token 过期跳登录页、去重请求……一次配置,处处生效。
顺带搞懂:响应拦截器 return res.data 改变了什么
axios 的响应默认是个 AxiosResponse 对象,长这样:
{
data: { code: 0, user: {...}, token: '...' }, // 真正的响应体在这里
status: 200,
statusText: 'OK',
headers: {...},
config: {...}
}
如果你挂了响应拦截器:
instance.interceptors.response.use(res => {
return res.data; // 把 AxiosResponse 解包,直接返回响应体
});
那么此后 await axios.get(...) 拿到的直接就是响应体本身,不再是那个套娃对象。这是一条「约定」——解包这件事,拦截器替你做了,业务代码里就别再 .data 一次:
// ✅ 拦截器已解包,直接返回 body
export const login = async (formData) => {
const res = await axios.post('/login', formData);
return res; // 就是响应体 { code, user, token }
};
搞懂了这条「返回值链路」,你就不会再写出 res.data.data 这种层层套娃的代码——因为你知道每一层是谁解掉的、res 到底指向哪。
④ 校验:后端凭什么信你给的 token
token 被拦截器自动带上了,请求到了后端。后端要做的事只有一件:验真。在这个 demo 的 mock 里,长这样:
response: req => {
const auth = req.headers['authorization']; // "Bearer xxxx"
if (!auth) {
return { code: 401, msg: 'No token provided' }; // 连头都没有,直接 401
}
const token = auth.split(' ')[1]; // 取出 "Bearer xxxx" 里的 xxxx
try {
let decoded = jwt.verify(token, secret); // 🔑 用同一个 secret 验真
return { code: 0, data: decoded.user }; // 验通过,返回身份
} catch (err) {
return { code: 401, msg: 'Invalid token' }; // 验不过,401
}
}
jwt.verify 干的事,就是把签名那一段用 secret 重新算一遍,和 token 里带的签名比对:
verify(token, secret) 成功时返回 payload:
{
"user": "admin",
"role": "admin",
"iat": 1787155021,
"exp": 1787241421
}
secret 不对 / token 被篡改 / 过期 → 抛错
JsonWebTokenError: invalid signature
这一步最能体现「无状态」的价值:后端从头到尾没查过任何数据库、没碰过任何共享存储,就凭一个 secret 和 token 本身,验出了「你是谁、什么角色、有没有过期」。签发的机器和校验的机器,可以天各一方。这就是第 ① 节埋下的那句话的兑现——token 本身就是状态。
⑤ 守卫:未登录的人,连页面都不该看到
前四个环节都在后端或「请求」层面。但还有一类需求在前端路由层面:用户没登录,直接输 /pay 这种受保护页的 URL,前端得在渲染之前就把他拦下来,踢回登录页。
这个 demo 用了一个 RequireAuth 组件来做「路由守卫」:
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../store/user';
function RequireAuth({ children }) {
const token = useAuthStore(state => state.token); // 从 store 读 token
if (!token) {
return <Navigate to="/login" replace />; // 🔑 没 token,重定向到登录页
}
return children; // 有 token,正常渲染受保护内容
}
用法是声明式的——把「这个路由需要登录」写成一个组件包裹,而不是在每个页面里写 if:
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/pay" element={
<RequireAuth>
<Pay />
</RequireAuth>
} />
</Routes>
为什么写成组件包裹,而不是在 Pay 组件里判断?
因为「谁能访问这个路由」是路由层的职责,不是页面自身的职责。把校验逻辑抽成一个可复用的 RequireAuth,你有 10 个受保护页,就包 10 次;要改「未登录跳哪」,只改这一个组件。
这背后是 React 的声明式哲学:描述「是什么」(这个路由需要登录才能看),而不是命令「怎么做」(如果没登录就跳转)。声明式的写法,可读性、可维护性都更好——你一眼扫过去,就知道哪个页面是受保护的。
把五个环节串起来:一张完整的数据流
五个环节讲完,把它们连起来,token 的一生就闭环了:
[后端] jwt.sign({user, role}, secret) ────── ① 签发 ──▶ token
│
[前端] localStorage 存证 + zustand 响应式 ◀──── ② 存储 ──┘
│
[前端] 请求拦截器自动加 Authorization: Bearer ◀─ ③ 携带 ──┘
│
[后端] jwt.verify(token, secret) 验真解码身份 ◀─ ④ 校验 ──┘
│
[前端] RequireAuth 读 store,没 token 踢回登录 ◀─ ⑤ 守卫 ─┘
这张图里藏着 JWT 鉴权最本质的一个事实:token 是无状态的。后端从不「记住」谁登录了,它只是在签发的瞬间盖个章,之后每次请求都靠「验章」重新认人。理解了这一点,你就理解了为什么 JWT 能在分布式系统里大行其道——因为它把「记忆」这件事,从后端转移到了 token 自己身上。
结尾:记住这张图,而不是记住 API
回到开头那个问题:那串乱码里到底装了什么?
装的是你的身份(payload)、它的防伪章(signature),以及一段「谁都能验、但不依赖任何服务器记忆」的机制。它从后端被签出来,落到你的浏览器里存起来,被拦截器一次次自动带出去,在后端一次次被验证,最后在路由层帮你守住受保护的页面。
JWT 鉴权的核心,从来不是「会调 sign / verify 两个 API」,而是理解 token 在前后端之间是怎么闭环流转的、每一步为什么这样设计。
下次再看到鉴权相关的代码,先别急着找 API 文档,先在脑子里过一遍这张「① 签发 → ② 存储 → ③ 携带 → ④ 校验 → ⑤ 守卫」的图。你会发现,每一行代码都能在这张图上找到它的位置——剩下的只是细节。
一个开放问题,评论区聊聊:token 到底该存 localStorage(简单但怕 XSS)还是 httpOnly cookie(安全但怕 CSRF、移动端不便)?你在真实项目里是怎么权衡的?