JWT结构与使用场景

候选人小王在面试某家SaaS公司时,面试官翻到简历上"使用JWT做用户认证"这一行,开口问道:

"JWT是怎么组成的?Token过期了怎么办?"

小王说:"JWT就是一段字符串,分成三部分,用点号隔开。过期了就让用户重新登录..."

面试官继续追问:"那Token被盗了怎么办?用户怎么主动注销Token?"

小张开始支支吾吾...

从一个问题开始

小王的回答暴露了很多初学者对JWT的典型理解:知道它是"一段字符串",知道"过期就重新登录",但不知道它背后到底是怎么工作的,也不知道它的局限性在哪里。

JWT看起来很简单,但它的设计哲学和使用边界,比大多数人想象的要复杂得多。

今天这篇文章,就是帮你把JWT从"会用"升级到"理解原理、知道边界"。

【直观类比】

JWT像一张自带防伪的通行证

想象你去游乐园,买了张通行证(Token)。

这张通行证上印着:

  • 正面信息:你的名字、有效日期、允许进入的区域(Payload)
  • 防伪签名:游乐园官方盖的章,能证明这张票是真的(Signature)

检票员不需要去数据库查你的购票记录,只需要:

  1. 看正面信息是否有效
  2. 验一下印章是不是真的

JWT就是这样的工作原理——它是自包含的,验证方不需要存储任何会话信息。

💡

这就是JWT最核心的优势:无状态验证。服务端不需要维护会话存储,Token本身包含了验证所需的所有信息。

但是...

游乐园通行证有个问题:一旦发给游客,你就收不回来了

如果你的票丢了,任何捡到的人都可以用。游客想要"注销"这张票?抱歉,只能等它过期。

这和JWT的"被动失效"问题是一模一样的。

核心原理

JWT的完整结构

JWT由三部分组成,用点号(.)连接:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

这三部分分别是:Header(头部)Payload(载荷)Signature(签名)

Header:声明类型和算法

{
  "alg": "HS256",
  "typ": "JWT"
}

然后用Base64URL编码:

import base64
import json

header = {"alg": "HS256", "typ": "JWT"}
header_json = json.dumps(header)
# Base64URL编码(注意不是标准Base64,+换成-,/换成_)
header_base64 = base64.urlsafe_b64encode(header_json.encode()).rstrip(b'=')
# 结果:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
⚠️

Base64不是加密!Header部分是明文的,任何人都能解码看到内容。所以不要在Payload里放敏感信息,比如密码。

Payload:存放声明

Payload里放的是Claims(声明),分为三种类型:

{
  "sub": "1234567890",      // 注册声明:subject,用户ID
  "name": "John Doe",       // 公开声明:自定义字段
  "iat": 1516239022,        // 注册声明:issued at,签发时间
  "exp": 1516242622         // 注册声明:expiration time,过期时间
}

常用的注册声明(Registered Claims):

声明含义示例
ississuer,签发者"iss": "example.com"
subsubject,主题/用户ID"sub": "12345"
audaudience,受众"aud": "api.example.com"
expexpiration time,过期时间"exp": 1699999999
nbfnot before,生效时间"nbf": 1699999000
iatissued at,签发时间"iat": 1699999000
jtiJWT ID,唯一标识"jti": "unique-id"

然后同样用Base64URL编码:

payload = {
    "sub": "1234567890",
    "name": "John Doe",
    "iat": 1516239022
}
payload_base64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=')
# 结果:eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ

Signature:防伪签名

签名才是JWT的核心防伪机制:

import hmac
import hashlib

# 签名算法
def create_signature(header_base64, payload_base64, secret):
    message = f"{header_base64}.{payload_base64}"
    signature = hmac.new(
        secret.encode(),
        message.encode(),
        hashlib.sha256
    ).digest()
    # Base64URL编码
    return base64.urlsafe_b64encode(signature).rstrip(b'=')

signature = create_signature(header_base64, payload_base64, "your-secret-key")
# 结果:SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

