news 2026/10/6 3:55:48

JWT API认证实战:从Session到双Token的原理与最佳实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JWT API认证实战:从Session到双Token的原理与最佳实践

做后端最烦的一件事就是刚上线一个接口,产品那边突然说“这个接口必须登录才能调”。以前项目少的时候我直接在接口里写死校验逻辑,前端传个userId过来,我查一下库,能用就行。等接口一多、服务一拆,这种玩法彻底崩了:每个接口都要往数据库里翻用户,分布式部署下session也存不到一块去,换端登录更是鸡飞狗跳。

后来我在新项目里全面换成了JWT(JSON Web Token)做用户认证与授权,把token签发给客户端,后端只负责验签,不存登录状态。这篇文章把我从设计到落地、再到踩坑的完整过程写出来,包含可以直接抄的代码、双token续签方案,以及我在线上遇到过的问题排查思路。不管你是刚入门API开发的新手,还是正在重构老系统的后端,应该都能从中找到能用的东西。

1. 为什么JWT比传统Session更适合API场景

1.1 传统Session认证的核心痛点

传统的Session认证流程大家应该都写过:用户登录成功,服务端生成一个sessionId,存到内存或Redis里,再把sessionId通过Cookie返回给浏览器。之后的请求带上Cookie,服务端拿着sessionId去查对应的会话数据,查到就算登录了。

这套流程在小单体时代没毛病,但放在现在的API场景里就有点别扭了。举个例子,一个系统同时有Web端、小程序端、iOS和Android端,客户端有的能存Cookie,有的只能自己管理token,你没法统一一套“写Cookie、自动携带Cookie”的玩法。更头疼的是后端一旦部署多个实例,session存到哪个实例是个大问题——负载均衡落在A实例的会话,请求被转发到B实例就找不到登录态了。

还有个更麻烦的场景:多个后端服务互相调用。比如订单服务要确认“当前用户是否是VIP”,它没有用户的session数据,只能再去用户服务查一次。一次调用变成两次,链路一长,延迟和故障率都上来了。

1.2 JWT的结构:三段字符串里藏了什么

JWT的思路很直接:把认证信息直接交给客户端保存,服务端不再存会话状态。一个完整的JWT长这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDAxIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDB9.signature

点号分割成三段:

  • Header(头部):声明token类型和签名算法,一般是{"alg":"HS256","typ":"JWT"}。
  • Payload(载荷):放业务数据,比如用户ID(sub)、过期时间(exp)、签发时间(iat)、自定义的role字段,这部分只是Base64编码,任何人都能解码。
  • Signature(签名):用Header里声明的算法,把前两段加上密钥一起做签名计算。只要密钥不泄露,token内容就不能被篡改。

我用PyJWT生成一个token的代码非常简单:

import jwt import time SECRET = "your-32-bytes-random-secret" payload = { "sub": "1001", "role": "admin", "iat": int(time.time()), "exp": int(time.time()) + 1800, } token = jwt.encode(payload, SECRET, algorithm="HS256") print(token)

注意,encode默认只做Base64和签名,不做加密。也就是说,任何拿到token的人都能看到你的sub和role字段。这不代表不能用,而是要求你别往payload里塞密码、手机号、身份证这类敏感信息。

1.3 JWT和Session怎么选

很多新手纠结JWT和Session哪个好,其实它们没有绝对的优劣,关键看场景。

对比项Session认证JWT认证
登录态存储服务端内存或Redis客户端保存token
横向扩容需要集中式session存储天然支持多实例
主动注销删除服务端session即可立即生效需要额外黑名单机制
跨端适配Cookie为主,App流程别扭任意客户端统一处理
接口性能每次请求查一次存储每次请求做一次验签

一句话总结我自己的选型经验:前后端分离、多端共存、微服务架构,优先JWT;传统服务端渲染、后台管理系统、对“踢人下线”有硬性要求的场景,Session反而更好实现。如果非要JWT做强制下线,也不是不行,后面会讲黑名单方案。

2. 认证方案设计:不只发一个token那么简单

