JWT、Cookie 与 Session

JWT、Cookie 与 Session #

1. 先搞清两个词 #

概念 英文 回答的问题 例子
认证 Authentication (AuthN) 你是谁? 输账号密码,证明你是张三
授权 Authorization (AuthZ) 你能干什么? 张三能看本单位案件,不能删用户

本文聚焦认证:登录成功后,怎么让后续每个请求都被认出来。

2. 为什么需要鉴权机制:HTTP 是无记忆的 #

HTTP 协议本身无状态——服务端处理完一个请求就「失忆」,下一个请求来时并不知道它和上一个是同一个人发的。

sequenceDiagram
    participant B as 浏览器
    participant S as 服务端
    B->>S: POST /login(账号密码)✅ 登录成功
    B->>S: GET /profile  ← 服务端:你是谁?🤔

于是需要一张「凭证」:登录时服务端发给你,之后每次请求都带上它,服务端凭它认人。怎么设计这张凭证,就分出了有状态与无状态两条路线。

3. 核心分歧:状态存哪? #

graph LR
    subgraph 有状态 Session
        A1[凭证只是一个ID] --> A2[(真正的身份数据<br/>存在服务端)]
    end
    subgraph 无状态 JWT
        B1[凭证自带身份数据<br/>+ 签名防伪] --> B2[服务端不存任何东西<br/>验签即可信]
    end
有状态(Session) 无状态(JWT)
身份数据存哪 服务端(内存/Redis/DB) 凭证本身(客户端持有)
服务端校验方式 拿 ID 去查存储 本地验签,无需查存储
能否主动吊销 ✅ 容易(删掉记录即可) ❌ 难(签出去就有效,详见 §9)
水平扩展 多实例需共享 Session 存储 ✅ 任意实例验签即可,天然友好
每次请求开销 一次存储查询(IO) 一次验签(纯 CPU)
凭证体积 小(一个 ID) 较大(携带全部字段,约 1~2KB)

一句话:有状态把数据留在服务端、凭证只是钥匙;无状态把数据放进凭证、服务端只管验真伪。


4. 登录的第一关:密码怎么安全存储 #

凭证机制解决「之后怎么认人」,但登录那一刻先要核对密码。密码怎么存,是整个鉴权体系的地基——库一旦泄露,存得好坏天差地别。

4.1 三条铁律 #

  1. 绝不明文存储。数据库泄露 = 所有账号密码直接外泄,且用户常跨站复用密码。
  2. 不要用可逆加密存密码。加密能解密,密钥一旦泄露等同明文;密码核对根本不需要「还原」原文,只需「比对」。
  3. 要用单向哈希(Hash),且必须是「慢哈希」+「加盐」

加密 vs 哈希:加密可逆(拿密钥能还原明文),用于「需要读回原文」的场景;哈希不可逆(只能算过去、推不回来),用于「只需验证是否一致」的场景。密码存储用哈希,不用加密。

4.2 为什么不能用 MD5 / SHA-256 直接存 #

MD5、SHA-1、SHA-256 是快速哈希,为「校验文件完整性」而生,算得飞快——这恰恰是存密码的灾难:

  • GPU 每秒能算几十亿次 SHA-256,弱口令几秒被暴力穷举。
  • 无盐时相同密码哈希值相同,攻击者可用彩虹表(预先算好的「明文→哈希」大字典)一键反查。

密码哈希需要的是故意算得慢、且可调难度的算法。

4.3 盐(Salt)是什么 #

盐 = 每个用户一个的随机字符串,拼到密码后再哈希,并和哈希结果一起存库(盐不需要保密)。

无盐:  hash("123456")            → 两个都用 123456 的用户,哈希完全相同 ❌
加盐:  hash("123456" + "x7Fq2a") → 用户A
        hash("123456" + "Kp9wLz") → 用户B   两人哈希完全不同 ✅

盐的作用:

  • 废掉彩虹表:预算字典针对的是「裸密码」,加了随机盐就对不上了,攻击者只能对每个用户单独爆破。
  • 隐藏撞密码:两个人设了同样的密码,加盐后哈希也不同,库里看不出来。

胡椒(Pepper):可选的「全局盐」,存在代码/配置/密钥管理服务里而不入库。库被脱但代码没泄时,多一层保护。盐入库、胡椒不入库,两者互补。

4.4 算法对比 #

