
简介这是一份面向C#开发者的JWT Token生成与验证示例项目聚焦API身份认证中令牌签发、签名校验、过期处理与授权流程适合正在搭建微服务或Web API安全体系的初中级开发者。压缩包约55MB共748个文件以.cs源码、.cshtml页面、.config配置、.dll程序集及.nupkg依赖包为主并带有完整Visual Studio项目结构含Global.asax与工程文件可直接打开运行。内容涵盖JWT三部分结构、HMAC SHA256签名生成、System.IdentityModel.Tokens.Jwt库的创建与读取、TokenValidationParameters参数校验、刷新令牌策略以及OAuth 2.0场景下的访问令牌应用同时对敏感信息存放、有效期设置和密钥管理等安全实践给出说明。目前已有9420人学习下载适合希望结合完整工程实例快速落地JWT认证机制的开发者。1. JWT Token生成及验证为什么服务端只认签名不认内容做前后端分离或微服务架构时登录态怎么传是绕不开的问题。Session 粘在服务端内存里水平扩展要额外做会话同步直接把用户 ID 塞进 Cookie 又没法防伪造。JWT Token生成及验证正好卡在这个需求点上服务端把用户身份、过期时间、自定义声明一起签名成一个自包含的 Token后续请求只要验签通过就信任里面的内容不需要查库、不需要共享 Session。这个方案看起来简单实际落地时大部分 401 问题不在签名算法而在密钥管理、时间同步和续签策略这些边角料上。我见过一个很典型的案例网关和业务服务部署在不同机器上业务服务偶发性报 token 过期排查到最后发现是两台机器系统时间差了 40 多秒。JWT 验证是纯本地计算稍不留神就是这种“开发环境正常、线上随机翻车”的局面。所以这篇不打算只讲 JWT 是什么而是把生成、验签、续签、密钥轮换到常见漏洞怎么堵按一线落地顺序过一遍。适合正在搭统一登录态、准备从 Session 迁移到 JWT、或者被线上 token 失效问题缠住的开发同学。2. JWT 的载荷与签名先弄清三段结构再写生成代码2.1 Header、Payload 与 Signature三段用 Base64URL 拼出的“身份凭证”一个标准 JWT 长成xxxxx.yyyyy.zzzzz这样用点号分成三段。第一段是 Header里面主要放两个字段alg指定签名算法常见的有 HS256、RS256typ固定是JWT。第二段是 Payload也就是载荷存放注册声明和自定义声明。注册声明里sub表示用户主体标识exp是过期时间戳iat是签发时间jti是 Token 唯一 ID自定义声明可以放user_id、role、tenant_id这类业务字段。第三段是 Signature签名值由 Header 和 Payload 拼接后用指定算法和密钥计算得出。签名这个动作是整个 JWT 的核心。它的输入是base64url(header) . base64url(payload)加上密钥和三段字符串运算出固定长度的签名串。校验方拿到 Token 后用同样的参数重算一遍签名和第三段比对一致才继续解析载荷内容。所以要明确一个概念Payload 里的内容只是 Base64URL 编码不是加密任何人拿到 Token 都能解码看到里面写了什么。JWT 防的是“篡改”不是“泄露”。我一般建议在生成阶段就把这个边界想清楚Payload 里只放非敏感的身份标识和权限标记比如sub、role、jti不要放手机号、身份证这类敏感数据。业务需要这些信息时用sub去后端查而不是直接塞进 Token。了解了结构再来看代码。下面的生成逻辑分别给出 Python 和 Java 两种常见实现覆盖中小团队最常用的技术栈。2.2 用 Python 与 Java 生成 JWTHS256 的完整最小代码Python 生态里最常用的是 PyJWTJava 生态里最常见的是 jjwt。两者对 JWT 标准支持都比较完整不需要自己手写 Base64URL 和签名逻辑但必须明白内部发生了什么。先看 Python 版本的生成import jwt import time import uuid # 注意生产环境密钥要从环境变量或配置中心读取不要硬编码 SECRET_KEY your-secret-key-please-change def generate_jwt(user_id: str, role: str, expire_minutes: int 120) - str: now int(time.time()) payload { # sub 是标准声明表示用户主体通常存用户ID或唯一标识 sub: user_id, # 自定义声明用于权限判断 role: role, # jti 唯一ID后续做黑名单时需要用到 jti: str(uuid.uuid4()), # iat 签发时间 iat: now, # exp 过期时间绝对值不能用相对值 exp: now expire_minutes * 60, # iss 签发者多服务共用一套JWT时建议带上 iss: auth-service, # aud 受众标识这个Token给哪个服务用 aud: api-gateway } # HS256 是双方共享密钥的对称算法 token jwt.encode(payload, SECRET_KEY, algorithmHS256) return token这段代码里最关键的是payload的构造。exp必须是一个绝对时间戳PyJWT 在验证时会拿当前时间与exp直接比较如果你传的是相对秒数Token 签出来就是过期的。jti字段建议每次生成都带上后面做强制下线、黑名单吊销时这个值就是定位 Token 的唯一线索。iss和aud在多服务场景下尤其重要如果没有这两个声明一个签发给订单服务的 Token 可能被用户拿来请求支付服务只要验签能过服务端就认了。再看 Java 版本用 jjwt 库生成同一个 JWTimport io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import java.util.Date; import java.util.UUID; import javax.crypto.SecretKey; public class JwtGenerator { // 生产环境密钥从配置中心获取这里仅演示 private static final SecretKey KEY Keys.hmacShaKeyFor( your-secret-key-please-change-32bytes.getBytes() ); public static String generateToken(String userId, String role, int expireMinutes) { Date now new Date(); Date expireAt new Date(now.getTime() expireMinutes * 60 * 1000L); return Jwts.builder() .setSubject(userId) .claim(role, role) .setId(UUID.randomUUID().toString()) .setIssuedAt(now) .setExpiration(expireAt) .setIssuer(auth-service) .setAudience(api-gateway) .signWith(KEY, SignatureAlgorithm.HS256) .compact(); } }Java 版里signWith(KEY, SignatureAlgorithm.HS256)有两个参数第一个是签名密钥对象第二个是指定算法。这里有个常见的坑Keys.hmacShaKeyFor()要求传入的字节数组长度必须至少 32 字节也就是 256 位否则会抛异常。很多人第一次用 jjwt 时随手写一个短字符串进去启动就报错。Python 那边 PyJWT 对密钥长度要求不严格但这不代表你可以忽略密钥强度。HS256 本质上是一个对称加密 MAC密钥太短等于给攻击者留了口子。2.3 签名算法怎么选RS256 优先HS256 限定同域选算法不能只看顺手。HS256 是共享密钥签名和验签用的是同一个密钥这意味着所有需要验签的服务都必须持有这个密钥。服务一多密钥分发范围就大任何一个服务泄露密钥攻击者就能自己伪造合法 Token。RS256 是非对称算法私钥只放在签发服务验签的服务只需要公钥。公钥泄露不影响签名安全最多被拿去验签伪造不了新 Token。对比项HS256RS256密钥类型对称密钥签验共用私钥签发公钥验签分发范围所有验签服务都需持有只需签发服务持有私钥泄露影响攻击者可伪造任意 Token公钥泄露不会导致伪造计算开销快稍慢适用场景单服务、纯内网可信环境微服务、多服务鉴权、跨团队协作我一般的建议是只要你的系统不止一个服务就默认用 RS256。虽然生成和验签的代码比 HS256 多几行配置但密钥管理半径小了一个数量级。尤其是现在很多团队把 JWT 当成统一登录态前后端分离、多个后端服务互相调用公钥分发到每个服务私钥锁在认证中心这个结构才是安全的。3. 验证流程与 401 排查从解码到验签每个环节都值得单独处理3.1 服务端验证的四个步骤格式、签名、时效、声明JWT 验证不是简单调一个decode就算完。服务端拿到 Token 后至少要依次做四件事第一步检查字符串是不是三段结构Base64URL 是否能正常解码第二步用当前有效的密钥验签确认 Token 没有被篡改第三步检查exp和nbf判断当前时间是否在有效窗口内第四步校验iss、aud以及业务自定义声明比如角色是否允许访问当前接口。import jwt from jwt.exceptions import InvalidTokenError, ExpiredSignatureError, InvalidAudienceError def verify_jwt(token: str) - dict: try: # decode 会同时完成签名校验与 exp/nbf 检查 payload jwt.decode( token, SECRET_KEY, algorithms[HS256], # 明确白名单禁止跟随Header指定算法 audienceapi-gateway, issuerauth-service, ) return payload except ExpiredSignatureError: # 过期需要单独处理因为用户可能期望走刷新流程 raise PermissionDenied(token expired) except InvalidAudienceError: # 受众不对说明Token不是给当前服务签发的 raise PermissionDenied(invalid audience) except InvalidTokenError: # 签名不合法、格式错误等统一走这里 raise PermissionDenied(invalid token)这段验证代码里algorithms[HS256]是必须显式传的绝对不能省略。PyJWT 在旧版本里如果不指定algorithms会默认跟随 Token Header 里的alg字段这直接导致算法混淆攻击后面章节会专门讲。audience和issuer建议每次都传尤其是多服务共用密钥时这两个声明能防止一个服务的 Token 被拿到另一个服务用。Java 版本验证逻辑类似用Jwts.parserBuilder()构建解析器后调用parseClaimsJws(token)这个方法会自动完成签名校验和时间检查。但如果也要做iss、aud校验需要显式配置import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.SignatureException; import io.jsonwebtoken.ExpiredJwtException; public Claims verifyToken(String token, SecretKey key) { return Jwts.parserBuilder() .setSigningKey(key) .requireIssuer(auth-service) .requireAudience(api-gateway) .build() .parseClaimsJws(token) // 这行不通过会直接抛异常 .getBody(); }3.2 时钟偏移与过期时间exp 校验失败的隐蔽原因校验exp看起来是纯时间戳比较实际生产环境里有个容易被忽略的问题不同服务器的系统时钟不一定同步。认证中心签发 Token 时用的是 A 机器的时间业务服务验签时用的是 B 机器的时间如果 B 比 A 慢 60 秒一个有效期 2 小时的 Token 实际只能存活 1 小时 59 分用户会偶发抱怨“刚登录就过期”。最常见的解决办法是给验签逻辑加时钟偏移容忍度。PyJWT 的jwt.decode方法支持leeway参数按秒计算payload jwt.decode( token, SECRET_KEY, algorithms[HS256], audienceapi-gateway, issuerauth-service, leeway30 # 允许30秒的时钟偏差 )Java 的 jjwt 也有对应机制通过setAllowedClockSkewSeconds配置Jwts.parserBuilder() .setSigningKey(key) .setAllowedClockSkewSeconds(30) .build() .parseClaimsJws(token);但我要提醒一句leeway不是调得越大越好。它本质上是把exp的有效期悄悄延长了设 30 秒是合理的设到 10 分钟就等于把 Token 有效期白白拉长隐患更大。另外建议在所有机器上配置 NTP 时间同步从源头解决这个问题而不是依赖应用层忍受偏差。3.3 用 curl 模拟 token 发包把验证逻辑暴露在服务端之外开发阶段调试 JWT我习惯用 curl 直接模拟客户端发包这样能把“生成 Token 的代码”和“验证 Token 的代码”分开排查。先拿到一个 Token然后在命令行里构造请求# 先获取token这里假设你的认证接口返回json我们用jq提取token字段 TOKEN$(curl -s -X POST https://api.example.com/auth/login \ -H Content-Type: application/json \ -d {username:tester,password:pass} | jq -r .token) # 用提取到的token访问业务接口 curl -i https://api.example.com/api/orders \ -H Authorization: Bearer $TOKEN这个手法的价值在于它能快速区分问题是出在“Token 没签发出来”还是“签发出来了但验签不通过”。加-i参数可以看完整响应头如果返回 401配合-vvv能看到完整的请求与响应过程。我在排查网关层 token 失效时经常在 Nginx 或网关入口直接打印AuthorizationHeader确认客户端实际发的 Token 是不是服务端返回的那个。如果响应是 401 但日志里看不到签名异常那大概率是 Token 在传输过程中被截断或修改了。我之前遇到过一次前端把 Token 存 localStorage请求时带上了多余的空格服务端解出来多一个字符验签必然失败。这种事用 curl 一测就现原形。4. JWT 避坑与常见问题5 个会让线上直接翻车的细节4.1 密钥硬编码进代码默认密钥是漏洞重灾区现象某天发现攻击者用一套固定的 JWT 密钥伪造了管理员 Token直接调用了内部接口。原因代码里把SECRET_KEY admin123写死在常量区或者直接用了网上教程里的示例密钥。这不是假设之前暴露过不少因默认 JWT 密钥导致认证绕过的事件。攻击者不需要暴力破解只需要收集常见的默认密钥黑名单逐个试签就能伪造出合法 Token。解决密钥必须从环境变量、配置中心或密钥管理服务读取并且定期轮换。代码评审时要专门检查有没有硬编码的密钥、口令、盐值。另外建议在配置中心里记录密钥的版本号方便轮换时按版本灰度。4.2 Token 失效之后JWT 天然没有“后悔药”现象用户点了“退出登录”但拿旧 Token 还是能访问接口直到exp过期。原因JWT 是无状态的服务端没记录 Token 状态验签通过就认为合法除非改密钥否则没法主动作废单个 Token。解决给每个 Token 生成jti在 Redis 里维护一个黑名单退出登录或修改密码时把jti写入黑名单并设置过期时间与exp对齐。每次验签时先查一下黑名单。这个方案会给每次请求增加一次 Redis 查询但换来了可控的吊销能力。如果你想彻底无状态可以接受“修改密码后最多 T 分钟内旧 Token 仍可用”的窗口但绝大多数业务场景都不该接受这个风险。4.3 KID 参数被滥用请求头里的“路径穿越”入口现象安全扫描发现攻击者通过修改 JWT Header 里的kid字段可以读取服务端任意文件内容。原因部分实现会用kid作为文件名或路径去加载密钥比如key/kid.pem攻击者把kid改成../../etc/passwd程序去读这个路径内容被带进签名验证逻辑造成信息泄露。解决kid应该是一个索引值用它在一个固定映射表里找密钥而不是直接拼接路径。最稳妥的做法是服务端维护kid - 密钥的 Map未知的kid一律拒绝。另外如果用的是 Node.js 的jsonwebtoken注意algorithms白名单必须显式设置配合key的选取逻辑一起做安全审查。4.4 算法混淆攻击把 RS256 的验签流程带进 HS256 的坑现象服务端用 RSA 公钥验证签名攻击者把 Token Header 里的alg改成HS256然后用服务端的公钥作为 HMAC 密钥来签名服务端竟然验签通过了。原因老版本的 JWT 库在验签时跟随 Header 里的alg字段选择算法公钥虽然是私钥的一半但它本身是一串字节可以被当作 HS256 的共享密钥。解决验签时显式指定algorithms白名单不要信任 Token Header 里的alg字段。所有主流的 JWT 库都支持指定允许的算法列表这行配置是安全底线。运行时如果发现 Header 里的alg不在白名单直接抛异常。4.5 敏感信息放 payloadBase64 不是加密现象业务日志里偶然打出了一段 JWT 的完整内容解码后发现里面带着用户的手机号和邮箱地址。原因Payload 默认只是编码不是加密只要有人拿到 TokenBase64URL 解码后就能看到明文。日志系统、浏览器调试面板、网关访问日志都可能成为泄露渠道。解决JWT 里只放sub、role、jti这类身份标识和权限声明不放任何明文敏感字段。业务需要手机号、邮箱、地址时用sub去用户服务查询。如果实在有敏感声明必须放进 Token考虑用加密 JWTJWE但这引入了额外的密钥管理和性能开销能用查库解决的别自找麻烦。5. Token 续签与失效策略让 JWT 不再一过期就把用户踢下线5.1 滑动续签 vs 刷新令牌两种常见做法的取舍JWT 的一个天然限制是签发后无法修改exp。用户一直在线操作2 小时有效期到了就被踢下线体验很差。解决这个问题的常见思路有两类滑动续签和刷新令牌。滑动续签的做法是每次请求验签时检查剩余有效期如果剩余时间小于某个阈值就顺便签发一个新 Token 返回给客户端客户端下次请求时替换掉旧 Token。这个方案的优点是服务器不维护状态缺点是每个请求要额外做一次到期时间判断而且 Token 有效期在活跃用户那里无限延长风险随之上升。适合对安全要求不高的内部工具类系统。刷新令牌的做法是客户端持有两个 Token一个是短期的access_token有效期 15 分钟到 2 小时一个是长期的refresh_token有效期 7 天到 30 天。access_token过期时客户端拿refresh_token去认证中心换取新的access_token。refresh_token保存在服务端或 Redis 里可以主动吊销。适合对外业务系统。方案服务端状态吊销能力实现复杂度安全等级滑动续签无无法主动吊销低中双 Token Refresh有Refresh 存库可吊销 Refresh Token中高5.2 用 Redis 黑名单实现可控失效核心命令与代码实现可控失效核心是解决“JWT 签发后改不回来”这个矛盾。我的做法是给每个 Token 分配jti退出登录、修改密码、封号时把jti写入 Redis并让黑名单的过期时间跟exp对齐。写黑名单的代码很简单import redis r redis.Redis(host10.0.0.5, port6379, decode_responsesTrue) def revoke_token(jti: str, expire_at: int): # 计算从现在到exp的剩余秒数 ttl expire_at - int(time.time()) if ttl 0: return # SET NX如果jti不存在就设置避免重复 # EX给黑名单项设置与原Token一致的过期时间 r.set(ftoken:blacklist:{jti}, 1, exttl, nxTrue)验证时在完成签名和exp校验后加一步黑白名单检查def is_token_revoked(jti: str) - bool: return r.exists(ftoken:blacklist:{jti}) 1这里的关键点是nxTrue在同一 Token 被重复吊销时不会刷新 TTL避免攻击者把一个已吊销的jti不断提交续期导致黑名单项永不过期。黑名单的存储量取决于单位时间内的吊销量和 Token 有效期。按一天 10 万次退出登录、有效期 2 小时计算同时存在的黑名单项大约 8 万Redis 完全没压力。5.3 双 Token 方案的最小实现access_token 与 refresh_token 分工双 Token 的完整实现我拆成三步走。第一步登录成功时同时签发access_token和refresh_token第二步access_token短有效期直接在业务服务的验签流程里做第三步refresh_token不参与业务验签只在认证中心的刷新接口里使用存储在 Redis 并绑定用户 ID 和设备信息。def refresh_access_token(refresh_token: str) - tuple[str, str]: # 校验refresh_token的签名和有效期 payload jwt.decode( refresh_token, REFRESH_SECRET_KEY, algorithms[HS256], verifyTrue ) user_id payload[sub] jti payload[jti] # 检查refresh_token是否还在Redis里 # 如果不存在说明已被吊销或兑换过拒绝 stored r.get(frefresh_token:{jti}) if stored ! user_id: raise PermissionDenied(refresh token revoked or used) # 生成新的access_token和refresh_token new_access generate_jwt(user_id, payload.get(role), expire_minutes30) new_refresh generate_refresh_token(user_id) # 旧refresh_token一次性使用立即删除防止重放 r.delete(frefresh_token:{jti}) # 新refresh_token入库 new_jti jwt.decode(new_refresh, REFRESH_SECRET_KEY, algorithms[HS256])[jti] r.set(frefresh_token:{new_jti}, user_id, ex7 * 24 * 3600) return new_access, new_refresh这个实现有几个细节refresh_token必须是一次性的换过一次就删除这样即使泄露攻击者也只能用一次refresh_token要绑定用户 ID防止用户把 Token 给别人用存储时设置ex7 * 24 * 3600与刷新 Token 本身的签名有效期一致不会出现 Redis 占着但 Token 早过期的情况。6. 进阶把 KID 与密钥轮换做成可运维的配置而不是玄学最后一个问题是我见过的不少团队都会踩的密钥轮换时一把新密钥上线所有老 Token 瞬间全部失效用户集体重新登录。根本原因是验签时用了固定密钥而 Token 里没有任何信息告诉服务端它是由哪把钥匙签出来的。kid就是干这个用的。正确做法是生成 Token 时在 Header 里写入kid服务端维护kid - 密钥的映射。轮换时新密钥上线但老密钥保留一段过渡期新旧 Token 都能验签通过。我一般按这个流程操作# 生成新密钥对RS256并分配kid openssl genrsa -out private_new.pem 2048 openssl rsa -in private_new.pem -pubout -out public_new.pem # kid建议用日期序号比如 key-2025-06-01-v1配置中心里同时存在key-2025-06-01-v1和key-2025-06-15-v1两组密钥签发时默认用新密钥验签时根据kid选择对应公钥。过渡期结束后下线旧密钥。这个机制还可以配合日志统计如果旧密钥的验签量降到接近零说明老 Token 基本自然过期可以安全清理。验证整个 JWT 方案是否稳定我习惯做一轮长时间的本地巡检用脚本每天生成大量 Token模拟正常请求和过期请求确认exp顺延、黑名单拦截、刷新链路三个指标都符合预期。这门技术本身不大但涉及的安全细节多把密钥管理系统搭好把续签和吊销策略想清线上能省掉九成的登录态事故。希望帮到你。本文还有配套的精品资源点击获取