签名的作用:如果Header或Payload被篡改,签名验证就会失败,因为攻击者没有密钥,无法生成新的有效签名。

完整JWT生成流程

def create_jwt(payload, secret, algorithm="HS256"):
    # 1. 创建Header
    header = {"alg": algorithm, "typ": "JWT"}
    header_base64 = base64.urlsafe_b64encode(
        json.dumps(header).encode()
    ).rstrip(b'=')
    
    # 2. 创建Payload
    payload_base64 = base64.urlsafe_b64encode(
        json.dumps(payload).encode()
    ).rstrip(b'=')
    
    # 3. 创建签名
    message = f"{header_base64}.{payload_base64}"
    if algorithm.startswith("HS"):
        signature = hmac.new(
            secret.encode(), message.encode(), hashlib.sha256
        ).digest()
    else:
        # RS256等非对称算法使用RSA
        signature = rsa_sign(message, private_key)
    
    signature_base64 = base64.urlsafe_b64encode(signature).rstrip(b'=')
    
    # 4. 拼接
    return f"{header_base64}.{payload_base64}.{signature_base64}"

签名算法:HS256 vs RS256

这是面试中经常被问到的问题,也是选型时的关键决策点。

HS256:对称签名

# 签发时:使用同一个密钥签名
token = jwt.sign({"user_id": 123}, "my-secret-key", algorithm="HS256")

# 验证时:使用同一个密钥验证
payload = jwt.verify(token, "my-secret-key", algorithm="HS256")

优点:简单,高性能 缺点:所有能验证Token的服务必须知道密钥。如果有多个服务,任何一个服务泄露密钥,整个系统都不安全。

适用场景:单体应用、微服务内部通信、团队内部共享密钥的场景。

RS256:非对称签名

# 签发时:使用私钥
token = jwt.sign({"user_id": 123}, private_key, algorithm="RS256")

# 验证时:使用公钥
payload = jwt.verify(token, public_key, algorithm="RS256")

优点:私钥只在签发服务器上,公钥可以公开给所有验证服务。即使验证服务被攻破,攻击者也只知道公钥,无法伪造Token。

适用场景:跨服务认证、开放API、第三方应用集成。

💡

OAuth2.0的Access Token通常用RS256,因为授权服务器和资源服务器可能是不同的团队甚至不同的公司。

边界与特例

Token过期了怎么办?

Token过期后,用户需要重新获取。有几种常见策略:

方案1:让用户重新登录(最简单)

  • 缺点:体验差
  • 适用:安全性要求高、用户操作不频繁的场景

方案2:Refresh Token

登录时返回两个Token:
- Access Token:短期token(15分钟)
- Refresh Token:长期token(7天)

Access Token过期后,用Refresh Token换新的Access Token
# Refresh Token流程
def refresh_access_token(refresh_token):
    # 验证refresh token
    payload = jwt.verify(refresh_token, refresh_secret)
    
    # 生成新的access token
    new_access_token = jwt.sign(
        {"user_id": payload["user_id"]},
        access_secret,
        algorithm="HS256",
        expires_in=900  # 15分钟
    )
    return new_access_token

方案3:Sliding Window / Fixed Window

  • Sliding Window:每次使用Token时,如果发现剩余有效期不足一半,就续期
  • Fixed Window:固定时间点续期,比如每天0点

Token被盗了怎么办?

这是JWT最大的痛点:Token一旦签发,服务端无法主动撤销

解决方案

  1. 短期Token:Access Token有效期设短一点(15分钟),即使被盗,损失也有限
  2. Token黑名单:维护一个Redis黑名单,存储已撤销Token的jti。但这违背了"无状态"的初衷
  3. 双Token + 用户修改密码:用户改密码时,使 Refresh Token失效
  4. Token版本号:在Payload中加一个version字段,用户注销时递增版本号
# 黑名单方案
def is_token_revoked(jti):
    return redis.exists(f"revoked_token:{jti}")

