# AWS Cognito の JWT(RS256) を正しく検証する：JWKS・kid・token_use の落とし穴と本番実装

> AWS CognitoのJWT(RS256)をバックエンドで正しく検証する実装ガイド。JWKS取得とkidマッチング、RS256署名検証、iss/aud/exp/token_useの検証、JWKSキャッシュと定期更新、API Gatewayオーソライザーとの二層検証、alg=noneやtoken_use未検証などの落とし穴を実コード（Python/TypeScript）で解説します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: AWS, Cognito, JWT, セキュリティ, Python
- URL: https://tomodahinata.com/blog/aws-cognito-jwt-rs256-verification-jwks-security-guide

## 要点

- JWTは署名された主張にすぎず、署名検証を通して初めてclaimsを信用してよい
- 検証は署名（RS256/kid）・iss・aud/client_id・exp/iat・token_useの5本柱を全て満たす
- algはRS256にホワイトリスト固定し、トークンheaderのalgを信用せずalg=none・混同攻撃を封じる
- IDとアクセストークンは目的も署名鍵も別なので、token_useを固定して独立に検証する
- JWKSはキャッシュ＋定期更新＋未知kidで即時リフレッシュ、本番はaws-jwt-verifyに寄せ、迷ったらfail-closed

---

「Cognito でログインしているから安全」——この一文には、実は大きな飛躍があります。**Cognito がトークンを発行することと、あなたのバックエンドがそのトークンを正しく検証することは、まったく別の話**だからです。

JWT は AWS 公式の言葉を借りれば「簡単にデコードでき、読め、そして**改ざんできる**」ものです。検証を怠れば、改ざんされたアクセストークンは権限昇格を、改ざんされた ID トークンはなりすましを招きます。**署名を検証して初めて、トークンの中身（claims）を信用してよい**——これが本記事の出発点です。

この記事は、Cognito が発行する JWT(RS256) を**バックエンドで正しく検証する実装**に焦点を当てます。題材として、私が構築した招待制 B2B SaaS（7業種＋閲覧/管理ロール、[経済産業大臣賞を受賞した木材流通DX](/case-studies/lumber-industry-dx)）で、**API Gateway のオーソライザーと合わせて全 221 エンドポイントを保護し、第三者ペネトレーションテストで認証欠落 0 件を実証した**設計判断も交えます。

> **この記事のルール**：仕様の出典は **AWS Cognito 公式ドキュメント（2026年6月時点）** です。claim 名・JWKS URL・検証手順はすべて公式に基づきます。仕様は改定され得るため、本番投入前に必ず[公式の「Verifying JSON web tokens」](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html)で最新を確認してください。そして大原則：**トークンは「署名を検証して初めて」信用できる**。署名検証前の claims は、ユーザーが書き換え可能な「ただの入力」です。

---

## 0. メンタルモデル：JWT は「署名された主張」である

設計を始める前に、たった一つの正しいモデルを共有させてください。

**JWT は「署名された主張（signed claims）」です。** トークンの中身（`sub` は誰、`token_use` は何、`exp` はいつまで）は、発行者が「こう主張している」というデータに過ぎません。その主張を**信用してよいかどうか**を決めるのが署名検証です。

Cognito の JWT は `.`（ドット）で区切られた3つのセクションで構成されます（公式）。

- **Header**：鍵ID `kid` と署名アルゴリズム `alg`。Cognito は `alg` に **`RS256`**（RSA + SHA-256）を使う。
- **Payload**：claims 本体。ID トークンならユーザー属性と `iss`・`aud`、アクセストークンなら scope・`iss`・`client_id` など。
- **Signature**：header と payload から計算された RSA256 の署名。JWKS URI で公開される公開鍵で検証する。

公式は明確にこう述べています。「悪意あるユーザーはトークンを改ざんできるが、アプリが公開鍵を取得して署名を比較すれば、改ざんされた署名は一致しない」。だから——**OIDC 認証由来の JWT を処理するアプリは、サインインのたびにこの検証操作を必ず実行しなければならない。**

