Keycloak の ID トークンを JWE で検証する
- JWE が
署名ではなく暗号化を担う仕組みであることを、JWS と比較しながら整理する - RFC 7516 を読みながら、JWE Compact Serialization の 5 つのセグメントと
alg/encの役割を確認する - Keycloak で ID トークンの JWE を有効にし、発行されたトークンの
alg/enc/kidがどう見えるかを確認する - 受け取った JWE を Go で復号し、内側が署名済みの ID トークンになっていることを確認する
- Better Auth の Cookie も題材にして、
dirのような別の鍵管理モードでは何が変わるかを見る
Next.js で認証機能を実装するとき、ライブラリに Auth.js や Better Auth を選択することがあります。これらはセッションデータの保存先に Cookie を選べます。その場合、データ構造として JWE がデフォルトで使われます。さらに RFC 9101 にも認可リクエストを JWE で保護する選択肢があり、JWS と何が違うのか、JWE について知りたくなりました。
JSON Web Tokens are encrypted (JWE) by default
訳:JSON Web Tokensはデフォルトで暗号化(JWE)されています
引用:https://authjs.dev/reference/core#jwt-1
https://github.com/kntks/blog-code/tree/main/2026/04/keycloak-id-token-jwe
以下をローカル環境で使用できる
- Docker
- Go
| 項目 | バージョン |
|---|---|
| Mac | 26.3.1 |
| Keycloak | 26.5.6 |
| Docker | 29.3.0 |
| Docker Compose | 5.1.1 |
OAuth 2.0 Token Introspection を Keycloak で検証する の構築手順と同じです。
この手順によって Terraform を実行することで Keycloak の設定を一度に行います。
JWE の前提を RFC 7516 で整理する
Section titled “JWE の前提を RFC 7516 で整理する”JWE とは
Section titled “JWE とは”以下の引用から、JWE は暗号化されたコンテンツを JSON ベースのデータ構造で表現し、機密性と完全性を扱えることがわかります。
JWE utilizes authenticated encryption to ensure the confidentiality and integrity of the plaintext and the integrity of the JWE Protected Header and the JWE AAD.
訳:JWEは、認証付き暗号化を利用して、平文の機密性と完全性、およびJWE保護ヘッダーとJWE AADの完全性を確保します。
A data structure representing an encrypted and integrity-protected message.
訳:暗号化され、かつ完全性保護されたメッセージを表すデータ構造。
JSON Web Encryption (JWE) represents encrypted content using JSON-based data structures.
訳: JWE は、暗号化されたコンテンツを JSON ベースのデータ構造で表現する
引用: https://datatracker.ietf.org/doc/html/rfc7516
なぜ JWE が必要になるのか
Section titled “なぜ JWE が必要になるのか”JWS の payload は base64url エンコードされているだけで、暗号化されているわけではありません。署名によって改ざんは検知できますが、トークン文字列を取得できた主体であれば中身を読むことができます。
そのため、改ざんされていないこと だけではなく、内容そのものを読まれたくない 場面では JWE が必要です。JWS は完全性や作成者検証を扱う仕組みであり、機密性を提供する仕組みではありません。
JWS と JWE の違い
Section titled “JWS と JWE の違い”JWS は署名によって完全性と作成者検証を扱い、JWE は暗号化によって機密性を扱います。JWS では、受信者はトークンが改ざんされていないことと、対応する署名鍵の保有者によって作成されたことを検証できます。一方で JWE は、第三者に内容を読まれないようにするための仕組みです。なお、JWE でも認証付き暗号によって完全性保護は行われますが、署名のように「誰が作ったか」を示すものではありません。
参考:
- https://datatracker.ietf.org/doc/html/rfc7515#section-1
- https://datatracker.ietf.org/doc/html/rfc7516#section-1
JWE Compact Serialization の見た目
Section titled “JWE Compact Serialization の見た目”JWE Compact Serialization では、5 つの値が . 区切りで並びます。各値はそれぞれ base64url エンコードされた状態で表現されており、. 自体は値ではなく、5 つのセグメントを区切るための文字です。
Protected Header は JSON を base64url エンコードした値で、それ以外の Encrypted Key、IV、Ciphertext、Authentication Tag はオクテット列を base64url エンコードした値です。Header だけは JSON に戻せます。
golang-jwt/jwe を使用したサンプルコードを Playground に用意したので、実行してみると以下のような JWE を取得できます。
手元で確認する場合は、サンプルコードを clone した上で go run cmd/gen-jwe/main.go でも同じように試せます。
(見やすくするために改行しています)
eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZHQ00ifQ.
neHSmkxHYRM37KgZukwoDs-ajUpmmLsuREGZ9ux5dcXM3g_ud4gVvPwLIozYlevhMqD2lOnhXH3DnWhweaOpkCKL1-pmBne5tdyR7CBWyjhmRRWvnunlSbfD52gL6AU6amkda3jdRu3rZCqCv8N8S5_aHVR-Yc-lRNQImLa4MLkadupF8sBjahH-y8peNPp7mr7JWcDjsKedHHK5iZ6IBH9Y5yKFmR6QdIcLqfqT5ZvZC1lDJE70o4qdM1cmxHsilD-wYAxneoLrBe1tcUuCTbyNQSl-0EWF3z0Tj1M52-T0OwElszmJ7MmwZRFgxO-ArACZRwmuLIs3pYcV5rSunQ.
XoPSuas0itbTkx80.
7KPN2Bad5KSP6BduaP782vvnf_d9b8msiAtp1QfD03DA0YZoSaZelZAXihqkbSIck1L5VrczuS0GiFlcbHQhuqvJCw5lqFyjKDFN9M3Y3HRIH4biWlpiob4nIb7z7emOgW0jl32izdV-gqoxsuGRZUXPuguDjvwF5k86c5nHMUrJds-yEg.
VEpvel8WH1lhODDXe5nSwwAAD とは
Section titled “AAD とは”Additional Authenticated Data (AAD) とは、暗号化はされないものの完全性の検証に含める追加データです。JWE Compact Serialization では、ASCII(BASE64URL(UTF8(JWE Protected Header))) で表現されます。
An input to an AEAD operation that is integrity protected but not encrypted.
AEAD演算への入力で、完全性は保護されているが、暗号化はされていないもの。
引用:https://datatracker.ietf.org/doc/html/rfc7516#section-2
Let the Additional Authenticated Data encryption parameter be ASCII(BASE64URL(UTF8(JWE Protected Header))).
訳:追加認証データ(Additional Authenticated Data, AAD)の暗号化パラメータは、ASCII(BASE64URL(UTF8(JWE Protected Header))) とする。
引用:https://datatracker.ietf.org/doc/html/rfc7516#section-3.3
JOSE Header で見るべき項目
Section titled “JOSE Header で見るべき項目”JOSE Header に関する記述は RFC 7516 section 4 に書かれており、JWE の JOSE ヘッダーを読み解く際は、「鍵の管理」を担う alg と 「本文の保護」を担う enc の 2 つに分けて考えると理解しやすいです。
alg(Algorithm): コンテンツ暗号化鍵(CEK)を暗号化する、あるいはその値を決定するためのアルゴリズムです。CEK をどう安全に相手へ渡すかという鍵管理(Key Management)の方式を定義します。enc(Encryption Algorithm):algによって共有された CEK を用いて、平文に対して 認証付き暗号(AEAD) を実行するためのアルゴリズムです。届いた鍵で本文をどう暗号化し、認証タグを付与するかというコンテンツ保護を定義します。
このように、alg は 「鍵のレイヤー(受け渡し方法)」、enc は 「本文のレイヤー(暗号化の実装)」 と考えると、JWE 仕様を理解しやすくなります。
参考:
- https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.1
- https://datatracker.ietf.org/doc/html/rfc7516#section-4.1.2
JWE 鍵管理モードと構造の変化
Section titled “JWE 鍵管理モードと構造の変化”JWE Compact Serialization は 5 つのセグメント(Header.EncryptedKey.IV.Ciphertext.Tag)で表現されますが、alg が示す鍵管理モードによっては 2 番目のセグメントである Encrypted Key が空文字列になる場合があります。たとえば ECDH-ES や dir では、コンテンツ暗号化鍵(CEK)を別途暗号化して運ぶ必要がない、あるいは鍵合意によって直接決定するため、Compact Serialization でも 2 つ目のセグメントは . に挟まれた空の値になります。
さらに、JSON Web Algorithms (JWA) の規定により、alg の種類に応じて JOSE ヘッダーに追加のパラメータが必須となる点にも注意が必要です。必要パラメータは以下の表にまとめています。
そのため、JWE オブジェクトを解析する際は「5 分割されているか」という形式的な確認だけでなく、「alg の値に基づいて、各セグメントやヘッダーに何が入る想定なのか」をセットで理解することが不可欠です。alg は単に鍵暗号のアルゴリズム名を示すだけでなく、CEK をどう共有するか というキーマネジメントの方式を決定し、その結果として JWE のデータ構造全体に影響を与えます。
| キーマネジメントモード | 代表的な alg 値 | JWE Encrypted Key | 追加される主なヘッダーパラメータ (JWA) |
|---|---|---|---|
| Key Encryption (鍵暗号化) | RSA-OAEP | 暗号化されたCEK | なし |
| Key Wrapping (鍵ラッピング) | A128KW, A128GCMKW | ラップされたCEK | GCM時は iv, tag / PBES2時は p2s, p2c |
| Direct Key Agreement (直接鍵合意) | ECDH-ES | 空 (長さ0) | epk (必須), apu, apv |
| Key Agreement with KW (鍵合意+ラッピング) | ECDH-ES+A128KW | ラップされたCEK | epk (必須), apu, apv |
| Direct Encryption (直接暗号化) | dir | 空 (長さ0) | なし |
参考:
- https://datatracker.ietf.org/doc/html/rfc7516#section-2
- https://datatracker.ietf.org/doc/html/rfc7518#section-4
AEAD があっても Nested JWT が使われる理由
Section titled “AEAD があっても Nested JWT が使われる理由”ここまで読むと、enc による認証付き暗号(AEAD)で完全性保護も得られるなら、なぜさらに JWS を組み合わせた Nested JWT が必要なのか、という疑問が出てきます。結論から言うと、JWE 単体が提供するのは機密性と暗号文に対する完全性保護であり、「誰がその内容を作ったか」までは示しません。Authentication Tag は正しい鍵を使って暗号化できる主体であれば生成できるため、JWS の署名のような作成者検証や非否認性が必要なら JWE(JWS(payload)) のようにネストして扱います。
JWT 種類別・鍵タイプ別 セキュリティ特性一覧
Section titled “JWT 種類別・鍵タイプ別 セキュリティ特性一覧”| 特性 | 説明 | JWS (非対称/署名) | JWS (共通/MAC) | JWE (非対称鍵) | JWE (共通鍵) | Nested JWT (非対称署名+暗号) | Nested JWT (共通鍵MAC+暗号) |
|---|---|---|---|---|---|---|---|
| 整合性 (Integrity) | 改ざんされていないこと | ○ | ○ | ○ | ○ | ○ | ○ |
| 真正性 (Authenticity) | 正しい鍵の保有者に由来すること | ○ | ○ (注2) | △ (注1) | ○ (注2) | ○ | ○ (注2) |
| 非否認性 (Non-repudiation) | 後から作成を否定しにくいこと | ○ | × | × | × | ○ | × |
| 機密性 (Confidentiality) | 第三者に読まれないこと | × | × | ○ | ○ | ○ | ○ |
- (注1) JWE (非対称鍵): 受信者の公開鍵で暗号化するため、特定の受信者のみが復号できることは保証しますが、公開鍵は一般に公開されているため、誰でもその暗号文を作成できてしまいます。したがって、これ単体では送信者の特定(真正性)には寄与しません。
- (注2) 二者間における真正性: 共通鍵(MACや共通鍵暗号)を使用する場合、その鍵を知っているのは送信者と受信者の2人のみという前提において、受信者は「自分が作っていない以上、相手が作ったものである」と論理的に確信できます。ただし、受信者も同じ鍵を持っていて偽造が可能であるため、第三者に対して「相手が作った」と客観的に証明することはできません。
Keycloak で JWE を有効にする
Section titled “Keycloak で JWE を有効にする”認可コードフローに JWE の暗号化を加えると、やり取りは次のようになります。
暗号化対象を ID トークンに絞る
Section titled “暗号化対象を ID トークンに絞る”今回は Keycloak で ID トークンを JWE にします。Access Token の暗号化は現在の Keycloak の標準機能では扱えないためです。
参考:https://github.com/keycloak/keycloak/discussions/9446
ID トークンを暗号化する場合、認可コードフローでそのトークンを受け取るクライアントが復号に必要な秘密鍵を持つ必要があります。今回の構成では、ブラウザの後ろにいる Go の API サーバがその受信者です。
復号用の鍵を用意する
Section titled “復号用の鍵を用意する”公開鍵を JWKS として公開する
Section titled “公開鍵を JWKS として公開する”まず、API サーバ側で公開鍵を JWK 形式にして返せるようにします。Use: "enc" で暗号化用途の鍵であることを示し、Alg: "RSA-OAEP" で Keycloak に使わせたい鍵管理アルゴリズムを揃えます。
type jwk struct { Kty string `json:"kty"` Use string `json:"use,omitempty"` Kid string `json:"kid,omitempty"` Alg string `json:"alg,omitempty"` N string `json:"n"` E string `json:"e"`}
func clientPublicJWKFromPEM() (*jwk, error) { pub, err := jwt.ParseRSAPublicKeyFromPEM(embeddedPublicKey) if err != nil { return nil, fmt.Errorf("parse public key: %w", err) } return &jwk{ Kty: "RSA", Use: "enc", Kid: clientKeyID, Alg: "RSA-OAEP", N: base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), }, nil}次に、その公開鍵を JWKS エンドポイントとして公開します。
func (s *server) handleClientJWKS(w http.ResponseWriter, r *http.Request) { key, err := clientPublicJWKFromPEM() if err != nil { // ... }
resp := struct { Keys []*jwk `json:"keys"` }{ Keys: []*jwk{key}, }
w.Header().Set("Content-Type", "application/jwk-set+json") // ... json.NewEncoder(w).Encode(resp)}この構成にしておくと、秘密鍵は API サーバの外に出さずに済みます。Keycloak には公開鍵だけを渡し、暗号化済みの ID トークンを受け取った API サーバが手元の秘密鍵で復号します。
Keycloak に暗号化設定を入れる
Section titled “Keycloak に暗号化設定を入れる”ID token encryption key management algorithmが JWE のalgに対応するID token encryption content encryption algorithmが JWE のencに対応する
今回の設定では、alg に RSA-OAEP、enc に A256GCM を選びました。RSA-OAEP は CEK を受信者へ渡すための鍵管理アルゴリズムで、A256GCM はその CEK を使って本文を暗号化する AEAD アルゴリズムです。

あわせて、Keycloak に API サーバの JWKS の URL を知らせます。今回の検証では、Go 側で GET /jwks を用意し、その URL をクライアント設定に登録しました。

Go クライアントで ID トークンを受け取る
Section titled “Go クライアントで ID トークンを受け取る”/callback では、まず Token Endpoint から返ってきた id_token をそのまま受け取り、その後に JWE なら復号、JWS ならそのまま通すようにしています。
tr, err := exchangeCode( s.discovery.TokenEndpoint, s.config.clientID, s.config.clientSecret, code, s.config.redirectURL, verifier,)if err != nil { // ...}
fmt.Printf("ID Token (JWE): %+v\n", tr.IDToken)normalizedIDToken, err := normalizeIDToken(tr.IDToken)if err != nil { // ...}
fmt.Printf("ID Token (JWS): %+v\n", normalizedIDToken)if _, err := verifyIDToken(normalizedIDToken, s.jwks, s.config.clientID, s.discovery.Issuer, nonce); err != nil { // ...}この normalizeIDToken は、受け取ったトークンのセグメント数を見て、3 分割なら JWS、5 分割なら JWE と判定して復号に進みます。
func normalizeIDToken(raw string) (string, error) { parts := strings.Split(raw, ".") switch len(parts) { case 3: return raw, nil case 5: return decryptCompactJWE(raw) default: return "", fmt.Errorf("unexpected token format: got %d parts", len(parts)) }}Keycloak のログイン画面から入力を完了するとサーバーのログから以下のような出力を得られます。
ID Token (JWE): eyJhbGciOiJ....omSWkwAID Token (JWS): eyJhbGciOiJ....rhfBdEgトークンを発行して JWE になっていることを確認する
Section titled “トークンを発行して JWE になっていることを確認する”Protected Header を確認する
Section titled “Protected Header を確認する”Go のクライアントで受け取った ID トークンを . で分割し、1 つ目のセグメントである Protected Header を確認します。ここでは alg が RSA-OAEP、enc が A256GCM、cty が JWT になっていることから、Keycloak の設定値どおりに Nested JWT が返ってきていることがわかります。
一方で、4 つ目のセグメントである Ciphertext は JSON ではなく暗号化済みのオクテット列です。そのため、Header と同じ要領で fromjson しても読めません。
$ ID_TOKEN_JWE=eyJhbGciOiJ....omSWkwA$ jq -R 'split(".") | select(length > 0) | .[0],.[3] | @base64d | fromjson' <<< $ID_TOKEN_JWE{ "alg": "RSA-OAEP", "enc": "A256GCM", "cty": "JWT", "kid": "client-key-hoge"}jq: error (at <stdin>:1): string ("ccFcHo0z4J...) is not valid base64 dataこの時点で、復号前に人間がそのまま確認できるのは Protected Header までです。Keycloak 側で設定した alg、enc、kid がここに載るため、まずこの部分を見れば どの鍵管理アルゴリズムとコンテンツ暗号化アルゴリズムで JWE が作られたか を確認できます。
Keycloak の ID トークンを復号して claim を確認する
Section titled “Keycloak の ID トークンを復号して claim を確認する”Go のコードで JWE を復号する
Section titled “Go のコードで JWE を復号する”受け取った ID トークンは normalizeIDToken から decryptCompactJWE に進みます。ここでは Protected Header を読み取り、alg に応じて CEK を復号し、その後 enc に従って ciphertext を復号します。
func decryptCompactJWE(raw string) (string, error) { parts := strings.Split(raw, ".") // ...
var header jweHeader if err := json.Unmarshal(protectedJSON, &header); err != nil { return "", fmt.Errorf("parse protected header: %w", err) }
cek, err := decryptContentEncryptionKey(header.Alg, parts[1]) if err != nil { return "", err }
// ...
plaintext, err := aead.Open(nil, iv, combined, []byte(parts[0])) if err != nil { return "", fmt.Errorf("decrypt JWE ciphertext: %w", err) }
return string(plaintext), nil}今回の設定では alg が RSA-OAEP なので、CEK の復号には API サーバが持っている RSA 秘密鍵を使います。enc が A256GCM であることから、この後の本文復号に AES-GCM を使うことがわかります。一方で、復号後の平文が JWS 形式の ID トークンであることは cty: "JWT" と、実際に復号した結果が 3 セグメントの JWT になっていることから判断できます。
復号後の JWS と claim を確認する
Section titled “復号後の JWS と claim を確認する”復号した後の JWS をデコードすると、Header と payload を通常の JWT と同じように読めます。ここでは alg が RS256 に変わっており、payload に iss、sub、aud、nonce などの claim が入っていることが確認できました。
$ ID_TOKEN_JWS=eyJhbGciOiJ....rhfBdEg$ jq -R 'split(".") | select(length > 0) | .[0],.[1] | @base64d | fromjson' <<< $ID_TOKEN_JWS{ "alg": "RS256", "typ": "JWT", "kid": "OQBagsEzCj2UTkmONUmvVdQZklRWRapmomaDTPmwQ5I"}{ "exp": 1776350935, "iat": 1776350635, "auth_time": 1776350471, "jti": "d8250b7d-b3dd-3889-c590-e47395c47b21", "iss": "http://localhost:8080/realms/myrealm", "aud": "myapp", "sub": "a2936516-1326-40ca-bdac-72ba1fb0072a", "typ": "ID", "azp": "myapp", "nonce": "yaBmuQHncMgl4vqHl5-wNROKGz9-8Vo2HIg3oE_dBzU", "sid": "Ygz48_07b7RsTCpjn3dtPI7m", "at_hash": "zm2kYoLddv-lQn3ECkLT1g", "acr": "0", "email_verified": false, "name": "foo bar", "preferred_username": "myuser", "given_name": "foo", "family_name": "bar", "email": "myuser@exmple.com"}JWE の外側では alg: RSA-OAEP と enc: A256GCM が見えていましたが、復号後の内側は RS256 で署名された ID トークンです。つまり、Keycloak は 署名済みの ID トークンを JWE で包んで返している ことがここでも確認できます。
補足: Better Auth の Cookie でも JWE を確認する
Section titled “補足: Better Auth の Cookie でも JWE を確認する”Better Auth で Cookie のセッションを JWE にする
Section titled “Better Auth で Cookie のセッションを JWE にする”Better Auth では Cookie を使用してセッション管理をする場合、セッションデータを JWE にできます。
pnpm create next-app keycloak-login --ts --app --turbopack --import-alias "@/*" --tailwind --src-dir --no-eslint --use-pnpmpnpm add better-authcallback URL = ${baseURL}/api/auth/oauth2/callback/:providerId 今回の場合 = localhost:3000/api/auth/oauth2/callback/keycloak
参考:https://better-auth.com/docs/plugins/generic-oauth#handle-oauth-callback
コードリーディング
Section titled “コードリーディング”実際にコードリーディングをすると以下のように JWE を組み立てていることがわかります。
export async function setCookieCache( // ...) { // ...
const sessionData = { session: filteredSession, user: filteredUser, updatedAt: Date.now(), version, };
// ...
if (strategy === "jwe") { // Use JWE strategy (JSON Web Encryption) with A256CBC-HS512 + HKDF data = await symmetricEncodeJWT( sessionData, ctx.context.secretConfig, "better-auth-session", options.maxAge || 60 * 5, ); } else if (strategy === "jwt") {
// ...https://github.com/better-auth/better-auth/blob/v1.6.2/packages/better-auth/src/cookies/index.ts
alg に “dir” を使っていることから Key Management Mode は Direct Encryption であることがわかります。
const alg = "dir";const enc = "A256CBC-HS512";
// ...
export async function symmetricEncodeJWT<T extends Record<string, any>>( // ...): Promise<string> { const currentSecret = getCurrentSecret(secret); const encryptionSecret = deriveEncryptionSecret(currentSecret, salt);
// ...
return await new EncryptJWT(payload) .setProtectedHeader({ alg, enc, kid: thumbprint }) .setIssuedAt() .setExpirationTime(now() + expiresIn) .setJti(crypto.randomUUID()) .encrypt(encryptionSecret);}https://github.com/better-auth/better-auth/blob/v1.6.2/packages/better-auth/src/crypto/jwt.ts
Protected Header をデコードする
Section titled “Protected Header をデコードする”ブラウザから Cookie の値を確認します。

better-auth.session_data の中身を見てみます。5つのセグメントのうち2番目の Encrypted Key が空です。つまり、Key Management Mode が Direct Encryption または Direct Key Agreement のどちらかであることがわかります。
# ↓ここが空eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIiwia2lkIjoiQ1NRdUFlOWQ5dHE0UUc4THpnVTJJTG1OYTI2QTZFNWlha3ZtOFBIV3VPWSJ9..-grBhCnEo1eTwyI-jtoiEA.8xxxxxxxxxN.h4tBtWDKjhdiQCCAQZuxEi10P5Tx3LJCXH_kOaEzsDIProtected Header は暗号化されていないので、デコードして中身を確認すると、alg が dir であることから Key Management Mode は Direct Encryption です。
echo eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIiwia2lkIjoiRGJPdWFkVkRjQTBvT280akRmY0FwRU1uSF8xcXFDM1ZNWlBBcXVuX295USJ9 | jq -R '@base64d | fromjson'{ "alg": "dir", "enc": "A256CBC-HS512", "kid": "DbOuadVDcA0oOo4jDfcApEMnH_1qqC3VMZPAqun_oyQ"}実装時に気をつけたい点
Section titled “実装時に気をつけたい点”実装で特に気をつけたいのは、復号失敗時のエラーの出し方です。RFC 7516 では、JWE の受信者が攻撃者に暗号文を何度も復号させる踏み台(復号オラクル)として利用されないよう注意する必要があるとされています。たとえば alg の違い、CEK の形式不正、AAD の不整合、ciphertext や Authentication Tag の検証失敗を、それぞれ別のエラーや別の応答時間で返してしまうと、攻撃者に内部状態の手がかりを与えてしまいます。
そのため、JWE の復号処理では どこで失敗したかを外部から区別できない ように実装しなければいけません。具体的には、フォーマットエラー、パディングエラー、長さエラー、認証タグ検証失敗を細かく出し分けず、失敗時は同じ扱いで拒否します。RFC 7516 では timing attack を和らげるために、不正な鍵形式を受け取った場合でもランダムな CEK を使って処理を継続することが強く推奨されています。
あわせて、鍵ごとに許可するアルゴリズムを絞ることも重要です。RFC 7516 では、たとえば RSA-OAEP 用の鍵に対して RSA1_5 を試せないようにしておくことで、chosen-ciphertext attack の余地を減らせると説明されています。実装では、alg や enc をトークンの値だけで鵜呑みにせず、この鍵で受け入れるアルゴリズムは何か をあらかじめ固定して照合する方が安全です。
When decrypting, particular care must be taken not to allow the JWE recipient to be used as an oracle for decrypting messages.
訳:復号の際、JWE 受信者がメッセージを復号するためのオラクルとして利用されないよう、細心の注意を払う必要があります。
To mitigate the attacks described in RFC 3218 [RFC3218], the recipient MUST NOT distinguish between format, padding, and length errors of encrypted keys.
訳:RFC 3218 [RFC3218] で説明されている攻撃を軽減するために、受信者は暗号化された鍵のフォーマット、パディング、および長さのエラーを区別してはなりません (MUST NOT)。
It is strongly recommended, in the event of receiving an improperly formatted key, that the recipient substitute a randomly generated CEK and proceed to the next step, to mitigate timing attacks.
訳:タイミング攻撃を軽減するために、不適切にフォーマットされた鍵を受信した場合には、受信者がランダムに生成された CEK で代用して次のステップに進むことが強く推奨されます
参考:
- https://datatracker.ietf.org/doc/html/rfc7516#section-11.4
- https://datatracker.ietf.org/doc/html/rfc7516#section-11.5
結論 / 学んだこと
Section titled “結論 / 学んだこと”- JWE は署名の仕組みではなく、ペイロードの機密性を提供する仕組みです
- JWS と JWE は役割が異なり、作成者検証まで必要な場合は Nested JWT として整理すると理解しやすいです
- Keycloak で ID トークンを JWE にするには、クライアント側が復号用の秘密鍵を持ち、対応する公開鍵を JWKS として公開しておく必要があります
- Keycloak の
ID token encryption key management algorithmとID token encryption content encryption algorithmが、それぞれ JWE のalgとencに対応していました - 発行されたトークンの Protected Header では
alg: RSA-OAEP、enc: A256GCM、cty: JWT、kidが確認でき、復号後の内側はRS256で署名された ID トークンでした