登录这个东西,说实话我写了快两年的业务代码了,一直没彻底搞明白。反正后端给什么我就存什么,请求头带上就行,出了问题找后端。直到这次接了个需求,要我从0搭一套登录鉴权的架子,前后端都我自己来,才被迫把每一层都捋了一遍。捋完发现,原来之前很多模糊的点,根本不是难,就是没人给你串起来讲。
一开始我只知道要存个token
最开始做登录的时候,我的认知非常表层:用户填用户名密码,点登录,后端返回一个token,我塞到localStorage里,完事。下次请求的时候,在请求头加个Authorization: Bearer xxx。至于这个token到底是什么,为什么长得奇形怪状的,后端怎么凭一串字符就知道我是谁,完全没想过。
直到这次自己写mock接口,我才反应过来------HTTP是无状态的啊。你第一次请求/login告诉我你是admin,第二次请求/getUserInfo,服务器凭什么还记得你是admin?它又没有记忆。
捋到这我突然想通了。以前公司用的cookie+session方案,本质上是服务器自己记了个小本子。你登录成功,服务器给你发个sessionId写在cookie里,就像给你一个手牌号码。下次你来,掏出手牌号码,服务器翻自己的小本子:哦,3号手牌对应的是admin用户。这个方案在单机部署的时候没问题,可一旦服务器变成了好几台,麻烦就来了。你的小本子存在A机器上,结果下一次请求被负载均衡分到了B机器,B翻不到你的记录,直接让你重新登录。
JWT解决的就是这个问题。它干脆不存小本子了,直接把你的身份信息"写"在令牌上,然后签个名防篡改。就像演唱会门票,上面印着你的名字、座位号、过期时间,门口的保安只需要验证一下票是不是真的、有没有过期,不需要联网去查主办方的数据库。任何一个门口的保安,只要认得防伪标记,都能验票。
把JWT拆开看,其实就是三段字符串拼起来
我之前一直觉得JWT是个什么神秘的加密算法,后来把生成的token往decode工具里一粘,瞬间就不神秘了。它就是用.隔开的三部分,每部分都是base64url编码后的JSON。
拿我demo里生成的token举例:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MjQ1MzAwOTMsImV4cCI6MTcyNDYxNjQ5M30.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
从左到右三段分别是Header、Payload、Signature。
Header里写了用的什么签名算法(这里是HS256)和类型是JWT,解码出来就是:
json
{
"alg": "HS256",
"typ": "JWT"
}
Payload就是装身份信息的地方,用户名、角色、签发时间、过期时间,都是你自己想塞什么塞什么。demo里解码后是这样:
json
{
"user": "admin",
"role": "admin",
"iat": 1724530093,
"exp": 1724616493
}
这里容易搞混的是,Payload不是加密的,只是编码。谁拿到token都能解码看到里面的内容,所以千万别往Payload里塞密码、身份证号这种敏感东西。我一开始以为JWT是加密的,觉得可以把用户的各种隐私信息都塞进去,后来才发现那等于裸奔。
第三段Signature是核心。它的生成逻辑是:把Header和Payload分别编码后用.拼起来,然后用只有服务器知道的密钥(secret),加上Header里指定的算法,对这整串东西做签名。签名的目的不是加密内容,而是防篡改------如果有人把Payload里的role从user改成admin,再发给服务器,服务器用同样的算法和密钥重新算一遍签名,和传过来的Signature对不上,直接就拒了。
跑了个最小demo,把整条链路打通了
光看原理不过瘾,我直接用Vite+React+zustand+vite-plugin-mock撸了一套完整的登录鉴权。前后端都在一个项目里,不用搭真实的后端服务,mock接口直接模拟JWT的签发和验证。
项目结构很简单:
bash
login-demo/
├── mock/
│ └── user.js # mock接口:登录+鉴权
├── src/
│ ├── api/
│ │ ├── config.js # axios实例+拦截器
│ │ ├── user.js # 登录接口
│ │ └── repo.js # 需鉴权的接口
│ ├── store/
│ │ └── user.js # zustand全局状态
│ ├── components/
│ │ ├── Nav.jsx # 导航栏
│ │ └── RequireAuth.jsx # 路由守卫
│ └── pages/
│ ├── Login.jsx # 登录页
│ ├── Home.jsx # 首页(无需登录)
│ └── pay.jsx # 支付页(需登录)
└── vite.config.js # 配置mock插件
先装依赖,用到了zustand做状态管理、jsonwebtoken在mock里签token:
bash
pnpm i zustand jsonwebtoken
再配一下vite.config.js,让mock插件生效:
javascript
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { viteMockServe } from 'vite-plugin-mock'
export default defineConfig({
plugins: [react(), viteMockServe({
mockPath: 'mock', // mock文件所在目录
localEnabled: true, // 开发环境启用mock
})],
})
后端mock:签发和验证一条龙
mock/user.js是整个demo的"后端",里面写了两个接口,一个登录发token,一个受保护的接口验token:
javascript
// mock/user.js
import jwt from 'jsonwebtoken';
const secret = 'secret2004$'; // 只有服务器知道的密钥,别泄露!
export default [
{
// 登录接口:用户名密码对得上,就发token
url: '/api/login',
method: 'post',
response: (req, res) => {
const body = req.body;
if(body.username !== 'admin' || body.password !== '123456'){
return { code: -1, message: '用户名或密码错误' }
}
// sign:把用户身份信息+密钥,生成一个带签名的token
const token = jwt.sign(
{ user: body.username, role: 'admin' }, // Payload
secret, // 密钥
{ expiresIn: 86400 } // 24小时过期,单位秒
)
return {
code: 0,
user: { username: body.username },
token: token,
}
}
},
{
// 受保护接口:请求头带了合法token才返回数据
url: '/api/repo',
method: 'get',
response: (req, res) => {
// Authorization头的格式是 Bearer <token>,这里要把前缀去掉
const authHeader = req.headers['authorization'];
const token = authHeader ? authHeader.split(' ')[1] : null;
if (!token) {
return { code: 401, message: 'No token provided' }
}
try {
// verify:用同样的密钥解,失败直接抛异常
let decoded = jwt.verify(token, secret);
return { code: 0, data: decoded.user }
} catch (error) {
return { code: 401, message: 'Invalid token' }
}
}
}
]
这段代码跑通之后,我用Postman测了一下登录接口,返回结果跟我预期的一模一样:

