一、一次登录的完整链路
很多文章把“登录成功”等同于“拿到 JWT”,但在游戏服务端,这两件事之间还隔着一层。登录成功只说明账号凭证正确;真正能让网关放行、让场景服接受请求的,是一份可调用凭证。完整链路通常是这样:
- 客户端通过 TLS 把账号凭证送到登录服,凭证可以是密码、短信验证码、第三方 OAuth code 或平台票据。
- 登录服校验凭证,检查封禁、风控、设备异常,必要时要求二次验证。
- 登录服查询账号可用的区服与角色列表,让玩家完成选区、选角。这一步的细节属于身份模型,不在本篇展开。
- 登录服签发 access token,并在需要长期会话时同时签发 refresh token。access token 里写入
sub(账号)、aud(允许调用方)、jti(唯一标识)、exp等声明。 - 客户端带着 access token 连接网关。网关验签、验声明、核对区服与角色,然后把连接绑定到会话。
- 网关把已认证上下文转发给场景服或世界服。下游可以再次校验 Token,也可以只信任网关并检查内部签名,取决于信任边界。
- access token 过期后,客户端用 refresh token 换取新的 access token。刷新、轮换与吊销放在 /game-server-in-action-423/ 展开。
这条链路里有两个容易混淆的点。
第一,JWT 不是登录凭证,而是调用凭证。账号密码只应该发给登录服,绝不能拿去换 JWT 的接口反复提交。JWT 一旦签发,持有者就能在有效期内调用对应受众的服务,所以它的泄露后果和密码不同。
第二,验签通过不等于有权访问。签名只证明“这份 Token 由我们签发、内容没被改过”,不证明“这个账号现在还能玩、这个角色属于这个区、这个设备没有被封”。业务授权必须在验签之后单独做。
二、JWT 的三段结构
JWT 是一个用点号分隔的三段字符串:
header.payload.signature
三段都是 base64url 编码。header 描述算法与类型,payload 是声明集合,signature 是对前两段的签名。base64url 只是编码,不是加密;任何人拿到 Token 都能解开前两段看到明文。因此 payload 里不能放手机号、邮箱、身份证、余额这类敏感信息,也不能放“角色等级”这种会变化、又会被下游当真的事实。
必须校验的字段如下:
| 字段 | 位置 | 校验规则 | 不校验的后果 |
|---|---|---|---|
alg | header | 只接受配置中允许的算法,明确拒绝 none | 攻击者改算法或伪造无签名 Token |
typ | header | 等于 JWT | 把其他用途的签名结构当 Token 使用 |
iss | payload | 等于预期的签发方 | 接受其他系统签发的同密钥 Token |
aud | payload | 包含当前服务的受众标识 | 登录服 Token 被拿去调战斗服或后台接口 |
exp | payload | 当前时间早于 exp 加时钟偏移 | 过期 Token 永久可用 |
nbf | payload | 当前时间不早于 nbf 减时钟偏移 | 提前签发的 Token 被立即使用 |
iat | payload | 不晚于当前时间加偏移 | 无法识别明显来自未来的伪造 Token |
sub | payload | 非空,且能映射到账号 | 匿名或空主体进入业务层 |
jti | payload | 非空且全局唯一 | 无法按单枚 Token 拉黑或审计 |
这张表里最常被跳过的是 aud 和 jti。前者导致“一个 Token 到处能用”,后者导致出事时只能按账号或时间范围封禁,无法精确吊销某一枚泄露的凭证。
三、签名算法怎么选
JWT 常见的签名算法有三类,选择取决于密钥怎么分布、谁来验签。
| 算法 | 密钥形态 | 验签成本 | 密钥轮换 | 适用场景 |
|---|---|---|---|---|
| HS256 | 所有签发方与验签方共享同一密钥 | 低 | 共享密钥,轮换要同时更新所有服务 | 单体登录服、网关与登录服同信任域 |
| RS256 | 私钥签发,公钥验签 | 较高 | 通过 kid 发布新公钥,旧公钥保留一段时间 | 多服务验签、第三方接入、信任域分离 |
| EdDSA | 私钥签发,公钥验签 | 低,签名短 | 同 RS256,但密钥与签名更小 | 新系统、移动端、希望减少带宽 |
HS256 的问题不是“不安全”,而是密钥扩散。每一个验签服务都持有签发密钥,任何一个服务被攻破,攻击者都能伪造全站 Token。RS256 与 EdDSA 把签发能力收在登录服,验签方只拿公钥,泄露公钥无法伪造签名,代价是密钥管理和 kid 轮换更复杂。
无论选哪种,都必须显式拒绝两件事:
alg: none。部分库在没有配置算法时会接受无签名 Token,这是历史遗留的经典漏洞。- 算法混淆。攻击者把 header 改成
RS256,再用公钥内容当作 HMAC 密钥签名;如果验签方按 header 动态选择算法,就可能把公钥当共享密钥验过。正确做法是由服务端配置决定算法,header 里的alg只用于比对,不用于选择。
kid 轮换时,验签方要按 kid 查公钥,找不到就拒绝,不能回退到“默认密钥”。旧公钥要保留到所有由它签发的 Token 都过期,通常是 access token 寿命加时钟偏移。
四、有效期、时钟与 payload 边界
access token 的有效期通常设为 10 到 15 分钟。太短会让刷新请求变多,太长会让泄露窗口变大。刷新令牌的有效期可以到几天或几周,但必须有轮换和吊销机制,否则只是把长寿命凭证换了个名字。
时钟偏移是分布式系统里绕不开的问题。登录服、网关、场景服的时钟不可能完全一致,验签方应允许 30 到 60 秒的偏移,并且所有节点接同一套 NTP。偏移量不能拍脑袋放大到几十分钟,那等于变相延长 Token 寿命。
payload 的边界可以用一句话概括:只放验签时必须知道、且在整个有效期内不会变化的事实。账号 ID、受众、签发方、过期时间、jti 都合适;角色等级、背包摘要、VIP 状态不合适,因为它们会变,而 Token 不会跟着变。下游如果需要这些数据,应该按 sub 回查,而不是信任 Token 里的旧值。
还有一个游戏场景特有的边界:Token 会出现在 WebSocket 握手 URL、HTTP 请求头、网关访问日志和客户端崩溃报告里。握手 URL 尤其危险,因为 URL 容易被代理、浏览器历史和监控系统记录。更稳妥的做法是握手阶段只传一次性票据,连接建立后再用票据换 Token,或者把 Token 放在 Authorization 头里而不是查询串。日志侧要对 Token 做脱敏,只保留 jti、sub 和过期时间,不能整段打印。
五、标准库实现 HS256
下面这份实现不依赖第三方库,把签发、验签、声明校验和错误分类放在一个文件里。重点看三处:base64url 必须用无填充编码,签名比较必须用常量时间函数,验签顺序必须是先签名后声明。
// file: ch04/token/jwt.go
// tier: L1
// verify: go test ./ch04/token/
package token
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"time"
)
// Claims 是登录服放进 access token 的最小事实集合。
// 不要在这里放手机号、邮箱、余额等敏感或易变数据。
type Claims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
Audience string `json:"aud"`
ExpiresAt int64 `json:"exp"`
NotBefore int64 `json:"nbf,omitempty"`
IssuedAt int64 `json:"iat"`
JWTID string `json:"jti"`
}
type jwtHeader struct {
Alg string `json:"alg"`
Typ string `json:"typ"`
}
// VerifyOptions 决定“谁能签发、签给谁、允许多少时钟偏移”。
type VerifyOptions struct {
Issuer string
Audience string
Leeway time.Duration
Now func() time.Time
}
var (
ErrJWTMalformed = errors.New("token: jwt malformed")
ErrJWTAlg = errors.New("token: jwt unsupported alg")
ErrJWTType = errors.New("token: jwt unsupported typ")
ErrJWTSignature = errors.New("token: jwt bad signature")
ErrJWTExpired = errors.New("token: jwt expired")
ErrJWTNotYetValid = errors.New("token: jwt not yet valid")
ErrJWTIssuer = errors.New("token: jwt issuer mismatch")
ErrJWTAudience = errors.New("token: jwt audience mismatch")
ErrJWTClaims = errors.New("token: jwt required claim missing")
ErrJWTKey = errors.New("token: empty signing key")
)
// Sign 使用 HS256 签发 token。单服务或共享密钥的小规模部署够用;
// 多服务独立验签时应改用 RS256 或 EdDSA。
func Sign(key []byte, claims Claims) (string, error) {
if len(key) == 0 {
return "", ErrJWTKey
}
if claims.Subject == "" || claims.JWTID == "" || claims.ExpiresAt == 0 {
return "", ErrJWTClaims
}
headerJSON, err := json.Marshal(jwtHeader{Alg: "HS256", Typ: "JWT"})
if err != nil {
return "", err
}
claimsJSON, err := json.Marshal(claims)
if err != nil {
return "", err
}
header := base64.RawURLEncoding.EncodeToString(headerJSON)
payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
signingInput := header + "." + payload
mac := hmac.New(sha256.New, key)
mac.Write([]byte(signingInput))
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return signingInput + "." + signature, nil
}
// Verify 先验签名,再验声明。顺序不能反:未验签的 payload 只是攻击者可控的 JSON。
func Verify(raw string, key []byte, opt VerifyOptions) (Claims, error) {
if len(key) == 0 {
return Claims{}, ErrJWTKey
}
parts := strings.Split(raw, ".")
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
return Claims{}, ErrJWTMalformed
}
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return Claims{}, ErrJWTMalformed
}
var header jwtHeader
if err := json.Unmarshal(headerJSON, &header); err != nil {
return Claims{}, ErrJWTMalformed
}
if header.Alg != "HS256" {
return Claims{}, ErrJWTAlg
}
if header.Typ != "JWT" {
return Claims{}, ErrJWTType
}
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return Claims{}, ErrJWTMalformed
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(parts[0] + "." + parts[1]))
if !hmac.Equal(sig, mac.Sum(nil)) {
return Claims{}, ErrJWTSignature
}
claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return Claims{}, ErrJWTMalformed
}
var claims Claims
if err := json.Unmarshal(claimsJSON, &claims); err != nil {
return Claims{}, ErrJWTMalformed
}
if claims.Subject == "" || claims.JWTID == "" || claims.ExpiresAt == 0 {
return Claims{}, ErrJWTClaims
}
now := time.Now()
if opt.Now != nil {
now = opt.Now()
}
leeway := opt.Leeway
if opt.Issuer != "" && claims.Issuer != opt.Issuer {
return Claims{}, ErrJWTIssuer
}
if opt.Audience != "" && claims.Audience != opt.Audience {
return Claims{}, ErrJWTAudience
}
if !now.Before(time.Unix(claims.ExpiresAt, 0).Add(leeway)) {
return Claims{}, ErrJWTExpired
}
if claims.NotBefore != 0 && now.Add(leeway).Before(time.Unix(claims.NotBefore, 0)) {
return Claims{}, ErrJWTNotYetValid
}
if claims.IssuedAt != 0 && now.Add(leeway).Before(time.Unix(claims.IssuedAt, 0)) {
return Claims{}, ErrJWTNotYetValid
}
return claims, nil
}
几个实现细节:
base64.RawURLEncoding 对应 JWT 规定的无填充 base64url。用带填充的 StdEncoding 会产生 =,导致与其他语言实现的 Token 不兼容。
hmac.Equal 是常量时间比较,避免通过响应时间差逐字节猜签名。自己写 bytes.Equal 或 == 在理论上会泄露比较进度。
Verify 先解码 header 并检查 alg、typ,再验签名,最后才解码 payload 校验声明。这个顺序不能反:未验签的 payload 只是攻击者可控的 JSON,提前读取 sub 并写进上下文,等于把认证交给了攻击者。
VerifyOptions 把签发方、受众、时钟偏移和时钟函数显式传入,测试可以注入固定时钟,生产代码也不会依赖全局时间。ExpiresAt、NotBefore、IssuedAt 用 Unix 秒,和 JWT 规范一致。
六、失败场景:只 decode 不 verify
某项目的网关中间件为了“性能”,只对 Token 做 base64 解码,取出 sub 就写进请求上下文,签名校验放在一个“稍后异步执行”的钩子里。上线后,攻击者把 payload 里的 sub 改成目标账号,重新编码,签名原样保留,网关照样放行。更糟的是,中间件没有检查 alg,攻击者直接把 header 改成 none 并清空签名,也能通过。
这个漏洞持续了两周才被发现,因为日志里只记录了解码后的账号,没有记录验签结果。复盘时的根因不是“JWT 不安全”,而是三条实现错误叠加:
- 把解码当成了验证。解码只是解析,验证必须验签。
- 没有固定算法白名单,header 里的
alg被当成了可信输入。 - 认证结果没有失败即拒绝,而是“先放行、后检查”。
修复方式是:在中间件入口统一调用 Verify,任何错误都返回 401;算法由配置固定,header 只做比对;验签通过后再做账号封禁、区服、角色归属等业务校验;所有拒绝都记录 jti 与错误类型,便于审计。若使用公钥算法,还要检查 kid 是否存在、公钥是否在有效期内,不能静默回退。
另一个常见变体是把签名密钥写进客户端。无论 HS256 还是 RS256,密钥都不能下发到客户端;客户端只持有签发的 Token,不参与签发或验签。把密钥打进安装包,等同于把签发能力交给所有人。
七、测试覆盖
配套测试覆盖了八类输入:正常签发与验签、篡改 payload、过期、alg: none、算法混淆(header 写 RS256)、错误受众、错误签发方、nbf 在偏移内与偏移外。每个用例都断言具体错误类型,而不是只断言“返回 error”,这样以后重构时能发现错误分类被悄悄改掉。
其中“篡改 payload”用例会解开 payload、修改 sub、重新编码,再原样拼接签名,确保验签一定失败;alg: none 用例构造无签名 Token,确保在解码 payload 之前就被算法检查拦下。测试使用固定时钟,不依赖 sleep,因此可以在 CI 里稳定重复运行。
如果项目使用第三方库,也建议保留一组“绕过尝试”的用例,因为库的默认行为会随版本变化。升级依赖时,先跑这组用例,再跑正常链路;比起阅读变更日志,直接验证“无签名、错算法、错受众、过期”四类输入更可靠。
小结
JWT 解决的是“跨服务、无共享会话时如何携带可验证身份”,不是“如何管理登录态”。一次完整的游戏登录要经过凭证校验、选区选角、签发调用凭证、网关绑定连接、下游授权检查五段;JWT 只覆盖其中“携带身份”的一段。
实现上记住三条底线:算法由服务端固定并拒绝 none,签名必须用常量时间比较,声明必须在验签之后逐项校验。payload 只放稳定且非敏感的事实,有效期配合刷新机制,jti 留给吊销与审计。
部署时还有一个细节:验签密钥或公钥应在进程启动时加载,并支持不停机轮换;轮换期间新旧两把密钥同时可用,按 kid 选择,等旧密钥签发的 Token 全部过期后再下线。把密钥读取放进每次请求的代码路径,会在流量高峰放大文件或配置中心的压力,也容易在配置中心抖动时把全站认证打挂。
常见错误与边界
- 只 decode 不 verify:把 base64 解码当成认证,攻击者可以随意改
sub。解码永远不等于验签。 - 接受
alg: none或按 header 选算法:无签名 Token 与算法混淆都由此而来。算法必须来自服务端配置。 - 不校验
iss与aud:同密钥的其他系统 Token、给其他服务签发的 Token 都能混进来。 - 把密钥写进客户端:客户端只应持有 Token,不应持有签发或验签密钥。
- 用 JWT 当会话存储:把角色等级、背包、权限列表塞进 payload,数据一变就与 Token 不一致。JWT 只放稳定事实。
- 忽略
kid轮换:换密钥后旧 Token 全部失效,或者找不到kid时回退到默认密钥,前者造成全服掉线,后者造成验签绕过。 - 有效期拍脑袋:access token 设成几天,等于取消短寿命带来的止损能力;设成几秒,则刷新风暴拖垮登录服。
边界方面,HS256 适合单一信任域,不适合把验签能力开放给外部合作方;RS256/EdDSA 需要额外的密钥发布、缓存和轮换机制。JWT 的 payload 会随请求反复传输,不适合放大对象;需要撤销能力时,必须配合黑名单或 token_version,这部分在下一篇展开。最后,JWT 不能替代 TLS:没有传输层加密,Token 在链路上就是明文。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。