彻底搞懂 JWT 鉴权:一条 token 从签发到守卫的完整链路

1 阅读7分钟

彻底搞懂 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一段 hashsecret 对前两段做的签名,防篡改

解码 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') 把它偷走。更安全的做法是存 httpOnly cookie(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、移动端不便)?你在真实项目里是怎么权衡的?