然后用拿到的token去请求/api/repo,在请求头里加上Authorization: Bearer xxx,顺利拿到了数据:

前端:axios拦截器是个偷懒神器
手动在每个请求里加Authorization头太蠢了,axios拦截器帮你默默地把这件事干了。请求发出去之前拦下来,检查localStorage里有没有token,有就塞到请求头里。响应回来的时候,再顺手把外层的data剥掉,省得每次都写res.data.data。
javascript
// src/api/config.js
import axios from 'axios';
const instance = axios.create({
baseURL: '/api',
timeout: 5000,
})
// 请求拦截器:每个请求自动带token
instance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if(token){
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
})
// 响应拦截器:直接返回业务数据
instance.interceptors.response.use(res => {
return res.data;
})
export default instance;
登录状态用zustand管,比Context省心太多
登录状态、用户信息这种要跨路由跨组件共享的东西,以前我会写Context+Provider包一层,写起来麻烦,还容易因为rerender搞得头疼。这次试了下zustand,真香。
javascript
// src/store/user.js
import { create } from 'zustand';
export const useAuthStore = create(set => ({
// 初始化就从localStorage读,刷新页面不丢失登录态
token: localStorage.getItem('token') || '',
user: JSON.parse(localStorage.getItem('user')) || null,
setAuth:({ token, user }) => {
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
set({ token, user });
},
logout: () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
set({ token: '', user: null });
}
}))
用起来也简单,哪个组件需要token或者用户信息,直接从store里取,不需要包Provider。登录成功之后调用setAuth,token和用户信息同时写进store和localStorage,刷新页面也不会丢:

路由守卫:没登录就别进来
支付页面这种敏感路由,没登录的话直接踢回登录页。我写了个RequireAuth组件包一下,逻辑简单粗暴------store里有token就渲染子组件,没有就重定向到登录页,顺便把用户本来想去的路径记下,登录成功后直接跳回去,体验好一点。
javascript
// src/components/RequireAuth.jsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../store/user';
function RequireAuth({ children }) {
const token = useAuthStore(state => state.token);
const location = useLocation();
if(!token) {
// 把当前路径塞到state里,登录页能拿到
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
return children;
}
export default RequireAuth;
路由里直接用:
javascript
// src/App.jsx
<Route path="/pay" element={
<RequireAuth>
<Pay />
</RequireAuth>
} />
翻完jsonwebtoken的实现逻辑,我愣了一下
demo跑通之后我又翻了翻jsonwebtoken这个库的源码思路,发现两个之前没想过的细节。
一个是签名不是加密 。我之前一直以为signature是把前两段加密了,其实不是。它就是对base64Url(header) + "." + base64Url(payload)这串文本做了一次HMAC-SHA256运算,结果就是signature。HMAC是单向的,你拿signature反推不出原文和密钥,只能拿着原文和密钥重新算一遍,对比结果是不是一样。所以验证的过程本质上是"你把原文(Header+Payload)和签名一起给我,我用同样的密钥再算一遍签名,对上了就是没被篡改过"。
另一个反直觉的点是JWT过期时间校验是verify函数自动做的 。我在demo里只传了expiresIn: 86400,sign的时候会自动在Payload里加iat(签发时间戳)和exp(过期时间戳)。调用verify的时候,只要当前时间超过了exp,不管签名对不对,直接抛TokenExpiredError。不用自己手写时间比较,这点很方便。
Payload里的
iat和exp都是Unix时间戳(秒级),不是毫秒级。如果你手动构造Payload,别搞错了单位。
几个我当时差点踩进去的坑
说实话之前如果让我直接写JWT鉴权,大概率会踩这几个坑。这次因为是自己从零搭,每一步都想了想,算是避开了。
Secret写死在前端代码里? 别笑,我真的见过有人这么干。前端代码打包之后用户是能看到的,密钥泄露了等于JWT整个签名机制废了,别人想伪造什么token就伪造什么。密钥必须只存在服务端。我们这个demo里secret写在mock文件里,mock文件只会跑在Vite的node进程里,不会打包到前端,所以是安全的。
token存localStorage怕XSS? 对,只要你的页面有XSS漏洞,攻击者就能通过脚本读到localStorage里的token,然后冒充你。这是事实,但不是说localStorage就不能用。大多数中后台系统、C端产品,只要做好XSS防护(CSP、输入转义、不滥用innerHTML),localStorage是够用的。对安全等级要求特别高的场景,可以考虑把token放在HttpOnly的Cookie里,这样JS读不到,XSS偷不走,但代价是你又得处理CSRF的问题。没有银弹,看业务取舍。
token一旦签发就没法主动作废? 这也是JWT的一个特点。因为服务器不存session,就算用户点了"退出登录",你只是把前端的token删了,这个token本身在过期之前还是有效的。如果token被泄露了,别人拿到照样能用。对登出安全性要求高的场景,一般会在服务端维护一个token黑名单(比如用Redis),或者把token过期时间设短一点,配合refresh token用。
捋完一圈的真实感受
搞完这个demo,我最直观的感受是JWT没有我之前想象得那么"高级"。它就是一个标准化的、带签名的JSON载体,解决的核心问题就是在不依赖服务端存储的情况下,安全地传递用户身份。分布式部署、跨域认证、微服务之间鉴权,这些场景JWT天然就适合,因为不需要做session共享,任何一台服务器只要拿同一个密钥就能验签。
但它也不是万能的。需要主动踢用户下线、需要让某个token立刻失效的场景,纯JWT方案就很别扭,还得配合服务端存储搞黑名单,那反而不如直接用session省事。还有Payload不能放敏感数据这个点,一定要刻在脑子里,别等数据泄露了才后悔。
我目前理解到的程度大概就是这些,有补充的可以评论区唠唠。
顺手捋的核心知识点
写到这顺手把核心的点捋了一遍,我自己回头复盘方便,你们要是懒得翻长文也可以直接看这部分。
1. JWT的本质与三段结构
- 核心结论 :JWT是一个用
.分隔的三段式字符串,由Header(算法+类型)、Payload(身份数据+时间戳)、Signature(防篡改签名)组成,目的是在服务端无存储的前提下安全传递用户身份。 - 易错提醒:Payload是base64url编码而非加密,任何人拿到token都能解码看到内容,严禁存放密码、密钥等敏感信息。
2. 签名(sign)与验证(verify)的核心机制
- 核心结论 :sign用服务端密钥对
base64(Header)+"."+base64(Payload)做HMAC运算生成Signature;verify用相同的密钥重新计算Signature并与传入值对比,不一致则判定为篡改。 - 易错提醒:签名是单向运算(无法反推原文和密钥),不是加密。密钥必须仅存服务端,严禁打包进前端代码。
3. 手动实现JWT签发与验证的关键步骤
- 核心结论 :实现一个最小JWT分为五步------①Header固定声明alg和typ并base64编码;②构造Payload(含user/role/exp等)并base64编码;③用密钥对前两段拼接字符串做HMAC-SHA256并base64编码得到Signature;④签发时三段用
.拼接返回;⑤验证时拆分三段,用密钥重算签名比对,同时校验exp是否过期。 - 易错提醒 :exp和iat是秒级时间戳,不是毫秒级;Authorization头的格式是
Bearer <token>,验证时记得split(' ')[1]取实际token。
4. JWT对比Cookie+Session的差异
- 核心结论:Session方案服务端存会话对象、依赖Cookie传递sessionId,存在分布式共享难题;JWT将身份信息存于token本身、通过签名保证完整性,任何持有相同密钥的服务器都能独立验证,天然适配分布式和跨域场景。
- 易错提醒:JWT的"无状态"是优点也是缺点------签发后无法在服务端主动作废,只能等过期,需要踢人下线的场景需额外配合Redis黑名单或refresh token机制。
5. axios拦截器的正确用法
- 核心结论 :request拦截器统一注入
Authorization请求头,避免每个接口手动拼接;response拦截器统一剥离外层res.data、处理401跳登录等全局逻辑。 - 易错提醒 :拦截器里处理完config和response必须
return回去,否则请求会被卡住;401处理要避免重复跳转。
6. zustand管理全局登录态的模式
- 核心结论:登录态(token+user)初始化时从localStorage读取(防刷新丢失),setAuth同时写入store和localStorage,logout双向清除;任何组件直接从store按需取状态,无需Provider包裹。
- 易错提醒 :user对象存localStorage必须
JSON.stringify/JSON.parse,不然取出来是字符串[object Object]。
7. 路由守卫(RequireAuth)的实现要点
- 核心结论 :高阶路由组件读取token,无token时用
<Navigate replace>重定向到登录页,同时将当前路径通过location.state传递给登录页,登录成功后navigate(from, { replace: true })跳回原目的地。 - 易错提醒:replace要加true,否则用户点浏览器后退会回到被拦截前的路由,又被踢回登录页,形成死循环。
8. token存储的安全取舍
- 核心结论:localStorage存储方便但有XSS泄露风险(做好CSP和输入转义可降低风险);HttpOnly Cookie防XSS但需额外处理CSRF;安全等级要求越高越偏向Cookie+CSRF Token方案。
- 易错提醒:认为"JWT比session安全"是认知误区,两者只是实现方式不同,安全与否取决于具体配置(过期时间、密钥强度、传输是否HTTPS等)。