鍵ローテーション
ローテーションする鍵の役割は 3 種類で、サイクルもそれぞれ違います:
- 署名鍵(
op.Keyset) — ID トークン、JWT アクセストークン、JARM、userinfo JWT を署名する秘密鍵(既定は ECDSA P-256、OP がRS256で署名する場合は RSA)。公開側は/jwksに載ります。 - Cookie 鍵(
op.WithCookieKeys) — session、interaction、CSRF の各 cookie を封緘する 32 byte の AES-256-GCM 鍵。 - inbound 暗号化鍵(
op.EncryptionKeyset) — クライアントが暗号化した request object を復号する秘密鍵。公開側はuse=enc鍵として/jwksに載り、outbound 暗号化鍵ではありません。
いずれも、新しい *Provider を構築してアトミックに差し替える形でローテーションします。差し替えは op.Reloadable が担います(再起動なしのリロードを参照)。鍵スライスをその場で書き換える API はありません。
署名鍵のローテーション
サイクル
- 定期: 60〜90 日に一度。通常のオーバーラップ期間(下記のトークン寿命と RP の JWKS キャッシュ期間)を終えてから退役鍵を外します。
- 侵害時: 直ちに交換し、侵害されたエントリを除いた
Keysetを再構築して JWKS から速やかに削除します。侵害鍵を通常のオーバーラップ期間のために残さず、その鍵で署名されたトークンはインシデント対応と失効の手順に従って扱います。
手順
鍵は 1 本 1 ファイルで置き、OP に読み込ませます。op.LoadKeyset には現行鍵のパスを先頭に、退役鍵のパスをその後に渡します。順序は op.WithKeyset と同じです。
// Step 1. 新しい鍵を生成して鍵ディレクトリに置く。
// 例: openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256
// それまで現行だったファイルを退役用のファイルにする。
// Step 2. 新鍵を先頭、旧鍵をその後にして読み込む。
ks, err := op.LoadKeyset("/etc/op/keys/signing.pem", "/etc/op/keys/signing-retiring.pem")
if err != nil { /* 旧構成のまま動かし続けてアラート */ }
// Step 3. 任意。OP 自身が退役 kid を信頼する期限を決める。
// 読み込んだ直後の NotAfter はゼロ。「退役した鍵を外すタイミング」参照。
ks[1].NotAfter = time.Now().Add(2*time.Hour + 5*time.Minute)
// Step 4. Keyset をリロードに渡す(次のセクション)。
opts := append(baseOptions, op.WithKeyset(ks))op.LoadSigningKey(path) は 1 本だけを読み込みます。どちらも PKCS#8 PEM、SEC1 または PKCS#1 の PEM、private JWK(kty が EC または RSA)を受け付け、鍵は ECDSA P-256 か 2048 bit 以上の RSA に限ります。暗号化された PEM、別の曲線、それより短い RSA 鍵、壊れたファイルはエラーになり、エラーにはパスだけを含め、鍵の中身は含めません。kid は、JWK に kid があればその値、なければ公開鍵の RFC 7638 SHA-256 thumbprint です。同じファイルを mount した全レプリカが同じ kid を公開します。LoadKeyset は kid の重複を拒否し、同じ鍵を 2 つのファイルから読んだ場合も重複として扱います。
Keyset の先頭が常に現行の signer で、それ以降は検証専用として JWKS に載ります。JWKS をキャッシュ済みの RP は、次の更新時か、知らない kid に出会った時点で、新しい kid を拾います。キャッシュヘッダの挙動は JWKS エンドポイント を参照してください。
退役エントリの NotAfter は、OP 自身の検証を止める期限です。その時刻以降、OP は /userinfo と /introspect での自分の JWT アクセストークンを含め、その kid を名乗るトークンを拒否し、key.retired_kid_presented を記録します。公開鍵は、Keyset から外して作り直すまで /jwks に残ります。retireAfter には、下で計算する重なり期間の終わりを指定し、ローテーションした時刻そのものにはしないでください。現行エントリの NotAfter は未設定か未来でなければならず、すでに NotAfter に達している場合は op.New が失敗します。
退役した鍵を外すタイミング
新しい provider が応答を始めた時点から数えて、次の 2 つが両方とも終わるまで待ちます。
- 旧鍵が署名したすべてのものの最長の寿命。アクセストークン(既定の
WithAccessTokenTTLは 5 分)と ID トークンです。 - RP 側の JWKS キャッシュの期間。
/jwksはmax-age=3600, stale-while-revalidate=3600で返すため、キャッシュが入れ替わるまでに最長で 2 時間かかります。
既定値では 約 2 時間 5 分 です(寿命の長い ID トークンがあれば、その分延びます)。寿命の長いトークンを運用している場合は、その寿命を上乗せしてください。これより早く外すと、その後に /jwks を取得した検証側は、旧鍵が署名したトークンを検証できません。
ローテーション中であることのシグナル
op.WithJWKSRotationActive(predicate) で Provider に述語を渡します。述語が true の間、/jwks は長い既定キャッシュの代わりに 5 分の Cache-Control: public, max-age=300, must-revalidate を返すので、RP のキャッシュが早く更新されます。重なり期間のあいだ true にしておいてください。op.Reloadable を使う場合、述語はリロードをまたぐフラグや期限を参照できるので、ローテーション時のリロードと、退役鍵を外す後のリロードを 1 つのクロージャで賄えます。JWKS エンドポイント § ローテーション中のキャッシュ制御 を参照。
署名アルゴリズムを切り替える
OP が署名に使うアルゴリズムは 1 つです。既定は ES256 で、op.WithSigningAlgorithm(op.AlgRS256) を指定すると RS256 になります。アクティブな鍵(Keyset の先頭)は、そのアルゴリズムに合う種類でなければなりません。つまりアルゴリズムを変えることは鍵の種類を変えることで、通常のローテーションと同じ重なり期間のルールに従います。切り替え前に発行したトークンが検証できるのは、旧い種類の鍵が NotAfter 付きで Keyset に残っている間だけです。
- 2048 bit 以上の RSA 鍵を生成します。例:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072。 Keysetの先頭にその鍵を置き、旧い EC 鍵をNotAfter付きで後ろに残します。署名アルゴリズムと種類が違うエントリにはNotAfterが必須で、なければop.Newが失敗します。op.WithSigningAlgorithm(op.AlgRS256)を加えてリロードします。NotAfterを過ぎたら、RSA 鍵だけでもう一度リロードします。
// 手順 1〜2。RSA 鍵がアクティブで、旧い EC 鍵は検証だけに使います。
ks, err := op.LoadKeyset("/etc/op/keys/signing-rsa.pem", "/etc/op/keys/signing-ec.pem")
if err != nil { /* 旧い構成のまま稼働を続け、アラートを上げる */ }
ks[1].NotAfter = time.Now().Add(2*time.Hour + 5*time.Minute)
// 手順 3。WithKeyset と WithSigningAlgorithm は、1 つのオプションリストに 1 回ずつ渡します。
opts := append(baseOptions, op.WithKeyset(ks), op.WithSigningAlgorithm(op.AlgRS256))
if err := r.Reload(ctx, opts...); err != nil { /* 直前の構成が稼働を続ける */ }
// 手順 4。NotAfter の後に、op.Keyset{rsaEntry} だけでリロードします。NotAfter は 退役した鍵を外すタイミング の計算で決めます。両方のエントリがある間、/jwks には 2 種類の鍵が載りますが、discovery が広告するのは OP が発行に使うアルゴリズムだけです。最初のリロード以降、id_token_signing_alg_values_supported、userinfo_signing_alg_values_supported、authorization_signing_alg_values_supported、introspection_signing_alg_values_supported は ["RS256"] になります。
旧いアクティブ鍵を除いた Reload は op.ErrReloadIncompatible で拒否されるので、重なり期間を飛ばした切り替えはできません。逆向きの手順で、RS256 の OP を ES256 に戻せます。署名鍵と暗号化鍵は別のものにしてください。WithEncryptionKeyset のエントリが署名鍵と同じ公開鍵を使っていると、op.New が拒否します。RS256 は profile.FAPI2Baseline、profile.FAPI2MessageSigning、profile.FAPICIBA とは併用できません。
RP から見える影響は次のとおりです。
ES256を固定している RPは、discovery を読み直してRS256を受け付けるようにするまで、切り替え後に発行されたトークンをすべて拒否します。リロードの前に知らせてください。- Dynamic Client Registration で
id_token_signed_response_alg=ES256を登録した RPは、OP が実際に発行する alg と食い違う値を持ち続けます。OP はクライアントごとには発行しないため、保存済みの値はそのままですが、その値を変えないメタデータを registration management endpoint へPUTするとinvalid_client_metadataで拒否されます。RP はid_token_signed_response_alg=RS256を送るか、省略してください(省略した場合は OP がRS256を補います)。切り替え後の登録はRS256が既定になります。 - RSA の公開鍵を公開すると、JOSE ライブラリが RSA の公開鍵を HS256 の秘密鍵として使うことをまだ許している RP が攻撃にさらされます。RP は検証時に期待する
algを固定してください。OP 自身の検証は、JWS ヘッダのalgが鍵のアルゴリズムと等しいことをすでに要求しています。
RS256 の OP に対して RP をテストするには、testkit.WithSigningAlgorithm(op.AlgRS256) でテスト用プロバイダを構築します。RSA 2048 の鍵を生成し、op.WithSigningAlgorithm を op.New に渡します。Provider.SignedJWT は、現在の署名鍵(RS256 か ES256 のどちらか、鍵に合わせた方)でテスト用 fixture に署名し、testkit の kid を付けます。
inbound 暗号化鍵のローテーション
op.EncryptionKeyset は、/auth と /par に届く暗号化 request object を復号する OP の inbound 専用 JWE 鍵集合です。outbound 応答のアクティブ鍵を供給するものではありません。outbound JWE 応答はクライアントの JWKS にある use=enc 鍵で暗号化します。
encKeys := op.EncryptionKeyset{
{KeyID: "enc-2026-09", PrivateKey: newEncPriv},
{KeyID: "enc-2026-05", PrivateKey: oldEncPriv, NotAfter: oldEncExpiry},
}
newProv, err := op.New(
op.WithIssuer("https://op.example.com"),
op.WithStore(myStore),
op.WithKeyset(signingKeys),
op.WithEncryptionKeyset(encKeys),
op.WithCookieKeys(cookieKey),
/* ...残りのオプション... */
)entry は inbound 復号候補の順序で並び、use=enc として公開されます。kid 付き JWE は一致する live entry を選びます。kidless JWE は保護ヘッダの alg と同じファミリに属する live 鍵へ絞り込まれ、残った候補が op.MaxKidlessEncryptionTrialKeys(既定値 4)を超えると、切り詰めず復号を拒否します。設定したファミリが上限を超える場合は op.New が warning を出すため、RP には kid を含めさせてください。
新旧 entry は、RP の JWKS キャッシュ期間と受理する request object の最長寿命を通して公開します。NotAfter は厳格な cutoff であり、その時刻以降は OP がその鍵を拒否し、公開側も JWKS から外します。overlap が終わったら退役 entry を削除してください。outbound 応答には OP 側の鍵がないため、別途ローテーションするものはありません。
Cookie 鍵のローテーション
サイクル
- 定期: 四半期ごと(リスクは低めです。cookie は
__Host-プレフィックスで保護され、寿命は session の寿命の中に収まります)。 - 侵害時: 即時。
手順
// 新しい 32 byte の鍵を生成する。
newKey := make([]byte, 32)
if _, err := rand.Read(newKey); err != nil { /* abort */ }
// 先頭の鍵で暗号化、それ以降は復号試行に使われる。
op.WithCookieKeys(newKey, oldKey)鍵をファイルから読む場合は op.LoadSymmetricKeys を使います。渡したパスの順に鍵を返し、先頭のパスが現行鍵です。
keys, err := op.LoadSymmetricKeys("/etc/op/keys/cookie.key", "/etc/op/keys/cookie-retiring.key")
if err != nil { /* ... */ }
op.WithCookieKeys(keys...)各ファイルには、32 byte の raw、64 文字の hex、32 byte 分の base64 のいずれかを入れます。前後の空白は無視します。それ以外の長さは拒否し、openssl rand -hex 16 の出力のような、印字可能 ASCII だけの 32 文字のファイルも対象です。このファイルは 128 bit 分のエントロピーしか持ちません。鍵は openssl rand -hex 32 で生成してください。
WithCookieKeys は復号用に旧鍵を残せるので、有効なセッションはそのまま継続します。session の既定は、アイドル 14 日・絶対 30 日です。oldKey は最長 30 日残してください。それより早く外すと、その鍵で封緘された cookie を持つユーザがログアウトされます。該当するセッションがすべて終わったら、oldKey をスライスから外します。レプリカが複数ある場合は、他のレプリカが開けない cookie を封緘するレプリカが出ないよう、マルチインスタンス展開 の 3 段階の手順でロールアウトしてください。
順序が重要
WithCookieKeys の先頭が現行の暗号化鍵です。旧鍵を先頭にしてしまうと、気付かれないうちに侵害済み鍵を現行扱いに格下げしてしまいます。型ではなく規約での強制なので、ローテーション時はレビューを慎重にしてください。
MFA 暗号化鍵のローテーション
WithMFAEncryptionKeys も cookie 鍵と同じ「先頭が現行、以降が過去」の形に従います。32 byte 鍵で TOTP のシークレットを保存時暗号化(AES-256-GCM、subject 識別子を AAD にバインド)します。StepTOTP に専用の EncryptionKey を渡した場合は、そちらが使われます。
op.WithMFAEncryptionKeys(currentKey, previousKey)この鍵も op.LoadSymmetricKeys で読み込めます。previousKey を退役させるのは、永続化済みのすべての TOTP レコードが currentKey で再暗号化されてからです。これはストア側のマイグレーション課題で、ライブラリが自動再暗号化することはありません。
再起動なしのリロード
op.NewReloadable は op.New で最初の *Provider を構築し、現行のプロバイダで応答する http.Handler を返します。Reload は置き換え用のプロバイダを構築して差し替えます。構築に失敗したときは、動いているプロバイダがそのまま応答を続けます。起動時もリロード時も同じ関数でオプション一式を組み立てると、リロードのたびに鍵ファイルを読み直すだけで、ほかの設定がずれることがありません。
r, err := op.NewReloadable(buildOptions()...)
if err != nil { log.Fatal(err) }
srv := &http.Server{Addr: ":8080", Handler: r}
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
go func() {
for range hup {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
if err := r.Reload(ctx, buildOptions()...); err != nil {
slog.Error("reload rejected; the previous configuration keeps serving", "error", err)
}
cancel()
}
}()ルータにパターンごとに OP をマウントする場合は、r.Routes() の各パターンに r を登録します。プロセスを止めるときは r.Shutdown(ctx) を呼びます。
リロードで引き継がれるものと、組み込み側に求められること:
オプションは全部渡す。
Reloadはop.Newと同じく全オプションを受け取り、差分は受け取りません。同じストアを使う。 セッション、interaction、ロックアウトカウンタなど永続化済みのレコードが引き継がれるのは、新しいプロバイダが旧プロバイダの書いたストアを読むからです。新しいハンドルで同じデータベースに接続し直すのは問題ありませんが、別のバックエンドを指すとこれらの状態は失われます。
issuer とルートは変えられない。 issuer かルート集合が変わると、
op.ErrReloadIncompatibleをラップしたエラーで拒否します(errors.Isで判定します)。変更したいときはプロセスを再起動してください。ルート集合の変更で拒否された場合、置き換え用のプロバイダはすでに構築済みなので、新しいWithStaticClientsの seed はストアに書き込まれています。同じオプションで再起動すれば同じ seed が適用されるため、害はありません。退役鍵は 2 番目以降に残す。
op.WithCookieKeysとop.WithMFAEncryptionKeysでは、新しい鍵を先頭に置き、退役する鍵を後ろに残します。稼働中のセッションや登録済みの TOTP シークレットを封緘した鍵を外すと、それらが読めなくなり、全ユーザがログアウトされ、TOTP の登録もやり直しになります。署名鍵も、旧鍵が署名したトークンが失効するまでKeysetに残してください。鍵の連続性は強制される。 新しいオプションが、稼働中のプロバイダが払い出したものを失わせる場合、
Reloadはop.ErrReloadIncompatibleで拒否し、稼働中のプロバイダが応答を続けます。WithCookieKeysのリストやWithKeysetが、稼働中の active(先頭)の鍵を含まない場合。KeyIDが同じで鍵素材が異なる鍵は、含まれていないものとして扱います。- 稼働中の設定が持つ MFA 暗号鍵を 1 つでも外す場合。
WithMFAEncryptionKeysで渡した鍵も、StepTOTP.EncryptionKey/EncryptionKeyPrevで渡した鍵も対象です。MFA 鍵を退役させるときは、すべての TOTP レコードを封緘し直してから、一度再起動してください。 WithDPoPNonceSourceが、稼働中のものと同じインスタンスでない場合。
そのため、ローテーションは 2 回のリロードで行います。最初のリロードで
[新, 旧]を渡し、後のリロードで[新]を渡します。cookie 鍵と署名 keyset では、その時点で新しい鍵が稼働中の active の鍵になっているため、2 回目のリロードが通ります。メモリ上の状態。 DPoP nonce source は、オプション組み立ての外で一度だけ作り、すべてのビルドに同じインスタンスを渡してください。別のインスタンスは、旧 source が発行した nonce が検証できなくなるため拒否されます。
op.WithJWKSRotationActiveに渡した述語は新しいオプション一式が持つ値になります。リロードをまたぐフラグや期限を参照するクロージャなら、1 つのクロージャが複数回のリロードにまたがって使えます。呼び出しのたびに作り直すクロージャは引き継がれません。Prometheus。
op.WithPrometheusを使っている場合は、旧プロバイダの collector を unregister し、同じ registry に新しい collector を登録します。この間の scrape では issuer の series が見えず、カウンタは 0 から再開します。rate()はこれをカウンタのリセットとして扱います。リロードが拒否されたときは、旧 collector を登録し直します。drain。 差し替えの後、
Reloadは旧プロバイダで処理中のバックチャネルログアウト配送が終わるまで戻りません。遅い RP がいるとReloadが遅れ、次のReloadもその後ろで待ちます。待ち時間は context で区切ってください。旧プロバイダですでに処理中のリクエストは、そのまま旧プロバイダで完了します。Reloadを HTTP パスに公開しない。Reloadは OP のセキュリティ設定を作り直します。シグナル、ファイル監視、OP のクライアントから届かない管理用プロセスのいずれかから起動してください。
ソース:
examples/53-production-sqlは、op.LoadKeysetとop.LoadSymmetricKeysで鍵ディレクトリから署名鍵と cookie 鍵を読み、op.NewReloadableで応答し、SIGHUP でリロードします。cookie 鍵のローテーションは、mv cookie.key cookie-retiring.keyで退役させ、新しいcookie.keyを書いてkill -HUPするだけです。
ローテーションしないもの
- Issuer。
WithIssuerを変えると、発行済みのすべてのトークン(issは JWT の一部です)と、すべての RP の discovery キャッシュが無効になります。ローテーションではなくデプロイ扱いです。 Keyset内のKeyID。 いったん JWKS に出したkidは、RP のキャッシュキーになる公開識別子です。別の鍵に同じkidを再利用すると、古い公開鍵をキャッシュしている RP で検証に失敗します。ローテーションのたびに新しいkidを生成してください。
検証チェックリスト
ローテーション後:
curl https://op.example.com/jwksで、新しいkidが出ていて退役したkidがまだ並んでいるか(撤去工程を完了するまでは並んでいるべき)を確認します。- 新しいトークンの
kidヘッダが新鍵を指しているか確認します:echo "<jwt>" | cut -d. -f1 | base64 -d | jq .kid。 - 切り替え(cut-over)後に
key.retired_kid_presented監査イベントが新たに発火していないことを確認します。このイベントは、NotAfterを過ぎたkidを名乗る JWS / JWE が届いたときに出るため、NotAfterを設定したエントリだけが対象です。沈黙が成功のシグナルです。 - ローテーション前に確立したブラウザセッションで、
/authの認可要求を再開し、再認証なしに進められること(session cookie を復号できること)を確認します。
なぜその場で書き換えず、新しい Provider を作るのか
ローテーションは OP のセキュリティ性質を根本から変える操作です。op.New(...) を改めて呼ばせる形にしておくことで:
- すべてのオプション衝突チェックが再走します(typo を載せたまま動かすことができません)。
- 検証パスがアトミックに差し替わります(中途半端なローテーション状態が発生しません)。
- 監督プロセス側のログ / 監査記録に明示的なイベントが残ります(プロセス内部で暗黙に状態変更が走るのとは違います)。
op.Reloadable を使えば、この監督プロセス側の処理は小さく収まります。リロードを直列化し、プロバイダをアトミックに差し替え、issuer やルートの変更、稼働中の cookie 鍵・署名鍵・MFA 鍵の喪失、別の DPoP nonce source への差し替えは拒否します。