如何輪換 JWT 簽署金鑰而無需登出所有使用者
若草率地輪替 JWT 簽署金鑰,會使所有有效的權杖失效,並登出所有使用者。以下是可避免此問題的重疊窗口與 `kid` 設計。
JWT 之所以值得信賴,是因為它由只有您的伺服器持有的金鑰簽署。如果該金鑰洩漏,任何人都可以偽造有效的權杖,因此您必須定期更換金鑰。最天真的方法,是用新金鑰替換舊金鑰,但這會使所有仍在傳輸中的權杖失效,並且每個已登入的使用者在下次發出請求時都會被登出。這篇文章展示了一種輪換金鑰而不會強制登出的設計:同時保持多個金鑰有效,用最新的金鑰簽署,並對所有金鑰進行驗證,直到舊權杖自行過期。
核心概念很簡單。簽署和驗證不必使用同一個金鑰。您只用一個金鑰(即當前的金鑰)進行簽署,但會對一組金鑰進行驗證。只要權杖是由該組中任何一個仍然有效的金鑰所簽署,它就保持有效。因此,輪換就變成將一個新金鑰加入該組,將簽署者移至該新金鑰,並僅在確定沒有任何由舊金鑰簽署的有效權杖後,才移除舊金鑰。
為何粗糙的金鑰替換會讓所有人都登出
假設你發行的權杖有 24 小時的存活時間。在任何時刻,你都有一群滾動的有效權杖,有些是幾秒前發行的,有些是將近 24 小時前發行的。所有這些權杖都是用發行當時的現行金鑰所簽署的。
現在你替換了金鑰。下一個請求帶著用舊金鑰簽署的權杖抵達,你的伺服器試圖用新金鑰來驗證它,簽章不符,驗證失敗。使用者看起來就像被登出了。這種情況會發生在金鑰替換前發行的每一個權杖上,而這正是你全部的活躍使用者群。你並非有意撤銷任何人的權杖。你只是改變了那個讓他們權杖得以驗證的唯一密鑰,而且沒有回頭路。
權杖的存活時間是關鍵所在。在你替換金鑰後,一個用舊金鑰簽署的權杖在其完整的存活時間內都可能保持有效。因此,舊金鑰必須在至少那麼長的時間內保持可用於驗證。這單一的觀察結果就是整個設計的核心。
解決方案:用一把金鑰簽署,用多把金鑰驗證
將金鑰分成兩種角色。
- 簽署使用一把金鑰,即當前的金鑰。每個新的權杖都用它來簽署。
- 驗證使用一組金鑰,即當前的金鑰,加上任何其權杖可能仍然有效的舊金鑰。
當收到一個權杖時,你不能假設它是由當前的金鑰簽署的。你得找出是哪把金鑰簽署了它,並用那把金鑰進行驗證。如果簽署金鑰仍在你的金鑰組中,那麼該權杖就是有效的。如果它是由一把你已經淘汰的金鑰簽署的,驗證就會失敗,這正是在金鑰確實已失效時你所期望的結果。
為了讓第一步的成本低廉,每把金鑰都會得到一個稱為 kid(金鑰 ID)的簡短識別碼。當你簽署時,你會將 kid 標記在權杖的標頭中。當你驗證時,你會從標頭中讀取 kid,並查找那把確切的金鑰。沒有 kid 意味著你必須逐一嘗試你金鑰組中的每把金鑰,直到有一把成功為止,這不僅速度較慢,還會洩漏一點時間資訊。有了 kid,驗證金鑰的查找就只是一次 map 讀取操作。
以下是一個帶有 kid 的 JWT 標頭範例。該標頭只是經過 base64url 編碼的 JSON,因此在進行任何簽章檢查之前都是可讀的:
{
"alg": "HS256",
"kid": "2026-07-01"
}
kid 可以是每個金鑰的任何獨特且穩定的值。日期、隨機字串或計數器都可以。唯一的規則是,它不得與另一個有效的金鑰衝突,而且它不得帶有任何秘密,因為任何人都可以讀取它。
輪換時間軸,一步一步來
輪換是一連串的狀態,而非單次的翻轉。每個狀態都是安全的,這意味著您可以在計時器上自動化轉換,而無需協調同步重新啟動。
| 階段 | 簽署金鑰 | 驗證集 | 發生了什麼事 |
|---|---|---|---|
| 穩定 | K1 | {K1} | 只存在一把金鑰,所有權杖都由 K1 簽署 |
| 引入 | K1 | {K1, K2} | K2 已加入集合中,但尚未使用它進行簽署 |
| 切換 | K2 | {K1, K2} | 新的權杖使用 K2,舊的 K1 權杖仍然可以驗證 |
| 重疊 | K2 | {K1, K2} | 等待最後一個 K1 權杖到期 |
| 淘汰 | K2 | {K2} | K1 已移除,其權杖無論如何都已消失 |
唯一重要的持續時間是重疊期。從您停止使用 K1 簽署的那一刻算起,K1 必須在驗證集中保留至少與權杖最長生命週期一樣長的時間。如果權杖的生命週期為 24 小時,而您在中午切換到 K2,那麼最新可能的 K1 權杖是在中午前鑄造的,並在隔天中午前失效。若提早移除 K1,您將會使合法發行的權杖失效。若在該時間窗之後移除它,則沒有任何有效的權杖依賴它,因此對使用者來說,淘汰是一個無操作 (no-op)。
請注意,驗證集在任何時候都不會縮小到低於有效權杖所需的大小。這就是讓輪換對使用者端保持不可見的原因。他們永遠不會看到因輪換而導致的簽章失敗,只會看到因權杖本已過期而導致的失敗。
一個精簡的 Go 實作
這是一個鑰匙圈,它持有多個金鑰,用當前的金鑰進行簽署,並透過 kid 進行驗證。它為了簡潔而使用 HMAC (HS256),但相同的架構也適用於非對稱金鑰 (RS256, ES256),其中驗證集持有公鑰,而只有簽署者持有私鑰。
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type key struct {
id string
secret []byte
notAfter time.Time // stop using for verification after this
}
type Keyring struct {
current string // kid of the signing key
keys map[string]*key // kid -> key, the verification set
}
func (r *Keyring) Sign(claims jwt.MapClaims) (string, error) {
k := r.keys[r.current]
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tok.Header["kid"] = k.id
return tok.SignedString(k.secret)
}
func (r *Keyring) Verify(raw string) (jwt.MapClaims, error) {
claims := jwt.MapClaims{}
_, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
kid, ok := t.Header["kid"].(string)
if !ok {
return nil, errors.New("token has no kid")
}
k, ok := r.keys[kid]
if !ok {
return nil, errors.New("unknown or retired kid")
}
if time.Now().After(k.notAfter) {
return nil, errors.New("key past its verification window")
}
return k.secret, nil
}, jwt.WithValidMethods([]string{"HS256"}))
return claims, err
}
有兩個細節至關重要。jwt.WithValidMethods 選項會鎖定可接受的演算法,這可以杜絕典型的 alg 混淆攻擊,也就是呼叫者將 alg 設為 none 或將 RS256 降級為 HS256。此外,kid 查詢在遇到未知或已淘汰的金鑰時會回傳錯誤,而不是退回使用預設值,因此,由您刻意移除的金鑰所簽署的權杖會安全地失敗。
輪替本身是在相同結構上的一個小變動。一個排程作業會新增下一個金鑰、切換簽署者,並修剪任何超過其時間範圍的內容:
func (r *Keyring) Rotate(newKid string, secret []byte, tokenTTL time.Duration) {
// New key can verify tokens minted from now until now + tokenTTL,
// plus a margin so the last-minted token is safely covered.
r.keys[newKid] = &key{
id: newKid,
secret: secret,
notAfter: time.Now().Add(tokenTTL * 2),
}
r.current = newKid // sign with the new key from here on
// Drop keys whose verification window has passed.
for kid, k := range r.keys {
if time.Now().After(k.notAfter) {
delete(r.keys, kid)
}
}
}
自動化跨多個實例的輪替
一個程序在記憶體中持有一個金鑰環是容易的。實際的部署在負載平衡器後有多個伺服器實例,而它們都必須就哪些金鑰是有效的達成共識。如果實例 A 輪替了一個新金鑰,而實例 B 從未聽說過它,那麼由 A 簽署的權杖在 B 上會失敗,而你又會回到隨機登出的情況。
解決方法是將金鑰環保存在共享儲存中,而不是每個程序的記憶體中。Redis 或資料庫的一列都可以。一個排程工作(通常是每週一次)會產生一個新金鑰,將其連同其 kid 和其時間窗寫入共享儲存,並移動當前指標。每個實例都從儲存中讀取金鑰集,可以透過帶有 TTL 的短期快取,或透過 pub/sub 通知,因此在幾秒鐘內,每個實例都會看到相同的金鑰集。
一個實用的設定是隨時保持兩個金鑰有效:當前的和下一個,並讓它們的時間窗重疊。每週執行的工作會將「下一個」提升為「當前的」,並鑄造一個全新的「下一個」。因為時間窗的重疊時間超過一個權杖的生命週期,所以絕不會出現實例會拒絕有效權杖的空窗期。如果你已經為會話或速率限制運行了 Redis,這只會增加一個小金鑰和一個類似 cron 的工作,不會有更重的負擔。
對於非對稱金鑰,有一個廣為人知的標準來公開驗證集:JWKS,這是一個位於已知 URL 的 JSON 文件,按 kid 列出您的公鑰。資源伺服器會擷取它、快取它,並將傳入權杖中的 kid 與公鑰進行匹配。您權杖標頭中的 kid 與索引 JWKS 條目的 kid 相同,這就是為什麼即使在單一服務的設定中也值得添加此識別碼的原因。它是一個接縫,讓其他服務可以在不持有您簽署金鑰的情況下驗證您的權杖。
您所接受的權衡取捨
此設計換來了零停機時間的輪替,但這並非沒有代價。
您現在管理的是一組金鑰而非單一金鑰:產生它們、安全地儲存它們、在各個實例間同步它們,並準時修剪它們。修剪邏輯中的一個錯誤,要嘛會保留已失效的金鑰(情況輕微),要嘛會過早移除金鑰(導致登出事件)。共享儲存區成為您認證關鍵路徑的一部分,因此其可用性現在會影響登入。
重疊視窗既是便利之舉,也是一種安全成本。在重疊期間,舊金鑰仍然會被接受,因此如果您輪替的原因是懷疑金鑰洩漏,那麼洩漏的金鑰在該視窗期間內仍會有效。單靠此機制,您無法既要接受有效的權杖,又要立即註銷一個已遭洩漏的金鑰。縮小曝險範圍的手段是縮短權杖的生命週期:存取權杖的生命週期為 15 分鐘到一小時,並由生命週期較長的更新權杖來刷新,這意味著一個已遭洩漏的簽署金鑰對攻擊者而言,在您切換金鑰後,其最多只有在那段短暫的視窗期間內有用。這就是為什麼短生命週期的存取權杖和更新權杖會與金鑰輪替搭配使用的主要原因。
在所有這些之下,還有無狀態與有狀態的選擇。使用一組金鑰來驗證 JWT 是無狀態的:任何實例只需使用記憶體中的金鑰即可檢查任何權杖,無需每次請求都查詢資料庫,這正是 JWT 的全部魅力所在。一旦您需要在特定權杖過期前將其撤銷(例如因為登出或帳戶遭駭),您就需要狀態:一個包含權杖 ID 的拒絕清單,並在每次請求時進行檢查。這就重新引入了您原本想避免的每次請求查詢。許多系統會接受一個小型的拒絕清單,以應對罕見的強制撤銷情況,同時保持常見路徑的無狀態性。金鑰輪替和個別權杖撤銷解決的是不同的問題,而輪替並不能為您提供撤銷功能。
當單一金鑰就足夠時
對於任何超越原型階段的東西,金鑰輪替都值得一做,但上述機制對於小型或生命週期短的系統來說可能過於繁瑣。在滿足以下所有條件時,使用沒有 kid 且沒有金鑰環的單一金鑰是可行的:
- 簽署金鑰只存在於伺服器記憶體或秘密管理器中,且絕不透過不安全的通道傳輸,因此洩漏的可能性很低。
- 權杖的生命週期很短,並且有重新整理的路徑,因此即使是強制輪替導致所有使用者登出,也只是短暫的困擾,而非服務中斷。
- 你控制著每一個驗證者,因此沒有外部方需要穩定的
kid或 JWKS 端點來在變更期間保持運作。 - 如果真的需要更換金鑰,你可以容忍一次計劃性的維護窗口來進行更換。
對於一個只有少數使用者的內部工具,在離峰時段更換金鑰並接受重新登入的成本,遠低於建立和操作一個金鑰環。重點在於了解你處於哪種情況。一旦你有了真正的使用者、外部驗證者,或是有按固定時程輪替的合規要求,多金鑰設計在第一次無人察覺的輪替中就值回票價了。
這個機制可以簡化為一條規則。用最新的金鑰簽署,用所有可能還有有效權杖的金鑰進行驗證,並且只在一個金鑰最長的可能權杖過期後才將其淘汰。只要正確設定重疊窗口,輪替就不再是使用者會感受到的事件。它變成了一項在他們保持登入狀態時運行的工作。