検証とは、具体的には次の5つを満たすことです。本記事はこの5本柱を一つずつ実装していきます。

| # | 検証する対象 | 何を確認するか | 怠ると |
| --- | --- | --- | --- |
| 1 | **署名（RS256 / JWKS の kid）** | header の `kid` で公開鍵を選び、RS256 で署名を検証 | 改ざんされたトークンを受理（権限昇格・なりすまし） |
| 2 | **発行者 `iss`** | 自分の User Pool の issuer と完全一致するか | 別 Pool・別テナントのトークンを受理 |
| 3 | **対象 `aud` / `client_id`** | 自分の App Client ID と一致するか | 別アプリ向けのトークンを流用される |
| 4 | **有効期限 `exp` / `iat`** | 期限切れでないか（clock skew を考慮） | 失効済みトークンを受理 |
| 5 | **`token_use`** | 受け取るべき種別（`id` or `access`）と一致するか | ID と Access の取り違え（後述） |

---

## 1. 二種類のトークン：ID トークンとアクセストークンは別物

「Cognito のトークン」と一括りにすると、ここで必ず詰まります。Cognito は認証成功時に **ID トークン・アクセストークン・リフレッシュトークン**の3つを返します（リフレッシュトークンは暗号化された不透明文字列で、検証の対象外）。検証対象になるのは ID トークンとアクセストークンの2つで、**目的も claims も署名鍵も別**です。

公式の記述を整理します。

| | **ID トークン** | **アクセストークン** |
| --- | --- | --- |
| 目的（公式） | ユーザーの**身元（identity）**を表す。`name`・`email`・`phone_number` などの属性 claim | **API 操作の認可**。scope に基づくリソースアクセス |
| `token_use` の値 | **`id`** | **`access`** |
| 対象を表す claim | **`aud`**（認証した App Client） | **`client_id`**（同じ値が ID トークンの `aud` に入る） |
| 主な claim | `sub`・`email`・`cognito:groups`・`aud`・`iss`・`exp`・`iat`・`auth_time` | `sub`・`scope`・`cognito:groups`・`client_id`・`username`・`iss`・`exp`・`iat` |
| 署名鍵 | ID トークン専用の鍵 | アクセストークン専用の**別の鍵** |

ここで**実装上きわめて重要な公式の注意点**があります。

> Cognito は User Pool ごとに RSA 鍵ペアを**2組**生成する。一方が**アクセストークン**を、もう一方が **ID トークン**を署名する。**同じユーザーセッションでも、アクセストークンの `kid` と ID トークンの `kid` は一致しない。** コード内では ID トークンとアクセストークンを**独立して検証**すること。

つまり「ID もアクセスも同じ検証器でまとめて通す」は誤りです。検証器は**トークン種別ごとに分け**、期待する `token_use` を固定します。

### どちらを検証するべきか

| シナリオ | 検証すべきトークン | 理由 |
| --- | --- | --- |
| OAuth 2.0 scope でリソース API を認可したい | **アクセストークン** | scope は**アクセストークンにのみ**入る。API 認可はアクセストークンの本来の目的（公式） |
| API Gateway の JWT オーソライザーや Cognito の self-service 操作 | **アクセストークン** | API Gateway はアクセストークンでの認可をサポート（公式） |
| 自前バックエンドでユーザー属性（email 等）を直接使いたい | **ID トークン** | 属性 claim は ID トークンに入る |

**OAuth 2.0 の原則では「API 認可＝アクセストークン」が王道**です。一方で本記事の題材である木材流通 DX のプロジェクトは、**ID トークンを採用**し、`token_use == id` 以外を拒否しました。招待制で App Client が限定された閉じた B2B 環境であり、認可の判断にユーザー属性（業種・ロール）を直接使う設計だったため、ID トークンが要件に素直に合致したからです。

**重要なのは「どちらが正解か」ではなく、「実際に使うトークン種別と `token_use` の検証を一致させる」こと**です。アクセストークンを受け取るのに `token_use == id` を要求したら、正規のトークンが全部弾かれます。逆に、ID トークンを受け取る API で `token_use` を検証しなければ、scope を持たない ID トークンを「認可済み」と誤認しかねません。