算法 类别 内置盐 可调难度 抗 GPU/ASIC 用于密码存储
MD5 / SHA-1 快速哈希 ❌ 绝不可
SHA-256 快速哈希 ❌ 绝不可
PBKDF2 慢哈希(多轮迭代) ✅ 迭代次数 一般 ⚠️ 可用,合规场景常见
bcrypt 慢哈希 ✅ cost 因子 较好 ✅ 成熟稳妥,广泛使用
scrypt 慢哈希(耗内存) ✅ CPU+内存
Argon2id 慢哈希(耗内存) ✅ 时间+内存+并行 最好 当前首选(OWASP 推荐)

选型建议:新项目首选 Argon2id;用 bcrypt 也完全稳妥(成熟、库多、Identity Service 即用 bcrypt)。三者都自带盐、可调成本,绝不要回退到裸 MD5/SHA。

4.5 Go 示例(bcrypt) #

package main

import "golang.org/x/crypto/bcrypt"

// 注册/改密:把明文密码哈希后入库(绝不存明文)
func HashPassword(plain string) (string, error) {
	// cost=12:成本因子,越大越慢越安全;bcrypt 会自动生成随机盐并拼进结果
	hashed, err := bcrypt.GenerateFromPassword([]byte(plain), 12)
	return string(hashed), err
}

// 登录:用库里的哈希校验用户输入,无需、也无法「解密」出原密码
func CheckPassword(hashedFromDB, plainInput string) bool {
	err := bcrypt.CompareHashAndPassword([]byte(hashedFromDB), []byte(plainInput))
	return err == nil // nil 表示匹配
}

关键点:bcrypt 自动生成随机盐、把「盐 + cost + 哈希」打包进一个字符串,校验时再从中取出盐——所以你只需存这一个字符串,不必单独管理盐字段。

4.6 bcrypt 哈希串格式详解 #

HashPassword 存进库的就是这样一串(60 字符):

$2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
└┬┘└┬┘ └───────────────────┬──────────────────────────────┘
 │  │   └ 22 字符盐 + 31 字符哈希(base64 编码,紧挨在一起)
 │  └ cost 因子 = 12(2^12 轮)
 └ 算法版本($2b$ = bcrypt)

正因盐就写在串里,换台机器、隔很久都能正确校验。Argon2 的哈希串同理(形如 $argon2id$v=19$m=65536,t=3,p=4$<盐>$<哈希>,把内存/时间/并行参数也编了进去)。

Identity Service 的 user 表用 password_hash varchar(256) 存的正是这种自带盐的哈希串;密码明文与哈希都绝不写入日志


5. Session:有状态方案 #

原理 #

  1. 登录成功,服务端生成一个随机、不可猜的 session_id,把用户身份存进服务端存储:session_id → {user_id, ...}
  2. session_id 通过 Cookie 发回浏览器。
  3. 之后每个请求,浏览器自动带上 Cookie;服务端用 session_id 查出身份。
  4. 登出 / 踢人:服务端直接删掉那条记录,凭证立即失效。
sequenceDiagram
    participant B as 浏览器
    participant S as 服务端
    participant R as Session 存储(Redis)
    B->>S: POST /login
    S->>R: 存 sid_abc → {user_id:10086}
    S-->>B: Set-Cookie: sid=sid_abc
    Note over B: 之后每个请求自动带 Cookie
    B->>S: GET /profile (Cookie: sid=sid_abc)
    S->>R: 查 sid_abc
    R-->>S: {user_id:10086}
    S-->>B: 张三的资料

Go 示例 #

package main

import (
	"crypto/rand"
	"encoding/hex"
	"net/http"
	"sync"
	"time"
)

// 服务端 Session 存储(示例用内存 map;生产用 Redis)
type Session struct {
	UserID    int64
	ExpiresAt time.Time
}

var (
	store = map[string]Session{}
	mu    sync.RWMutex
)

func newSessionID() string {
	b := make([]byte, 32)
	rand.Read(b) // 必须用加密安全随机数,绝不能用可预测值
	return hex.EncodeToString(b)
}

// 登录:建会话 + 下发 Cookie
func login(w http.ResponseWriter, userID int64) {
	sid := newSessionID()
	mu.Lock()
	store[sid] = Session{UserID: userID, ExpiresAt: time.Now().Add(2 * time.Hour)}
	mu.Unlock()

	http.SetCookie(w, &http.Cookie{
		Name:     "sid",
		Value:    sid,
		Path:     "/",
		HttpOnly: true,                    // JS 读不到,防 XSS 窃取
		Secure:   true,                    // 仅 HTTPS 传输
		SameSite: http.SameSiteStrictMode, // 防 CSRF
		MaxAge:   7200,
	})
}

