バックエンド

全員をログアウトさせることなくJWT署名キーをローテーションする方法

JWT署名キーを単純にローテーションすると、すべての有効なトークンが無効になり、すべてのユーザーがログアウトされます。それを回避するのが、オーバーラップ期間とkidを用いた設計です。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

JWTは、サーバーだけが保持する鍵で署名されているため、信頼されます。その鍵が漏洩すると、誰でも有効なトークンを偽造できるようになるため、定期的に鍵を変更する必要があります。単純な方法、つまり古い鍵を新しい鍵に交換する方法では、まだ有効期間中のすべてのトークンが壊れ、ログインしているすべてのユーザーは次のリクエスト時に強制的にログアウトさせられます。この記事では、強制的なログアウトをゼロにする鍵のローテーション設計を紹介します。それは、複数の鍵を同時に有効にしておき、最新の鍵で署名し、古いトークンが自然に期限切れになるまですべての鍵で検証するというものです。

中心となる考え方は小さなものです。署名と検証は、同じ単一の鍵を使用する必要はありません。署名は現在の鍵というただ1つの鍵で行いますが、検証は鍵のセットに対して行います。トークンがそのセットにまだ含まれているいずれかの鍵で署名されていれば、そのトークンは有効なままです。そうなると、ローテーションは、セットに鍵を追加し、署名者をそれに移動させ、その古い鍵で署名されたものが有効でなくなるのを待ってから初めて古い鍵を削除する、という手順になります。

なぜ安易な鍵交換が全員をログアウトさせてしまうのか

例えば、有効期間24時間のトークンを発行するとします。どの時点においても、常に変動する有効なトークン群が存在し、その中には数秒前に発行されたものもあれば、24時間近く前に発行されたものもあります。それらはすべて、発行時に最新だった鍵で署名されています。

ここで、鍵を交換します。次のリクエストが古い鍵で署名されたトークンを伴って到着すると、サーバーは新しい鍵でそれを検証しようとしますが、署名が一致せず、検証は失敗します。ユーザーはログアウトしたように見えます。これは、交換前に発行されたすべてのトークン、つまりアクティブなユーザーベース全体で発生します。意図的に誰かを失効させたわけではありません。ただ、トークンを検証可能にしていた唯一の秘密情報を変更しただけであり、元に戻す方法はありませんでした。

トークンの有効期間が要点です。古い鍵で署名されたトークンは、交換後もその完全な有効期間まで有効であり続ける可能性があります。そのため、古い鍵は少なくともその期間、検証に使用可能な状態を維持する必要があります。その唯一の所見が、設計のすべてです。

解決策: 1つのキーで署名し、多数のキーで検証する

キーを2つの役割に分割します。

  • 署名は1つのキー、つまり現在のキーを使用します。すべての新しいトークンはそれで署名されます。
  • 検証はキーセット、つまり現在のキーに加えて、トークンがまだ有効である可能性のある古いキーを使用します。

トークンが届いたとき、それが現在のキーで署名されたとは想定しません。どのキーが署名したかを特定し、そのキーに対して検証します。署名キーがまだキーセット内にあれば、そのトークンは有効です。すでにリタイアしたキーで署名されていた場合、検証は失敗します。これは、本当に失効したキーに対してまさに望む動作です。

この最初のステップを低コストで行うために、すべてのキーは kid (キーID) と呼ばれる短い識別子を持ちます。署名する際、トークンヘッダーに kid を刻印します。検証する際、ヘッダーから kid を読み取り、そのキーを正確に検索します。kid がないと、セット内のすべてのキーを1つが機能するまで試す必要があり、これはより遅く、わずかなタイミング情報を漏洩させます。kid があれば、検証キーの検索は1回のマップ読み取りで済みます。

以下に kid を持つJWTのヘッダーを示します。ヘッダーは単にbase64urlエンコードされたJSONなので、署名チェックの前に読み取ることができます:

{
  "alg": "HS256",
  "kid": "2026-07-01"
}