---

## 2. JWKS：公開鍵をどこから取得するか

署名検証に必要な公開鍵は、User Pool の **JWKS（JSON Web Key Set）エンドポイント**から取得します。URL は公式で次の形式と定められています。

```
https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json
```

返ってくる JSON は、複数の公開鍵（JWK）の配列です。公式のサンプルを引用します。

```json
{
  "keys": [
    {
      "kid": "1234example=",
      "alg": "RS256",
      "kty": "RSA",
      "e": "AQAB",
      "n": "1234567890",
      "use": "sig"
    },
    {
      "kid": "5678example=",
      "alg": "RS256",
      "kty": "RSA",
      "e": "AQAB",
      "n": "987654321",
      "use": "sig"
    }
  ]
}
```

各フィールドの意味（公式）：

- **`kid`**：JWS の署名に使われた鍵を示すヒント。トークン header の `kid` と突き合わせる鍵。
- **`alg`**：`RS256`（RSA + SHA-256）。
- **`kty`**：鍵の種類。ここでは `RSA`。
- **`e`** / **`n`**：RSA 公開鍵の指数（exponent）と法（modulus）。Base64urlUInt エンコード。
- **`use`**：鍵の用途。`sig`（署名検証用）。

**鍵が複数あるのは、ID トークン用とアクセストークン用で別鍵だから**（前章）であり、さらに**鍵ローテーション中は新旧の鍵が並ぶ**ためです。だから「最初の鍵を使う」ような実装は誤りで、**必ず `kid` で正しい鍵を選ぶ**必要があります。

---

## 3. 自前で検証する：Python での実装（5本柱を一つずつ）

ここからは「ライブラリに任せず、何が起きているかを理解する」ための実装です。本番では第6章の `aws-jwt-verify` を推奨しますが、**仕組みを理解しないまま使うと落とし穴を踏む**ので、まず手で組み立てます。

依存は `PyJWT`（`cryptography` 付き）と `requests` を想定します。

### 3.1 JWKS の取得と kid による鍵選択

```python
"""Cognito JWT を検証する最小実装（理解のため）。本番は aws-jwt-verify を推奨（第6章）。"""
from __future__ import annotations

import time
from dataclasses import dataclass

import jwt
import requests
from jwt.algorithms import RSAAlgorithm


@dataclass(frozen=True)
class CognitoConfig:
    region: str
    user_pool_id: str
    app_client_id: str
    expected_token_use: str  # "id" または "access"。受け取る種別に合わせて固定する

    @property
    def issuer(self) -> str:
        # iss はこの形式と完全一致しなければならない（公式）
        return f"https://cognito-idp.{self.region}.amazonaws.com/{self.user_pool_id}"

    @property
    def jwks_url(self) -> str:
        return f"{self.issuer}/.well-known/jwks.json"


def fetch_jwks(cfg: CognitoConfig) -> dict[str, dict]:
    """JWKS を取得し、kid をキーにした dict に変換する。"""
    resp = requests.get(cfg.jwks_url, timeout=5)
    resp.raise_for_status()  # 取得失敗を握り潰さない（fail-closed）
    return {key["kid"]: key for key in resp.json()["keys"]}
```

ポイントは `raise_for_status()` です。**JWKS の取得に失敗したら検証を続行してはいけません**（fail-closed）。後述する「JWKS 無検証取得」の落とし穴です。

### 3.2 kid マッチング → RS256 署名検証

公式の手順は明快です。「トークン header の `kid` と JWKS の `kid` を突き合わせ、一致する公開鍵で署名を検証する」。**ここで `alg` をトークン任せにせず、`RS256` に固定する**のが最重要のセキュリティ対策です（理由は第7章の `alg=none` / アルゴリズム混同攻撃）。