// 中间件:从 Cookie 取回身份
func authMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		c, err := r.Cookie("sid")
		if err != nil {
			http.Error(w, "未登录", http.StatusUnauthorized)
			return
		}
		mu.RLock()
		s, ok := store[c.Value]
		mu.RUnlock()
		if !ok || time.Now().After(s.ExpiresAt) {
			http.Error(w, "会话失效", http.StatusUnauthorized)
			return
		}
		// 这里拿到 s.UserID,可放入 context 传给后续 handler
		next.ServeHTTP(w, r)
	})
}

// 登出:删掉服务端记录,凭证立即作废
func logout(w http.ResponseWriter, r *http.Request) {
	if c, err := r.Cookie("sid"); err == nil {
		mu.Lock()
		delete(store, c.Value)
		mu.Unlock()
	}
}

要点session_id 本身没有任何含义,它只是一把钥匙;删掉服务端记录,钥匙就开不了门——这是有状态方案「能主动吊销」的根本原因。

Session 一致性问题:多实例下怎么保证「换台机器也认得」 #

上面的示例把 Session 存在单机内存里,单实例没问题;可一旦服务多实例 + 负载均衡部署,麻烦就来了:用户在实例 A 登录(session 存在 A 的内存),下一个请求被负载均衡分到了实例 B——B 内存里根本没有这条 session,于是「明明登录了却被当成未登录」。这就是 Session 的一致性问题:同一用户的会话状态,必须让所有实例都看到同一份。

graph TB
    U[用户已在 A 登录] --> LB{负载均衡}
    LB -->|下次请求轮询到 B| B[实例 B 内存无此 session ❌]
    LB -->|轮询到 A| A[实例 A 有 session ✅]

三种解法,逐级更优:

方案 做法 优点 缺点
粘性会话
(Sticky Session)
负载均衡按来源(IP/Cookie)把同一用户固定打到同一实例 改动小、不引依赖 实例宕机→该实例 session 全丢;扩缩容/重启后分配失衡;负载不均
Session 复制
(Replication)
实例之间互相同步各自的 session 任意实例都能处理 N 个实例两两复制,网络与内存开销随实例数膨胀,不易扩展
集中式存储
(Redis/DB)
所有实例把 session 读写到同一个外部存储(如 Redis) 实例彻底无状态、可随意扩缩容、宕机不丢 session 引入外部依赖与一次网络往返;Redis 自身要做高可用

主流选择是集中式存储(Redis):把前面 Go 示例里的内存 map 换成 Redis 读写即可,所有实例共享一份会话,一致性天然成立。采用集中式存储后,仍要留意两点一致性细节:

  • 并发写覆盖:同一 session 被多个请求并发修改(如同时刷新过期时间)可能互相覆盖;多数场景 session 只读、影响小,必要时用 Redis 原子操作或加锁。
  • 主从复制延迟:Redis 主从架构下,刚写主库的 session 立刻从从库读可能读不到(短暂不一致);对登录态这类关键数据,读写建议走主库或开启读主。

反过来看,无状态 JWT 根本没有这个问题——它不在服务端存任何会话,任意实例靠验签即可独立认人,天生适配多实例水平扩展。代价是「吊销难」(见 §9)。要不要承受 Session 的一致性成本,正是有状态 / 无状态选型的关键权衡之一。


6. Cookie:凭证的「运输工具」 #

Cookie 常和 Session 混为一谈,其实它只是浏览器自动存储与回传一小段数据的机制,是凭证的载体,本身不绑定任何方案——Session ID 能放 Cookie,JWT 也能放 Cookie。

服务端通过响应头 Set-Cookie 下发,浏览器之后对同源请求自动带上 Cookie 请求头,无需前端代码干预。

6.1 格式详解:下发与回传是两个头 #

① 服务端下发——Set-Cookie 响应头(一个 Cookie 一行,名=值 后跟分号分隔的属性):

HTTP/1.1 200 OK
Set-Cookie: sid=abc123xyz; Path=/; Domain=example.com; Max-Age=7200; HttpOnly; Secure; SameSite=Strict
Set-Cookie: theme=dark; Path=/; Max-Age=31536000

逐字段:

Set-Cookie: sid = abc123xyz ; Path=/ ; Domain=example.com ; Max-Age=7200 ; HttpOnly ; Secure ; SameSite=Strict
            └┬┘  └───┬───┘    └──┬──┘  └──────┬──────┘   └────┬────┘  └───┬───┘ └──┬─┘  └─────┬──────┘
            名     值(凭证)   生效路径    生效域名        存活秒数     禁JS读   仅HTTPS   防跨站携带

② 浏览器回传——Cookie 请求头(只带 名=值,多个用 ; 拼接,不回传任何属性):

GET /profile HTTP/1.1
Host: example.com
Cookie: sid=abc123xyz; theme=dark

注意:HttpOnlySecurePath 等属性只是给浏览器看的「投递规则」,回传时一律不带——服务端只收得到 名=值

6.2 关键安全属性(务必设对) #

属性 作用
HttpOnly 禁止 JS(document.cookie)读取,防 XSS 窃取凭证
Secure 只在 HTTPS 下发送,防中间人窃听
SameSite=Strict/Lax 限制跨站请求携带 Cookie,防 CSRF
Max-Age / Expires 有效期;不设则为关闭浏览器即失效的会话 Cookie
Path / Domain 限定 Cookie 生效的路径与域名范围

Cookie vs 其它存法:把令牌放 Cookie,胜在自动携带 + 可设 HttpOnly 防 XSS,但需防 CSRF;放 localStorage + 手动加 Authorization 头则相反——不怕 CSRF,但 JS 可读、易受 XSS 影响。

能,但要换一种方式携带 session_id

关键在于:Session 的本质是「服务端存状态 + 客户端每次带回那个 ID」。Cookie 只是携带 ID 最省事的工具,不是唯一工具。禁用 Cookie 只是断了默认的运输通道,只要还能把 session_id 送回服务端,Session 机制照常工作。

替代携带方式:

方式 做法 说明
自定义请求头 前端把 sid 放进 Authorization 或自定义头手动发送 SPA / App 最常用,干净可控
localStorage + 头 sid 存 localStorage,每次请求 JS 读出来塞进头 同上;注意 XSS 风险
URL 重写 把 sid 拼进 URL,如 /profile;jsessionid=abc123 传统 Java Web 的退路,但 sid 易泄露在浏览器历史、日志、Referer不推荐
隐藏表单字段 每个表单塞一个 <input type=hidden> 带 sid 老式多页应用,仅对表单提交有效
graph LR
    SID["session_id"] -->|默认| CK[Cookie 自动带]
    SID -->|Cookie 被禁| H[自定义 Header 手动带]
    SID -->|Cookie 被禁| U[URL 重写, 不推荐]
    CK --> SRV[(服务端按 sid 查身份)]
    H --> SRV
    U --> SRV

结论与边界

  • 浏览器禁用 Cookie + 没做任何替代 → 传统依赖 Cookie 的 Session 直接断(最常见的「禁用 Cookie 就登录不了」就是这种)。
  • 改用 Header / localStorage 携带 sid → Session 照常可用,这也是前后端分离项目的普遍做法。
  • JWT 同理:JWT 若放在 Cookie 里,禁用 Cookie 一样受影响;但 JWT 通常放 Authorization: Bearer 头(不依赖 Cookie),所以天然不受「禁用 Cookie」影响——这也是无状态方案在 SPA / App 场景更受欢迎的原因之一。

7. JWT:无状态方案 #

JWT(JSON Web Token)是一段自带身份信息、并用签名防篡改的字符串。服务端不需要存任何东西,靠验签就能确认它是自己签发且未被改动的。

7.1 结构:三段,用 . 连接 #

eyJhbGciOiJSUzI1NiJ9 . eyJzdWIiOiIxMDA4NiIsImV4cCI6MTcuLi59 . SflKxw...(签名)
└──── Header ────┘   └──────── Payload ────────┘   └── Signature ──┘
   算法/类型            身份数据(claims)              防伪签名
  • Header:声明签名算法(如 RS256)。
  • Payload:身份数据(claims),如 sub(用户ID)、exp(过期时间)、自定义的权限/单位等。⚠️ 仅 Base64 编码、未加密,任何人都能解开看,故别放密码等敏感信息
  • Signature:用密钥对前两段算出的签名。改动任何一个字符,验签都会失败——这是防伪的关键。

7.2 格式详解:拿真实 token 拆开看 #