kidは、キーごとに一意で安定したものであれば何でもかまいません。日付、ランダムな文字列、またはカウンターはすべて使用できます。唯一のルールは、他の有効なキーと衝突してはならないこと、そして、誰でも読み取ることができるため、秘密情報を含んではならないことです。

ローテーションのタイムライン、ステップバイステップ

ローテーションは一連の状態のシーケンスであり、単一の切り替えではありません。各状態は安全に留まることができるため、タイマーで移行を自動化でき、同期した再起動を調整する必要は決してありません。

フェーズ 署名鍵 検証セット 何が起きているか
安定 K1 {K1} 鍵は1つだけ存在し、すべてのトークンはK1で署名される
導入 K1 {K1, K2} K2がセットに追加されるが、まだそれで署名されるものはない
切り替え K2 {K1, K2} 新しいトークンはK2を使用し、古いK1トークンもまだ検証可能
重複 K2 {K1, K2} 最後のK1トークンの有効期限が切れるのを待つ
廃止 K2 {K2} K1は削除される。そのトークンはどのみちすべて無効になっている

重要な唯一の期間は重複期間です。K1は、それで署名するのをやめた瞬間から数えて、少なくともトークンの最大有効期間と同じ期間、検証セットに留まらなければなりません。トークンの有効期間が24時間で、正午にK2に切り替えた場合、最も新しいK1トークンは正午直前に発行され、翌日の正午直前に失効します。それより早くK1を削除すると、正当に発行されたトークンが無効になります。その期間を過ぎてから削除すれば、有効なトークンはそれに依存していないため、廃止はユーザーにとって何の影響もありません。

どの時点でも、検証セットが有効なトークンが必要とする範囲を下回って縮小することはない点に注意してください。これが、ローテーションをユーザー側から見えなくしている理由です。ユーザーは、ローテーションによって引き起こされる署名エラーを目にすることはなく、すでに有効期限が切れたトークンによるエラーしか目にしません。

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
}

2つの詳細が実に重要です。jwt.WithValidMethods オプションは受け付けるアルゴリズムを固定し、呼び出し元が algnone に設定したり、RS256 を HS256 にダウングレードしたりする古典的な alg 混乱攻撃を防ぎます。そして 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)
		}
	}
}

複数インスタンスにわたるローテーションの自動化

1つのプロセスがキーリングをメモリ内に保持するのは簡単です。実際のデプロイメントでは、ロードバランサーの背後に複数のサーバーインスタンスがあり、それらすべてがどのキーが有効であるかについて合意する必要があります。もしインスタンスAが新しいキーにローテーションし、インスタンスBがそれを全く知らない場合、Aによって署名されたトークンはBで失敗し、ランダムなログアウトに逆戻りしてしまいます。

解決策は、キーリングをプロセスごとのメモリではなく、共有ストレージに保持することです。Redisやデータベースの行が機能します。スケジュールされたジョブ(週次が一般的)が新しいキーを生成し、そのkidと有効期間とともに共有ストアに書き込み、現在のポインターを移動させます。各インスタンスは、TTL付きの短いキャッシュか、pub/sub通知を通じて、ストアからキーセットを読み取ります。そのため、数秒以内にすべてのインスタンスが同じセットを参照するようになります。

実用的な設定では、現在と次の2つのキーを常に有効にし、それらの有効期間を重複させます。週次で実行されるジョブは、次期キーを現在キーに昇格させ、新しい次期キーを生成します。有効期間がトークンの寿命よりも長く重複しているため、インスタンスが有効なトークンを拒否するようなギャップは決して生じません。セッションやレート制限のためにすでにRedisを運用している場合、これは1つの小さなキーと1つのcronのようなジョブを追加するだけで、それ以上の負荷はありません。

非対称鍵の場合、検証セットを公開するためのよく確立された標準があります。それはJWKSで、既知のURLにあるJSONドキュメントであり、kidによって公開鍵をリストアップします。リソースサーバーはそれをフェッチしてキャッシュし、受信したトークン内のkidを公開鍵と照合します。トークンヘッダー内のkidは、JWKSエントリのインデックスとなるkidと同じです。これが、単一サービスのセットアップであっても、この識別子を追加する価値がある理由です。これは、他のサービスがあなたの署名鍵を保持することなくあなたのトークンを検証できるようにするための継ぎ目となります。