```python
def verify_token(token: str, cfg: CognitoConfig, jwks: dict[str, dict]) -> dict:
    """署名 → iss → aud/client_id → exp/iat → token_use の順で検証し、claims を返す。"""
    # (1) header だけを先に読む。ここではまだ claims を信用しない
    header = jwt.get_unverified_header(token)
    kid = header.get("kid")
    if kid is None or kid not in jwks:
        # kid が未知 → 鍵ローテーションの可能性。呼び出し側で JWKS を再取得して再試行する
        raise ValueError("unknown kid: JWKS の再取得が必要かもしれません")

    # (2) kid に対応する公開鍵を JWK から構築
    public_key = RSAAlgorithm.from_jwk(jwks[kid])

    # (3) 署名検証＋iss/exp/iat/aud をライブラリに検証させる
    claims = jwt.decode(
        token,
        key=public_key,
        algorithms=["RS256"],        # ★ alg をホワイトリスト固定。トークンの alg を信用しない
        issuer=cfg.issuer,           # iss を検証（不一致は例外）
        audience=cfg.app_client_id if cfg.expected_token_use == "id" else None,
        leeway=60,                   # clock skew（時計のズレ）を 60 秒許容
        options={
            "require": ["exp", "iat", "iss"],  # 必須 claim を強制
            "verify_signature": True,
            "verify_exp": True,
            "verify_iat": True,
        },
    )

    # (4) token_use を検証（PyJWT は知らない claim なので自前で）
    if claims.get("token_use") != cfg.expected_token_use:
        raise ValueError(
            f"token_use mismatch: expected {cfg.expected_token_use}, got {claims.get('token_use')!r}"
        )

    # (5) アクセストークンは aud ではなく client_id を自前で照合する
    if cfg.expected_token_use == "access":
        if claims.get("client_id") != cfg.app_client_id:
            raise ValueError("client_id mismatch")

    return claims  # ここまで通って初めて claims を信用してよい
```

PyJWT の `jwt.decode` に渡している項目が、そのまま公式の検証手順に対応しています。

- `algorithms=["RS256"]`：**最重要**。トークン header の `alg` を信用せず、こちらが許可した RS256 のみ受理。
- `issuer=cfg.issuer`：公式が言う「`iss` は User Pool と一致しなければならない」。
- `audience=...`：ID トークンは `aud`（PyJWT が検証可能）。アクセストークンは `aud` ではなく `client_id` なので、`audience` を渡さず (5) で自前照合。
- `leeway=60`：clock skew。サーバー間の時計ズレで正規トークンを誤って弾かないための許容秒数。
- `require=["exp", "iat", "iss"]`：これらの claim が**無い**トークンを拒否（claim の欠落＝検証スキップを防ぐ）。

そして公式の `token_use` 検証ルールを (4) で実装しています。

> `token_use` claim を確認する。アクセストークンのみ受理するならその値は `access` でなければならない。ID トークンのみ使うならその値は `id` でなければならない。両方使うなら `id` か `access` のいずれかでなければならない。（公式）

> **設計の要点（SRP）**：`fetch_jwks`（鍵の取得）と `verify_token`（検証）を分離しています。鍵取得は I/O・キャッシュの責務、検証は純粋な暗号・claim 照合の責務。混ぜると、テストもキャッシュ戦略も破綻します。

---

## 4. JWKS のキャッシュと定期更新：本番のシングルトン

第3章の `fetch_jwks` を**リクエストごとに呼ぶのは論外**です。JWKS エンドポイントへの外部 HTTP がボトルネックになり、レート制限や一時障害でログイン全体が落ちます（Single Point of Failure）。

一方、公式はこう釘を刺します。

> Cognito は User Pool の署名鍵をローテーションすることがある。ベストプラクティスとして、公開鍵を `kid` をキーにアプリ内にキャッシュし、**定期的にキャッシュを更新**せよ。受け取ったトークンの `kid` をキャッシュと比較し、**正しい issuer なのに未知の `kid` を受け取ったら、鍵がローテーションされた可能性があるので `jwks_uri` からキャッシュを更新**せよ。