def revoke_token(jti, exp):
    # 过期时间-当前时间 = 需要在黑名单中存储的时间
    ttl = exp - int(time.time())
    if ttl > 0:
        redis.setex(f"revoked_token:{jti}", ttl, "1")
⚠️

如果你需要"用户注销后立即失效"这样的强需求,JWT可能不是最佳选择,建议用Session。

JWT的Payload能被篡改吗?

不能。签名是基于Header和Payload计算的,任何修改都会导致签名验证失败。

但是,Payload是Base64编码,不是加密。所以:

  • ✅ 攻击者不能修改Payload(签名会不匹配)
  • ✅ 攻击者不能伪造Token(没有密钥)
  • ⚠️ 攻击者可以看到Payload的内容(Base64解码即可)

不要在JWT Payload中存储任何敏感信息,比如密码、身份证号、银行卡号。

常见误区

误区1:JWT比Session更安全

JWT和Session是两种不同的会话管理方案,不是谁比谁更安全的问题。

  • Session把会话存在服务端,Token存在客户端
  • JWT本身也是一种Token,验证时可能需要查黑名单
  • 没有绝对更安全的方案,只有更适合场景的方案

误区2:JWT Payload是加密的

不是。JWT的Header和Payload只是Base64编码,任何人都能解码看到内容。签名只是防止篡改,不防止读取。

误区3:Token存Cookie比存LocalStorage更安全

存Cookie:

  • ✅ 可以设置HttpOnly防止XSS读取
  • ✅ 可以设置Secure只在HTTPS下传输
  • ⚠️ 容易受到CSRF攻击

存LocalStorage:

  • ✅ 不受CSRF影响
  • ⚠️ 容易被XSS攻击窃取
💡

移动端App建议用Keychain/Keystore,比Web存储更安全。

误区4:永远不要过期

很多初学者为了"永不过期",干脆不设置exp

这是严重的安全漏洞。如果Token被盗,攻击者就拥有了永久访问权限。

合理的过期时间:

  • Access Token:15分钟~1小时
  • Refresh Token:1天~7天
  • API Token:根据业务场景,可以长一些

记忆技巧

口诀

JWT三部分,头部声明算法,载荷放声明,签名来防伪 HS256对称密钥,团队内部用,RS256公私钥,开放API用 Payload不是加密,敏感信息别放里 Token要短期,被盗才不慌,长期用Refresh

JWT vs Session速查

特征JWTSession
存储位置客户端服务端(Redis等)
验证方式签名验证Session ID验证
扩展性好(无状态)需共享存储
撤销能力弱(需黑名单)强(服务端删除)
PayloadBase64编码敏感信息在服务端
适用场景微服务、开放API需要强撤销的场景

实战检验

检验问题1

问题:前端每次请求都要带Token,你是怎么传递的?

常见错误答案

  • 直接放URL参数(?token=xxx)—— 会被浏览器历史记录、服务器日志记录
  • 放LocalStorage —— 可能被XSS攻击窃取

推荐答案

  1. Authorization: Bearer <token>请求头(最佳)
  2. 放Cookie(配合HttpOnly、Secure、SameSite)

检验问题2

问题:用户在多个设备登录,怎么实现"单点登录"?

分析

  • Session方案:在Redis中以用户ID为Key存储会话列表,注销时删除所有
  • JWT方案:维护Token黑名单,或用Refresh Token关联所有设备会话

检验问题3

问题:微服务架构下,API网关怎么验证JWT?

推荐架构

用户 → API Gateway → 验证JWT → 路由到具体服务

                   公钥(从授权服务器获取,缓存)

授权服务器用RS256签发Token,公钥公开。API Gateway拿到公钥后可以验证Token,无需查询授权服务器。

【面试官心理】

面试官问JWT,其实是在测试你对"无状态认证"的理解深度。知道JWT三部分结构是60分,知道HS256/RS256区别是80分,知道JWT的局限性和适用场景是90分,如果还能说出撤销机制和微服务认证方案,那就是P7的水平了。


延伸阅读