一个完整 JWT 就是 base64url(Header) + "." + base64url(Payload) + "." + base64url(Signature)。注意是 base64url 编码,不是加密——任何人都能解码出 Header 和 Payload 的明文 JSON(可在 jwt.io 在线解)。

原始 token(为可读已折行,实际是一整行无换行):

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0xIn0
.
eyJpc3MiOiJpZGVudGl0eS1zZXJ2aWNlIiwic3ViIjoiMTAwODYiLCJleHAiOjE3MTg5MDAwMDAsImp0aSI6ImE3ZjMiLCJ1c2VybmFtZSI6InpoYW5nX3NhbiJ9
.
NHVQ7r8...(RSA 私钥对前两段算出的签名,约 350 字节)

第一段 Header,base64url 解码后是:

{
  "alg": "RS256",   // 签名算法
  "typ": "JWT",      // 类型
  "kid": "key-1"     // 密钥 ID,用于多公钥时定位该用哪把验签(密钥轮换)
}

第二段 Payload,base64url 解码后是:

{
  "iss": "identity-service",   // 签发方
  "sub": "10086",              // 主体:用户 ID
  "exp": 1718900000,            // 过期时间(Unix 秒)
  "jti": "a7f3",                // 令牌唯一 ID(吊销/审计用)
  "username": "zhang_san",
  "permission_codes": ["system:user:list"]  // 自定义业务字段
}

第三段 Signature:对 base64url(Header).base64url(Payload) 用密钥按 alg 算出的签名,不可解码成 JSON,只用于验真伪。

再次强调:Payload 是可读的(任何人 base64 解码即见明文)。它防的是「篡改」(改了签名就失效),不是「偷看」。敏感数据别往里放。

7.3 签名算法:HS256 vs RS256 #

HS256(对称) RS256(非对称)
密钥 签发、验签共用同一把密钥 私钥签发 / 公钥验签
适用 单服务自签自验 一方签发、多方验签(公钥可公开)
风险 验签方都要持有密钥,泄露即灾难 公钥泄露无害,运维门槛低

本平台用 RS256:Identity Service 用私钥签发,网关等用公钥验签——公钥泄露无影响,且便于密钥轮换(靠 Header 里的 kid 区分新旧公钥)。

7.4 工作流程 #

sequenceDiagram
    participant B as 浏览器
    participant S as 服务端
    B->>S: POST /login
    Note over S: 用私钥签发 JWT<br/>(不存任何东西)
    S-->>B: { access_token: "eyJ..." }
    Note over B: 保存 token
    B->>S: GET /profile<br/>Authorization: Bearer eyJ...
    Note over S: 本地验签 + 查 exp<br/>无需查数据库 ✅
    S-->>B: 张三的资料

7.5 Go 示例(github.com/golang-jwt/jwt/v5#

package main

import (
	"crypto/rsa"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

// 自定义 claims:标准字段 + 业务字段
type Claims struct {
	Username        string   `json:"username"`
	PermissionCodes []string `json:"permission_codes"`
	jwt.RegisteredClaims // 内含 sub / exp / iat / iss / jti 等标准字段
}

// 签发:服务端用私钥签,签完即可丢弃,不留存
func IssueToken(priv *rsa.PrivateKey, userID string, perms []string) (string, error) {
	claims := Claims{
		Username:        "zhang_san",
		PermissionCodes: perms,
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   userID,
			Issuer:    "identity-service",
			ID:        newUUID(),                                          // jti:令牌唯一ID,吊销/审计用
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)), // 短有效期,缩小被盗窗口
		},
	}
	return jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(priv)
}

// 验签:任意服务持公钥即可校验,无需访问数据库
func ParseToken(pub *rsa.PublicKey, tokenStr string) (*Claims, error) {
	token, err := jwt.ParseWithClaims(tokenStr, &Claims{},
		func(t *jwt.Token) (any, error) {
			// 强制校验算法,防「alg=none」等降级攻击
			if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
				return nil, jwt.ErrSignatureInvalid
			}
			return pub, nil
		},
	)
	if err != nil || !token.Valid { // jwt 库会自动校验 exp 等
		return nil, err
	}
	return token.Claims.(*Claims), nil
}

对比 Session 看本质:验签全程没有任何存储查询——身份就写在 token 里,签名保证它没被篡改。这就是「无状态」省掉一次 IO、且天然支持多实例的来源。


8. 一张图看懂两条路线 #