つまり要件は2つ：**(a) キャッシュして外部 I/O を減らす**、**(b) ローテーションに追従する**。木材流通 DX では、これを**二重チェックロックのシングルトン**でキャッシュし、**6時間間隔で更新（起動時にプリウォーム）**する形で実装しました。これを一般化したのが次のコードです。

```python
"""JWKS を二重チェックロックでキャッシュし、定期更新＋未知kidで即時リフレッシュする。"""
import threading
import time


class JwksCache:
    """スレッドセーフな JWKS シングルトンキャッシュ。
    - 起動時プリウォームでコールドスタートの遅延を排除
    - refresh_interval ごとに自動更新（鍵ローテーション追従）
    - 未知 kid を引いたら即時リフレッシュ（公式の推奨どおり）
    """

    def __init__(self, cfg: CognitoConfig, refresh_interval: float = 6 * 3600) -> None:
        self._cfg = cfg
        self._refresh_interval = refresh_interval
        self._lock = threading.Lock()
        self._jwks: dict[str, dict] = {}
        self._fetched_at = 0.0

    def prewarm(self) -> None:
        """起動時に呼ぶ。最初のリクエストで JWKS 取得の遅延を負わせない。"""
        self._refresh()

    def _is_stale(self) -> bool:
        return (time.monotonic() - self._fetched_at) > self._refresh_interval

    def _refresh(self) -> None:
        jwks = fetch_jwks(self._cfg)  # 取得失敗は例外で伝播（fail-closed）
        self._jwks = jwks
        self._fetched_at = time.monotonic()

    def _ensure_fresh(self) -> None:
        if not self._is_stale():
            return
        # 二重チェックロック：ロック取得は鮮度が切れているときだけ
        with self._lock:
            if self._is_stale():  # 待っている間に他スレッドが更新済みかを再確認
                self._refresh()

    def get_key(self, kid: str) -> dict:
        self._ensure_fresh()
        if kid in self._jwks:
            return self._jwks[kid]
        # 正しい issuer なのに未知 kid → ローテーションの可能性。一度だけ強制更新して再試行
        with self._lock:
            if kid not in self._jwks:
                self._refresh()
        if kid not in self._jwks:
            raise ValueError(f"kid {kid!r} not found even after refresh")
        return self._jwks[kid]
```

二重チェックロック（double-checked locking）の意図は明確です。**鮮度が切れていない通常時はロックを取らず**（高速・並行）、切れたときだけロックを取り、しかも**ロック取得後にもう一度鮮度を確認**して、待っている間に他スレッドが更新済みなら二重取得を避けます。鍵ローテーション時の「未知 kid → 強制更新 → 再試行」も、公式の推奨フローそのものです。

> **コスト効率と信頼性の両立**：6時間間隔は「ローテーション追従の十分さ」と「外部 I/O の少なさ」のバランス点です。未知 kid での即時更新があるので、間隔を短くしすぎてエンドポイントを叩き続ける必要はありません（YAGNI）。プリウォームは「最初の一人だけ遅い」を消すための投資です。

---

## 5. 検証をどこで行うか：API Gateway オーソライザー × アプリ内検証の二層

「トークン検証はどこでやるべきか」は設計判断です。選択肢を整理します。

| 検証する場所 | 長所 | 短所 | 向くケース |
| --- | --- | --- | --- |
| **API Gateway オーソライザー**（JWT/Lambda） | 各サービスに到達する前に弾ける。共通化・運用が楽。署名・iss・aud・exp を一括検証 | プラットフォーム依存。きめ細かい認可（業種・ロール）はやりにくい | 全 API の一次防衛線 |
| **アプリ内検証** | scope・`cognito:groups`・業種ロールなど**ドメイン固有の認可**まで踏み込める | 各サービスに実装が必要（DRY 違反になりがち） | 細粒度の認可が必要な層 |
| **両方（二層）** | 一次防衛（GW）＋ドメイン認可（アプリ）で多層防御 | 実装コスト | **本番の B2B SaaS** |