受け入れることになるトレードオフ

この設計はゼロダウンタイムのローテーションを実現しますが、それにはコストが伴います。

今や1つではなく一連の鍵を管理することになります。それらを生成し、安全に保管し、インスタンス間で同期し、適時に整理するのです。整理ロジックのバグは、無効な鍵を残したままにするか(軽微)、あるいは鍵をあまりにも早く削除してしまいます(ログアウトイベント)。共有ストアは認証のクリティカルパスの一部となり、その可用性がログインに影響を与えるようになります。

重複期間は利便性であると同時に、セキュリティ上のコストでもあります。重複期間中、古い鍵はまだ受け入れられるため、ローテーションの理由が漏洩の疑いである場合、漏洩した鍵はその期間中、機能し続けます。このメカニズムだけでは、有効なトークンを尊重しつつ、侵害された鍵を即座に無効にすることはできません。この露出を縮小する手段は、短いトークンの有効期間です。より有効期間の長いリフレッシュトークンから更新される、15分から1時間のアクセストークンは、侵害された署名鍵が攻撃者にとって有用なのは、切り替え後、最大でもその短い期間だけであることを意味します。これが、短いアクセストークンの有効期間とリフレッシュトークンが鍵のローテーションとセットで使われる主な理由です。

このすべて根底には、ステートレスかステートフルかという選択もあります。鍵セットによるJWTの検証はステートレスです。どのインスタンスもメモリ内の鍵だけでどのトークンでもチェックでき、リクエストごとのデータベース検索は不要です。これがJWTの最大の魅力です。ログアウトやアカウントの侵害のために、特定のトークンを有効期限が切れる前に失効させる必要がある瞬間、ステートが必要になります。つまり、リクエストごとにチェックされるトークンIDの拒否リストです。それは、避けようとしていたリクエストごとの検索を再び導入することになります。多くのシステムでは、一般的なパスをステートレスに保ちつつ、まれな強制失効ケースのために小さな拒否リストを受け入れています。鍵のローテーションとトークンごとの失効は異なる問題を解決するものであり、ローテーションは失効機能を提供するものではありません。

単一のキーで十分な場合

ローテーションは、プロトタイプを超えるものには行う価値があります。しかし、上記の仕組みは、小規模または短命なシステムにとっては過剰になる可能性があります。以下のすべてが当てはまる場合、kidがなくキーリングもない単一のキーで問題ありません。

  • 署名キーはサーバーのメモリまたはシークレットマネージャーにのみ存在し、安全でないチャネルを経由することは決してないため、漏洩の可能性は低いです。
  • トークンの有効期間は短く、リフレッシュパスがあるため、全員をログアウトさせる強制的なローテーションであっても、停止ではなく一時的な迷惑にすぎません。
  • すべての検証者を制御しているため、安定したkidやJWKSエンドポイントを必要として変更後も動作し続ける外部の当事者はいません。
  • 万が一そのような事態になった場合に、キーを交換するための計画的なメンテナンスウィンドウを1回許容できます。

少数のユーザーが利用する内部ツールの場合、閑静な時間帯にキーを交換し、再ログインを受け入れるコストは、キーリングを構築して運用するよりもはるかに低くなります。重要なのは、自分がどの状況にいるかを知ることです。実際のユーザー、外部の検証者、または固定スケジュールでのローテーションを求めるコンプライアンス要件ができた瞬間、マルチキー設計は、誰も気づかないうちに初めてローテーションを行ったときに、その価値を発揮します。

このメカニズムは1つのルールに集約されます。最新のキーで署名し、まだ有効なトークンが存在する可能性のあるすべてのキーで検証し、最も長い可能性のあるトークンの有効期限が切れた後にのみキーをリタイアさせます。重複期間を正しく設定すれば、ローテーションはユーザーが体感するイベントではなくなります。それは、ユーザーがログインしたままで実行されるジョブになります。