2.1 登录接口:校验完密码后签发什么

登录接口的核心动作就两个:校验用户名密码,签发token返回给客户端。我这里的校验逻辑不展开,重点在签发token时往payload里放了什么。

我的通用设计:

import uuid def create_access_token(user_id: str, role: str, expires_minutes: int = 30) -> str: now = int(time.time()) payload = { "sub": str(user_id), # 用户ID,必须唯一 "role": role, # 角色,用来做权限判断 "iat": now, # 签发时间 "exp": now + expires_minutes * 60, # 过期时间 "jti": uuid.uuid4().hex, # token唯一ID,注销/黑名单要用 } return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)

很多人只放sub和exp,等我上了生产环境才发现远远不够。role字段能让你在接口层直接做权限拦截,少查一次数据库;jti是黑名单机制的关键,后面讲续签和登出的时候会体现。

2.2 别再只发一个token了:双token机制详解

早期我做JWT只发一个access token,过期时间设置比较长,比如24小时。结果出过两个事:一个用户的token在公共电脑上忘了退,被人截了之后整整一天都能访问接口;另一个是token到期后强制重新登录,用户吐槽“我用着用着突然被踢下线”。

后来我改成标准的双token方案:

  • access_token:有效期短,一般15分钟到2小时,用来正常访问API。
  • refresh_token:有效期长,一般7到14天,只用来换取新的access_token。

访问接口只认access_token,过期了前端就拿着refresh_token去调/api/refresh,换一个新的access_token回来。这样就算access_token泄露,攻击者也只有很短的利用窗口;而refresh_token即使泄露,控制好用途、加上轮换策略,风险也可控得多。

2.3 校验中间件:每个受保护接口的第一步

JWT的校验逻辑不是每个接口单独写一遍,而是统一在中间件或拦截器里做。我拿FastAPI举个完整例子:

import jwt from fastapi import Depends, FastAPI, HTTPException from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials app = FastAPI() security = HTTPBearer() def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)): token = credentials.credentials try: payload = jwt.decode( token, SECRET_KEY, algorithms=[ALGORITHM], # 必须显式指定算法白名单 ) return payload except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="token expired") except jwt.InvalidTokenError: raise HTTPException(status_code=401, detail="invalid token")

这段代码里有几个关键点值得强调。

第一,HTTPBearer会自动检查请求头里的Authorization字段,要求格式必须是Bearer <token>。有些客户端喜欢直接把token裸放在query参数或者自定义header里,我不推荐,因为Authorization是HTTP标准字段,网关、日志、代理都会天然兼容它。

第二,algorithms=[ALGORITHM]这个参数不能省。PyJWT较新版本要求必须显式指定算法白名单,这是一个非常关键的安全约束。如果你不限制算法,攻击者可以自己伪造一个alg:none的token来绕过签名校验。

第三,校验通过后,我直接把payload返回给接口。后续接口想拿用户ID、角色都从payload里取,不需要再查库。

2.4 续签与登出:两个必须落地的接口

继续说双token机制里的两个配套接口。

续签接口的逻辑核心是:拿refresh_token换新的access_token。我贴一个重点流程的伪代码:

@app.post("/api/refresh") def refresh_token(refresh_token: str): # 1. 验证refresh_token的签名和过期时间,失败直接401 # 2. 去Redis检查该token的jti是否在黑名单中,在就拒绝 # 3. 从payload里取user_id,签发新的access_token # 4. 推荐顺便轮换refresh_token:旧refresh_token作废,签发新的refresh_token return {"access_token": new_access, "refresh_token": new_refresh}

登出接口的原理不是删除一个不存在的服务端session,而是把当前token的jti加入黑名单,缓存时间一直到它的exp为止:

import redis r = redis.Redis() def logout(token_jti: str, token_exp: int): ttl = token_exp - int(time.time()) if ttl > 0: r.setex(f"blacklist:{token_jti}", ttl, "1")

校验token的时候加一步:

def is_blacklisted(jti: str) -> bool: return r.exists(f"blacklist:{jti}") == 1

有了这套设计,我才能在JWT体系下实现“主动踢人”,也算弥补了JWT无状态的一大短板。

3. 完整实操:从零搭一个带JWT认证的API服务

3.1 依赖选型与配置项

我这里用Python的FastAPI + PyJWT做演示,因为代码量少、易读,换个语言也能照着思路翻译。你要是用Java,对应的是jjwt或者Spring Security里的JwtDecoder,设计逻辑完全一致。

先准备依赖:

pip install fastapi uvicorn PyJWT redis

Java侧的Maven坐标也列一下,方便需要的朋友:

<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.x</version> </dependency>

配置项方面,我习惯把密钥和过期时间全部放进环境变量,防止密钥硬编码进代码库,搞不好就跟着代码一起泄露了。

配置项推荐值说明
JWT_SECRET_KEY随机生成32字节以上字符串HS256签名使用的密钥,必须足够强
JWT_ALGORITHMHS256如果走RS256则改为RS256
ACCESS_TOKEN_EXPIRE_MINUTES30access_token有效分钟数
REFRESH_TOKEN_EXPIRE_DAYS7refresh_token有效天数

生成一个足够强的密钥可以用这条命令:

openssl rand -base64 32

这里要专门说一句,网上很多教程直接写SECRET_KEY = "my-secret",这种弱密钥在HS256下非常容易被暴力破解。攻击者拿到一个有效token的样本后,可以用大量常见单词离线尝试签名匹配。密钥一旦被还原,他能任意伪造你的token,整个认证体系等于作废。

3.2 用户登录与受保护接口完整代码

完整的接口代码如下,我把能缩略的地方尽量精简,重点展示JWT相关链路:

import time import uuid import jwt from fastapi import Depends, FastAPI, HTTPException from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials SECRET_KEY = "from_env_or_secret_manager" ALGORITHM = "HS256" app = FastAPI() security = HTTPBearer() # 模拟用户表:生产环境请用数据库 + bcrypt/argon2 存密码 USERS = { "admin": {"password": "$2b$12$hashed_password_example", "role": "admin"}, } def create_access_token(user_id: str, role: str, expires_minutes: int = 30): now = int(time.time()) payload = { "sub": user_id, "role": role, "iat": now, "exp": now + expires_minutes * 60, "jti": uuid.uuid4().hex, } return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) def verify_token(token: str) -> dict: try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) return payload except jwt.PyJWTError: raise HTTPException(status_code=401, detail="invalid token") @app.post("/api/login") def login(username: str, password: str): user = USERS.get(username) if not user or not verify_password(password, user["password"]): raise HTTPException(status_code=401, detail="bad credentials") access_token = create_access_token(username, user["role"]) return {"access_token": access_token, "token_type": "bearer"} @app.get("/api/user/info") def user_info(credentials: HTTPAuthorizationCredentials = Depends(security)): payload = verify_token(credentials.credentials) return {"user_id": payload["sub"], "role": payload["role"]}

我实际跑过这个例子,用curl验证整个流程非常直观。先调登录接口拿token,再带token访问受保护接口。

curl -X POST "http://localhost:8000/api/login?username=admin&password=123456"

返回一个token后:

curl -H "Authorization: Bearer eyJhbGciOi..." http://localhost:8000/api/user/info

如果请求头不带token或者token过期,服务端就直接返回401 JSON,前端不用再猜失败原因。

3.3 前端如何规范携带token

后端接口准备好了,前端这一侧也有一堆细节。我要求团队用Axios统一封装请求,在拦截器里自动带上token:

import axios from "axios"; const apiClient = axios.create({ baseURL: "/api" }); apiClient.interceptors.request.use(config => { const token = localStorage.getItem("access_token"); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); apiClient.interceptors.response.use( response => response, error => { if (error.response && error.response.status === 401) { // 这里可以调用刷新token接口,或直接跳登录页 window.location.href = "/login"; } return Promise.reject(error); } );

这里有两个实操注意点。

一个是token放localStorage还是cookie各有利弊。localStorage的好处是前端读取方便,缺点是XSS攻击能把token偷走;cookie配合httpOnly可以防XSS,但要额外处理CSRF。我在常见场景下先用localStorage,如果项目对安全等级要求高,再用cookie+CSRF token的叠加方案。

另一个是刷新token失败时不能无限重试。我踩过几次坑:access_token过期后,前端并发发了多个请求,每个请求都触发刷新,结果把refresh_token用一次就作废了(轮换策略下),导致后续请求全部401。解决办法是做一个并发锁——刷新过程中其他请求先排队等待同一个刷新Promise结果。

3.4 跑通全流程的调试经验

完整的验证流程我会分四步走:

  1. 登录拿token,确认返回结构里有access_token和token_type。
  2. 不带token访问受保护接口,确认返回401。
  3. 带有效token访问,确认返回200。
  4. 手工构造一个过期token(把payload里的exp改成过去时间再重新签名),确认返回401。

第四步很多人会忽略,但偏偏特别重要。你永远不知道客户端会在什么情况下拿到一个过期token,提前验证服务端能优雅处理,总比线上用户看到500错误强。

4. JWT安全性与性能:绕开这些经典漏洞

4.1 算法混淆:从alg:none到RS256/HS256切换

JWT最经典的攻击之一就是算法混淆。我举三种典型情况。

第一种是alg:none。某些JWT库在实现时允许alg字段为none,意思是“我不做签名校验”,攻击者直接把Header里的算法改成none,删掉签名部分,服务端如果没做算法白名单校验,就会把这个伪造token当成合法token接受。不过现在主流库默认都会拒绝alg:none,PyJWT里遇到这种情况会直接抛错。防御方式就是前面说的,decode时指定algorithms=[ALGORITHM],不要信任token自身声明的算法。

第二种是密钥混淆。如果你的服务既支持HS256又支持RS256,攻击者可以把token的alg改成HS256,然后用RS256的公钥当HMAC密钥来签名。乍一听很绕,攻击逻辑是:服务端用公钥验证RS256签名,但攻击者改用HS256,签名用的密钥是“公钥的内容”,而公钥通常是公开的。服务端如果没限制算法,就会用公钥做HS256验签,结果验签成功。防御同样是指定算法白名单,并在代码里彻底禁用不需要的算法。

4.2 弱密钥和密钥管理:HS256还是RS256

HS256是对称算法,签发和验证用的是同一个密钥。RS256是非对称算法,签发用私钥,验证只用公钥。

这里我给出自己的选型建议:如果是单体服务或者服务间通信场景不复杂,HS256完全够用,管理一个密钥就行;如果你要做开放API平台,第三方需要用你提供的公钥来验签,或者系统里有多个服务需要各自独立验签,优先用RS256。RS256的另一个好处是私钥只存在于认证服务,其他服务即使被攻破,也无法伪造新token,只能验证已有token。

不管用哪种,密钥都不能写死在代码里。我的实际做法是:开发环境用环境变量,生产环境用专门的密钥管理系统或者部署平台的安全配置,定期轮换。轮换密钥的时候注意新旧密钥要有一段时间的并存期,否则线上业务会有大量401。

4.3 Payload不是密文,别把敏感数据放进去

每次有同事拿着JWT来问我“为什么token里能看到用户手机号”,我都得重复一遍:JWT的Payload只是Base64编码,不是加密。写个base64.urlsafe_b64decode就能还原出完整的JSON。

所以下面的东西绝对不要放Payload:密码、手机号、身份证号、邮箱、银行卡等任何个人敏感信息;也不要放大的业务对象,比如用户完整的地址列表。一方面是信息泄露风险,另一方面token每次请求都带着,Payload体积过大直接拖慢请求速度。Payload里放个sub用户ID加一个role角色字段就够用了,需要更多用户信息时让接口按ID查数据库。

4.4 重放攻击、时钟偏移和性能开销

JWT用exp字段控制过期,但exp不是万能的。攻击者截获一个还有效的token,在过期前可以反复使用。要降低重放风险,可以结合三件事:缩短access_token的有效期,降低被利用的窗口;关键业务接口增加额外校验,比如来源IP或者设备指纹;用jti做一次性校验,记录已经被使用过的token ID。注意,jti一次性校验对access_token的意义不大,它更多是用在refresh_token和短信验证码这类一次性凭证上。

时钟偏移是另一个容易被忽略的点。签发token的服务器和验证token的服务器如果时间相差太大,明明token还没到过期时间,验签时报ExpiredSignatureError;或者反过来,签发出来的token因为服务器时间慢了,一出生就被判定“已过期”。排查方式就是检查各节点的时间同步是否正常,NTP校时是常规操作。代码层面可以在验证过期时通过options={"leeway": 30}之类的参数做一点容忍窗口。

性能方面,JWT的优势在于验签过程纯CPU计算,不依赖网络IO。一次RS256验签大概是微秒到毫秒级别,比每次请求都查一次Redis要快得多。但代价是黑名单机制一旦引入,每次校验又要查一次Redis。我的取舍是:普通高流量接口只做纯验签,不查黑名单;只有登录、退出、改密码这类关键操作才强制检查黑名单。

5. 常见问题与排查技巧速查表

5.1 典型报错和排查对照

我把线上和开发过程中遇到最多的问题整理成表格,你如果遇到类似报错可以直接对号入座。

现象可能原因排查方向
401 invalid token签名密钥不一致,或算法白名单配置缺失检查签发和校验的SECRET_KEY是否一致,algorithms参数是否显式声明
401 token expiredaccess_token过期或服务器时钟不准先看exp和当前时间差值,再用leeway排除时钟偏差
403 Forbidden已通过认证但权限不足检查payload中的role、scope,确认接口层权限判断是否覆盖了本次请求
请求带token却仍然401Authorization头格式错误,缺少Bearer前缀用curl逐字检查请求头内容,很多客户端拼接字符串时多打了空格
刷新token后马上失效refresh_token被并发使用,轮换逻辑有竞争加并发锁,刷新接口保证同一时刻只有一个刷新请求在处理
登录成功但前端找不到token后端返回字段名不统一全端约定好access_token、token_type的命名和大小写

5.2 Token过期与续签的几个真实案例

有一次同事找我,说生产环境大量用户“登录状态突然丢失”。我查了一圈,发现是refresh_token接口在鉴权时误用了access_token的过期时间校验。因为两套token用的是同一套解析方法,但refresh_token的exp是28天后,而解析方法里写了leeway=0,结果某个节点时间同步出问题,导致一批refresh_token被误判过期。从那之后,我坚持把access_token和refresh_token的校验逻辑拆成两个独立函数,各自维护过期规则。

还有一次是第三方App接入我们的API,他们的客户端在access_token过期时不是调刷新接口,而是直接调登录接口重新登录。他们的后端是在一个循环里反复重试,结果登录接口频繁被调用,数据库压力暴涨。排查之后我建议他们的策略变成“刷新优先,登录兜底”,并且给登录接口加了失败频控。这也提醒我:对外提供API时,一定要把续签流程的时序图写明白,别让调用方自己猜。

5.3 调试阶段必须会的一手操作

调试JWT最常用的工具是jwt.io,它能把token的Header和Payload直接解码出来看。但这里我要强调一个安全意识:生产环境的真实token绝对不要粘贴到第三方网站去看,就算是无土传输也一样,随便把线上token贴出去等于把用户身份交出去。我自己的做法是:开发测试时用测试环境生成的token随便贴,线上排障时只打印token的jti和过期时间这些非敏感信息。

另外,我调试时会故意构造一些异常token来测试服务的健壮性,比如:

  • 乱写的字符串,测试无效token分支。
  • 签名字节被篡改一个字符的token,测试签名校验分支。
  • 过期时间设为过去时间的token,测试过期分支。
  • 缺sub字段的token,测试解析分支。

把这几类token挨个打一遍接口,基本就能确认中间件的异常处理是否都返回了正确的401和错误信息,而不是让异常直接冒到最外层变成500。

还有一个实用技巧:日志里不要把完整token打出来,容易被日志收集系统当成敏感信息泄露。我都是打印最后四位和jti,够排查问题了,又能保护token完整内容。

写在项目里的最后一点经验

我做了好几个接入JWT的项目之后,最大的体会是:JWT不是加一层签名就完事的库,而是一套需要设计的认证架构。签发时你想清楚放哪些claim、过期时间定多长、要不要双token、黑名单用什么存储;接收时你想清楚算法白名单、密钥来源、异常分支怎么返回;运维时你想清楚密钥轮换、时钟同步、日志脱敏。这些环节缺一个,线上的坑迟早会踩。

如果你现在正要给API接入JWT,我建议别急着写代码,先花半小时画一下完整的请求链路:登录→签发access_token→带token访问接口→过期→刷新→登出。把这几个节点全部画出来,再按照这个链路去填代码,最后能少改一两轮。这也是我在多轮重构后最想记住的一点。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 3:55:24

Agent-Reach 实战:用 Python 构建能下地干活的 CLI AI Agent

1. 从标题到落地&#xff1a;Agent-Reach 到底想解决什么问题第一次看到 Agent-Reach 这个名字&#xff0c;我下意识把它拆成了两半&#xff1a;Agent 和 Reach。Agent 是当下最热的 AI 智能体概念&#xff0c;Reach 则是"触达、够得着"的意思。合在一起&#xff0c;…

作者头像 李华
网站建设 2026/10/6 3:55:07

HTML5简历模板改造指南:换色、换内容、导出PDF全攻略

简介&#xff1a;这是一款面向求职者的响应式简历网站模板&#xff0c;以绿色为主色调&#xff0c;整体清新自然&#xff0c;适合需要在线展示个人技能与工作经历的求职者使用。压缩包共50个文件&#xff0c;容量约1.38MB&#xff0c;包含HTML页面、CSS样式表、JavaScript交互脚…

作者头像 李华
网站建设 2026/10/6 3:54:08

Agent生产环境可观测性实战:Agent-Reach全链路追踪与效能评估

Agent 应用跑通了 demo&#xff0c;不代表能扛住生产环境。我见过太多团队在发布会现场翻车&#xff1a;Agent 在测试集上表现亮眼&#xff0c;一上真实业务就疯狂跳戏。问题不只在模型本身——你根本看不清它在调用链路上每一步发生了什么、卡在了哪里、为什么绕远路。这也是我…

作者头像 李华
网站建设 2026/10/6 3:53:38

Cursor+MCP连接Figma:AI精准还原设计稿的完整实战

先说我自己的真实感受。过去接设计稿还原这种活儿&#xff0c;最烦的不是写代码&#xff0c;而是“对着稿子猜”。图层命名全是一堆 Frame 123、Rectangle 45&#xff0c;字号间距要自己拿鼠标量&#xff0c;切图还得开一堆插件。后来把Cursor、Figma和MCP这三样东西串在一起&a…

作者头像 李华
网站建设 2026/10/6 3:52:22

OpenShell 免费开源经典开始菜单全指南:从安装到精通

如果你还在忍受 Win10/Win11 那个越来越臃肿的开始菜单&#xff0c;OpenShell 应该出现在你的备选清单里。OpenShell 是一个完全免费、开源的 Windows 开始菜单增强与替换工具。它前身叫 Classic Shell&#xff0c;老版本停更后由社区接手维护&#xff0c;项目名也改成了 Open-…

作者头像 李华
网站建设 2026/10/6 3:51:55

TiDB助力数据库国产化升级:五大行业迁移实践与痛点解析

看到TiDB社群3月14日要在长沙办“数智湖南”活动的预告&#xff0c;我第一反应不是“又一场数据库技术沙龙”&#xff0c;而是这个主题组合背后的信号&#xff1a;零售、医疗、金融、交通、制造……这些行业的数据库国产化升级&#xff0c;已经从PPT汇报阶段&#xff0c;进入真…

作者头像 李华