木材流通 DX では**両方**を採用しました。**API Gateway のオーソライザーで全 221 エンドポイントを一次防衛**し（署名・`iss`・`aud`・`exp`・`token_use` を検証、`token_use == id` 以外を拒否）、**業種ベースの認可はルーター層**で `frozenset` のホワイトリスト照合により厳格化（不一致は 403、ID 列挙攻撃を抑止）しました。

この「二層」が効くのは、**責務が違う**からです。署名・発行者・有効期限の検証は「このトークンは本物か」という**認証**の問い。業種・ロールの照合は「この本物のユーザーはこの操作をしてよいか」という**認可**の問い。前者を GW で共通化し、後者をアプリのドメイン層に置くことで、認可ロジックの変更が GW 設定に波及しません（ETC：変更が局所化される）。

> **fail-open を fail-closed に**：セキュリティ監査では、Webhook や JWT 周りの「エラー時に通してしまう（fail-open）」挙動を、すべて**エラー時は拒否する（fail-closed）**に是正しました。検証コードで例外を握り潰して `return True` してしまう実装は、最悪のバグです。**迷ったら閉じる**——これがセキュリティの既定値です。

---

## 6. 推奨実装：aws-jwt-verify に任せる

ここまで手で組み立てましたが、**本番で推奨されるのは公式が名指しする [`aws-jwt-verify`](https://github.com/awslabs/aws-jwt-verify) を使うこと**です。公式ドキュメントの記述を引用します。

> Node.js アプリでは、AWS は `aws-jwt-verify` ライブラリを推奨する。`CognitoJwtVerifier` に検証したい claim 値を設定できる。チェックできるのは、アクセス/ID トークンが壊れていない・期限切れでない・有効な署名を持つこと、正しい User Pool と App Client から来たこと、正しい OAuth 2.0 scope を含むこと、署名鍵の `kid` が JWKS URI の鍵と一致すること、など。

TypeScript（このサイトと同じスタック）での推奨実装は次のとおりです。**検証器はハンドラの外で一度だけ作る**のが要点で、これで JWKS キャッシュが再利用されます（第4章を自前で書く必要がなくなる）。

```ts
import { CognitoJwtVerifier } from "aws-jwt-verify";

// ★ ハンドラの外で一度だけ生成 → JWKS キャッシュが再利用される
const verifier = CognitoJwtVerifier.create({
  userPoolId: process.env.COGNITO_USER_POOL_ID!, // iss と JWKS URL を自動導出
  tokenUse: "id",                                // token_use を固定（"id" or "access"）
  clientId: process.env.COGNITO_APP_CLIENT_ID!,  // id なら aud、access なら client_id を照合
});

export async function verify(token: string) {
  try {
    // 署名・kid・iss・aud/client_id・token_use・exp/nbf をまとめて検証
    const payload = await verifier.verify(token);
    return payload; // 検証済み claims
  } catch {
    // 失敗時は必ず拒否（fail-closed）。理由をログに残すなら PII は出さない
    throw new Error("invalid token");
  }
}
```

`CognitoJwtVerifier.create` の3つのパラメータが、本記事の5本柱に直結します（公式 README）。

- **`userPoolId`**：issuer と JWKS エンドポイントを**自動導出**。`iss` 検証と鍵取得を任せられる。
- **`tokenUse`**：`token_use` claim が期待値（`"id"` / `"access"`）と一致するかを検証。
- **`clientId`**：ID トークンなら `aud`、アクセストークンなら `client_id` を照合。

ライブラリは**発行者ごとに JWKS をメモリキャッシュ**し、`kid` で鍵を選びます。JWKS は最初に一度だけ取得し、**未知の `kid`（＝鍵ローテーション）が現れたときだけ再取得**します——第4章で手作りしたロジックが、そのまま標準で備わっています。

> **なぜ自前実装より推奨されるか（DRY / 信頼性）**：公式 README は「`issuer` と `audience` のようなセキュリティ上重要な claim を**明示的に指定させる**設計にすることで、利用者が安全に使いやすくしている」と述べます。**検証ロジックは「車輪の再発明」をすると落とし穴を踏みやすい領域**です。第3章を理解した上で、本番は枯れたライブラリに寄せる——これが正解です。Python なら同等の `python-jose`＋自前検証や、トークン種別ごとの検証器を使います。

---

## 7. セキュリティの落とし穴（ここが本番の分かれ目）

検証コードは「書けば安全」ではありません。**有名な抜け穴を一つでも踏むと、検証全体が無意味**になります。

### 7.1 `alg=none` / アルゴリズム混同攻撃

最も古典的かつ致命的な攻撃です。攻撃者がトークン header の `alg` を `none` に書き換え、署名を空にして送る——**ライブラリがトークンの `alg` を信用すると、署名検証をスキップして通してしまう**。さらに `alg` を `HS256`（HMAC）に変えて、**公開鍵（誰でも知れる）を HMAC の秘密鍵として使わせる**アルゴリズム混同攻撃もあります。

**対策は一つ**：検証側で**アルゴリズムをホワイトリスト固定**すること。第3章の `algorithms=["RS256"]` がこれです。**トークン header の `alg` は絶対に信用しない**。Cognito は `RS256` しか使わない（公式）のだから、`RS256` 以外を受理する理由はありません。

### 7.2 `token_use` を検証しない

第1章のとおり、**ID とアクセスは目的も鍵も別**です。`token_use` を検証しないと、例えば「scope を必要とする API」にアクセストークンの代わりに ID トークン（scope を持たない）が通ってしまう、あるいはその逆が起きます。公式が `token_use` の検証を独立した手順として明記しているのは、これが**検証漏れの定番**だからです。**受け取る種別を `token_use` で固定**してください。

### 7.3 `aud` / `client_id` を検証しない

`iss`（発行者）だけ見て `aud` / `client_id` を見ないと、**同じ User Pool の別 App Client 向けに発行されたトークンを流用**されます。マルチクライアント環境では致命的です。ID トークンは `aud`、アクセストークンは `client_id`——**種別ごとに正しい claim を照合**します（公式：両者は同じ値を指す）。

### 7.4 JWKS を無検証・無防備に取得する

JWKS の取得は HTTPS（証明書検証あり）で行い、**取得失敗時は検証を中止（fail-closed）**します。取得失敗を握り潰して「鍵が無いから検証スキップ」にすると、検証そのものが無効化されます。また、**未知 kid を引くたびに無制限に再取得**するとエンドポイントへの DoS を自分で誘発するので、再取得は「一度だけ・キャッシュ前提」に制限します（第4章）。

### 7.5 署名検証前に claims を使う

`jwt.get_unverified_header` で header を読むのは鍵選択のためだけ。**payload の claims（`sub`・`email`・`cognito:groups` 等）を、署名検証が完了する前に使ってはいけません。** 「ログ出力のために先に `sub` を読む」程度でも、**未検証の値を業務ロジックや認可判断に流す**入口になります。検証を通った返り値の claims のみを使う、という規律を徹底します。

### 7.6 失効・ローテーション

`exp` は「有効期限」であって「失効」ではありません。ユーザーを即時に締め出したいケース（退会・権限剥奪・漏洩）では、`exp` だけでは足りません。Cognito はトークン失効（revocation）に対応しており、リフレッシュトークンを revoke すると、**同じ `origin_jti` を持つアクセス/ID トークンが無効化**されます（公式）。即時失効が要件なら、`origin_jti` / `jti` を使った失効チェックや、短い `exp`（Cognito は 5分〜1日で設定可能）と組み合わせます。

### 7.7 clock skew（時計のズレ）

サーバー間の時計は必ず多少ズレます。`exp` / `iat` を厳密に秒単位で比較すると、**正規のトークンを「期限切れ」「未来発行」として誤って弾く**事故が起きます。第3章の `leeway=60` のように、**数十秒〜数分の許容（leeway）**を入れます。ただし大きくしすぎると失効が遅れるので、**最小限**にとどめます。

---

## 8. 役割分担：この記事は「検証の実装」、設計・SSO は別記事

混乱を避けるため、関連記事との役割を明確にしておきます。

- **認証の設計（複雑な認可要件の組み立て方）**は [複雑な認証要件のCognito設計](/blog/aws-cognito-complex-authentication-design) で扱います。グループ・ロール・テナント分離の**設計**の話です。
- **エンタープライズ SSO（SAML / OIDC 連携）**は [SAML/OIDCエンタープライズSSO](/blog/aws-cognito-saml-oidc-enterprise-sso) で扱います。外部 IdP との**フェデレーション設計**の話です。
- **本記事**は、それらで発行された **JWT をバックエンドが正しく検証する「実装とセキュリティ」**に特化しています。

設計・SSO はそちらへ、トークン検証の実装はこちらへ——重複なく補完し合う構成です。

---

## 9. まとめ：検証チートシート

迷ったときの早見表です。

- **5本柱を全部やる**：① 署名（RS256・JWKS の `kid`）② `iss` ③ `aud` / `client_id` ④ `exp` / `iat`（+ clock skew）⑤ `token_use`。どれか一つでも欠けたら検証は不完全。
- **`alg` は `RS256` にホワイトリスト固定**：トークン header の `alg` を絶対に信用しない（`alg=none`・混同攻撃対策）。
- **トークン種別を取り違えない**：受け取る種別と `token_use` を一致させる。ID は `aud`、Access は `client_id`。鍵も別なので**独立して検証**。
- **JWKS はキャッシュ＋定期更新＋未知 kid で即時リフレッシュ**：起動時プリウォーム、6時間間隔が一つの実用解。取得失敗は fail-closed。
- **検証はライブラリに寄せる**：本番は `aws-jwt-verify`（公式推奨）。検証器はハンドラ外で一度だけ生成し、JWKS キャッシュを再利用。
- **どこで検証するか**：API Gateway オーソライザーで一次防衛、ドメイン認可（業種・ロール・scope）はアプリ層へ。二層で多層防御。
- **迷ったら閉じる**：例外・取得失敗・claim 欠落はすべて拒否（fail-closed）。

JWT 検証は「署名された主張を、署名を確かめてから信用する」という一行の原則に集約されます。けれど本番では、`kid` の取り違え、`token_use` の見落とし、`alg` の固定漏れ、JWKS のキャッシュ戦略、fail-open の罠——**一つでも踏むと検証全体が崩れる**繊細な領域です。

私は招待制 B2B SaaS（経済産業大臣賞を受賞した木材流通 DX）で、**Cognito の RS256 + JWKS を基盤に、API Gateway オーソライザーと合わせて全 221 エンドポイントを保護**し、`token_use` の固定・JWKS のシングルトンキャッシュ・fail-open の fail-closed 化までを設計・実装しました。その結果、**第三者ペネトレーションテスト（実在 15 ロール）で全 221 エンドポイントの認証欠落 0 件**を実証しています。これらを、**一人 × 生成AI（Claude Code）で、速く・安全に**作り切りました。

**「自社の Cognito 認証、本当に正しく検証できていますか？」**——トークン検証の設計レビューから API 全体の認可設計、ペネトレ指摘の是正まで、一気通貫で伴走できます。お気軽にご相談ください。

---

### 参考（公式ドキュメント）

- [Verifying JSON web tokens（Amazon Cognito）](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html) — 検証手順・JWKS URL・`kid`・`token_use` の正規ルール
- [Understanding user pool JSON web tokens (JWTs)](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-with-identity-providers.html) — ID/アクセストークンの位置づけ・claims・refresh/revoke
- [Understanding the identity (ID) token](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-id-token.html) — ID トークンの claim 一覧（`token_use: id`・`aud`・`iss` ほか）
- [Understanding the access token](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-access-token.html) — アクセストークンの claim 一覧（`token_use: access`・`client_id`・`scope` ほか）
- [aws-jwt-verify（GitHub・AWS Labs）](https://github.com/awslabs/aws-jwt-verify) — AWS 公式推奨の JWT 検証ライブラリ・`CognitoJwtVerifier` API・JWKS キャッシュ