graph TB
    L[用户登录成功] --> Q{凭证里放什么?}
    Q -->|放一个随机ID| SES[Session 有状态]
    Q -->|放身份数据+签名| JWT[JWT 无状态]
    SES --> SES2[服务端存储记录映射<br/>每次请求查存储]
    JWT --> JWT2[服务端不存<br/>每次请求验签]
    SES2 --> SES3[✅吊销容易<br/>❌多实例要共享存储]
    JWT2 --> JWT3[✅扩展友好/无IO<br/>❌吊销难/凭证较大]

9. 无状态的代价:令牌吊销难题 #

Session 删一条记录就能踢人,JWT 没有「记录」可删——只要没过期,签出去的令牌就一直有效。登出、改密、停用、踢下线都会遇到这个问题。常见缓解手段:

  1. 短有效期(access token):让令牌很快自然过期,缩小被盗/滞后窗口(代价:刷新更频繁)。
  2. 黑名单(jti blacklist):给每个令牌一个唯一 jti,要吊销时把 jti 记入黑名单;验签后再查一下黑名单,命中即拒。这相当于给无状态方案打了个「有状态补丁」。
  3. 双令牌(access + refresh)
graph LR
    A["access token<br/>短期(如2h)<br/>带全部身份, 频繁使用"]
    R["refresh token<br/>长期(如7天)<br/>只用来换新 access"]
    A -.过期.-> R
    R -->|/refresh 重查最新身份| A

短期 access 降低泄露风险;长期 refresh 只在续期时用一次,刷新时服务端可重查最新权限重新签发,缓解「权限改了但旧令牌仍是旧数据」的问题。


10. 本平台实践:Identity Service 的选型 #

Identity Service 选了无状态 JWT(RS256)+ jti 黑名单,正是上面各方案的组合落地(完整设计见 services/identity-service/identity-service.md §2):

决策 选择 为什么
路线 无状态 JWT 不依赖 Redis、网关可水平扩展、验签纯本地无 IO
签名 RS256 非对称 Identity 私钥签发,网关公钥验签;公钥经 JWKS 端点分发,支持密钥轮换
令牌 access(2h) + refresh(7天) 短 access 缩小吊销延迟;refresh 滚动刷新(换新并拉黑旧的)防滥用
吊销 jti 黑名单 登出/改密/踢人时把 jti 入黑名单,网关验签后查询(短 TTL 缓存),近线生效
密码 bcrypt 哈希 user.password_hash 存自带盐的 bcrypt 串;明文与哈希均不入日志
校验位置 全在网关 网关验签 + 查黑名单后,把身份信息解析成 X-User-Id 等头注入下游,兄弟微服务只读头、不感知 JWT
sequenceDiagram
    participant B as 浏览器
    participant G as 网关
    participant I as Identity Service
    participant Biz as 兄弟微服务
    B->>G: 请求 + JWT
    Note over G: ①公钥验签(本地)<br/>②查 jti 黑名单(gRPC,带缓存)
    G->>I: IsJtiRevoked(jti)?
    I-->>G: 未吊销
    Note over G: ③解出身份, 注入 X-User-Id 等头
    G->>Biz: 转发(已带可信身份头)
    Biz-->>B: 业务结果(不感知 JWT)

这套组合的取舍很典型:用无状态换来了扩展性与低运维成本,再用「短有效期 + jti 黑名单 + 滚动刷新」把无状态最大的短板(吊销难)补到业务可接受的程度。


11. 小结 #

  • HTTP 无记忆,登录后靠凭证认人;凭证里放什么,决定了有状态还是无状态。
  • 密码存储是地基:单向哈希 + 每用户随机盐 + 慢哈希算法(Argon2id / bcrypt),绝不明文、绝不裸 MD5/SHA。
  • Session(有状态):凭证是把钥匙,数据在服务端 → 吊销容易,但多实例有一致性问题(需 Redis 集中存储等方式让各实例共享会话)。
  • JWT(无状态):凭证自带数据 + 签名 → 扩展友好、无 IO,但吊销难、体积大、Payload 可被解码看(不能放敏感信息)。
  • Cookie 只是凭证的运输工具,和方案无关;用它就务必设好 HttpOnly / Secure / SameSite。禁用 Cookie 后,只要改用 Header 等方式携带 ID,Session 仍可用。
  • 没有银弹:内网、多实例、低运维优先 → 选 JWT 并用黑名单/短有效期补吊销,正如本平台 Identity Service。