# Correctly Verifying AWS Cognito's JWT (RS256): The Pitfalls of JWKS, kid, and token_use, and a Production Implementation

> An implementation guide to correctly verifying AWS Cognito's JWT (RS256) in the backend. We explain — in real code (Python/TypeScript) — JWKS fetching and kid matching, RS256 signature verification, verification of iss/aud/exp/token_use, JWKS caching and periodic refresh, two-layer verification with the API Gateway authorizer, and pitfalls like alg=none and not verifying token_use.

- Published: 2026-06-24
- Author: 友田 陽大
- Tags: AWS, Cognito, JWT, セキュリティ, Python
- URL: https://tomodahinata.com/en/blog/aws-cognito-jwt-rs256-verification-jwks-security-guide
- Category: Authentication & authorization
- Pillar guide: https://tomodahinata.com/en/blog/auth-platform-selection-2026-cognito-auth0-clerk-supabase

## Key points

- A JWT is nothing but signed claims, and you may only trust the claims after passing signature verification
- Verification must satisfy all 5 pillars: signature (RS256/kid), iss, aud/client_id, exp/iat, and token_use
- Whitelist-pin alg to RS256, don't trust the token header's alg, and seal off alg=none and confusion attacks
- ID and access tokens differ in purpose and signing key, so pin token_use and verify them independently
- Cache JWKS + refresh periodically + immediately refresh on an unknown kid, lean on aws-jwt-verify in production, and when unsure, fail-closed

---

"It's safe because we log in with Cognito" — this one sentence actually contains a big leap. Because **Cognito issuing a token and your backend correctly verifying that token are completely separate matters.**

A JWT, to borrow AWS's official words, is something that "can be easily decoded, read, and **tampered with**." Neglect verification, and a tampered access token invites privilege escalation, and a tampered ID token invites impersonation. **You may trust the token's contents (claims) only after verifying the signature** — this is the starting point of this article.

This article focuses on **the implementation that correctly verifies, in the backend, the JWT (RS256) that Cognito issues.** As the subject matter, I'll mix in design decisions from an invite-only B2B SaaS I built (7 industries + viewer/manager roles, [a lumber-distribution DX that won the METI Minister's Award](/case-studies/lumber-industry-dx)), where I **protected all 221 endpoints together with the API Gateway authorizer and proved 0 missing-authorization findings in a third-party penetration test.**

> **The rules of this article**: the source of the spec is the **AWS Cognito official documentation (as of June 2026).** The claim names, JWKS URL, and verification procedure are all based on the official. Since specs can be revised, always check the latest at the [official "Verifying JSON web tokens"](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html) before going to production. And the grand principle: **a token can be trusted "only after verifying the signature."** Claims before signature verification are "mere input" rewritable by the user.

---

## 0. Mental model: a JWT is "signed claims"

Before starting the design, let me share just one correct model.

**A JWT is "signed claims."** The token's contents (`sub` is who, `token_use` is what, `exp` is until when) are merely data of "the issuer claims this." What decides **whether you may trust** that claim is signature verification.

Cognito's JWT consists of 3 sections separated by `.` (dots) (official).

- **Header**: the key ID `kid` and the signing algorithm `alg`. Cognito uses **`RS256`** (RSA + SHA-256) for `alg`.
- **Payload**: the claims body. For an ID token, user attributes and `iss`・`aud`; for an access token, scope, `iss`, `client_id`, etc.
- **Signature**: the RSA256 signature computed from the header and payload. Verified with the public key published at the JWKS URI.

The official clearly states this. "A malicious user can tamper with the token, but if the app fetches the public key and compares the signature, the tampered signature won't match." So — **an app that processes JWTs from OIDC authentication must always perform this verification operation on every sign-in.**

Verification, concretely, means satisfying the following 5. This article implements these 5 pillars one by one.

| # | What to verify | What to confirm | If neglected |
| --- | --- | --- | --- |
| 1 | **Signature (RS256 / JWKS's kid)** | Select the public key by the header's `kid`, and verify the signature with RS256 | Accept a tampered token (privilege escalation, impersonation) |
| 2 | **Issuer `iss`** | Does it exactly match your User Pool's issuer? | Accept a token from another Pool / another tenant |
| 3 | **Audience `aud` / `client_id`** | Does it match your App Client ID? | A token for another app gets diverted |
| 4 | **Expiry `exp` / `iat`** | Is it not expired (considering clock skew)? | Accept an expired token |
| 5 | **`token_use`** | Does it match the kind you should receive (`id` or `access`)? | Mix up ID and Access (discussed later) |

---

## 1. Two kinds of tokens: the ID token and the access token are different things

Lump them together as "Cognito's tokens" and you'll definitely get stuck here. On successful authentication, Cognito returns 3 tokens: **ID token, access token, and refresh token** (the refresh token is an encrypted opaque string, not subject to verification). The two subject to verification are the ID token and the access token, and they differ in **purpose, claims, and signing key.**

Let me organize the official descriptions.

| | **ID token** | **Access token** |
| --- | --- | --- |
| Purpose (official) | Represents the user's **identity.** Attribute claims like `name`・`email`・`phone_number` | **Authorization of API operations.** Resource access based on scope |
| Value of `token_use` | **`id`** | **`access`** |
| Claim representing the audience | **`aud`** (the App Client that authenticated) | **`client_id`** (the same value goes into the ID token's `aud`) |
| Main claims | `sub`・`email`・`cognito:groups`・`aud`・`iss`・`exp`・`iat`・`auth_time` | `sub`・`scope`・`cognito:groups`・`client_id`・`username`・`iss`・`exp`・`iat` |
| Signing key | A key dedicated to the ID token | A **different key** dedicated to the access token |

Here is an **extremely important official caveat for implementation.**

> Cognito generates **2 sets** of RSA key pairs per User Pool. One signs the **access token**, the other signs the **ID token.** **Even in the same user session, the access token's `kid` and the ID token's `kid` do not match.** In your code, **verify the ID token and the access token independently.**

In other words, "pass both ID and access through the same verifier together" is wrong. **Split the verifier per token kind** and pin the expected `token_use`.

### Which one should you verify?

| Scenario | Token to verify | Reason |
| --- | --- | --- |
| You want to authorize a resource API with OAuth 2.0 scope | **Access token** | Scope is only in the **access token.** API authorization is the access token's original purpose (official) |
| API Gateway's JWT authorizer or Cognito's self-service operations | **Access token** | API Gateway supports authorization with the access token (official) |
| You want to directly use user attributes (email, etc.) in your own backend | **ID token** | Attribute claims are in the ID token |

**By OAuth 2.0 principles, "API authorization = access token" is the royal road.** On the other hand, the lumber-distribution DX project that is this article's subject **adopted the ID token** and rejected anything other than `token_use == id`. Because it was a closed B2B environment, invite-only with limited App Clients, and the design used user attributes (industry, role) directly for authorization decisions, the ID token fit the requirements straightforwardly.

**What matters is not "which is correct" but "matching the verification of `token_use` to the token kind you actually use."** If you require `token_use == id` while receiving access tokens, all the legitimate tokens get rejected. Conversely, if you don't verify `token_use` on an API that receives ID tokens, you may mistake a scope-less ID token as "authorized."

---

## 2. JWKS: where to fetch the public key from

The public key needed for signature verification is fetched from the User Pool's **JWKS (JSON Web Key Set) endpoint.** The URL is defined by the official in the following format.

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

The returned JSON is an array of multiple public keys (JWKs). Let me quote the official sample.

```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"
    }
  ]
}
```

The meaning of each field (official):

- **`kid`**: a hint indicating the key used for the JWS's signature. The key to match against the token header's `kid`.
- **`alg`**: `RS256` (RSA + SHA-256).
- **`kty`**: the key type. Here, `RSA`.
- **`e`** / **`n`**: the RSA public key's exponent and modulus. Base64urlUInt encoded.
- **`use`**: the key's use. `sig` (for signature verification).

**There are multiple keys because the ID-token key and access-token key are different** (previous chapter), and further because **during key rotation, the old and new keys line up.** So an implementation like "use the first key" is wrong, and you **must select the correct key by `kid`.**

---

## 3. Verify it yourself: implementation in Python (the 5 pillars one by one)

From here is an implementation for "not leaving it to a library, but understanding what's happening." In production I recommend Chapter 6's `aws-jwt-verify`, but **using it without understanding the mechanism makes you step on pitfalls**, so first we assemble it by hand.

The dependencies assumed are `PyJWT` (with `cryptography`) and `requests`.

### 3.1 Fetching JWKS and selecting the key by 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"]}
```

The point is `raise_for_status()`. **If JWKS fetching fails, you must not continue verification** (fail-closed). It's the pitfall of "fetching JWKS without verification" described later.

### 3.2 kid matching → RS256 signature verification

The official procedure is clear. "Match the token header's `kid` against the JWKS's `kid`, and verify the signature with the matching public key." **Here, not leaving `alg` to the token, but pinning it to `RS256`** is the most important security countermeasure (the reason is Chapter 7's `alg=none` / algorithm-confusion attack).

```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 を信用してよい
```

The items passed to PyJWT's `jwt.decode` correspond directly to the official verification procedure.

- `algorithms=["RS256"]`: **most important.** Don't trust the token header's `alg`; accept only the RS256 we permit.
- `issuer=cfg.issuer`: the official's "`iss` must match the User Pool."
- `audience=...`: the ID token uses `aud` (PyJWT can verify it). The access token uses `client_id`, not `aud`, so we don't pass `audience` and match it ourselves in (5).
- `leeway=60`: clock skew. The allowance in seconds so we don't mistakenly reject legitimate tokens due to clock drift between servers.
- `require=["exp", "iat", "iss"]`: reject a token **without** these claims (preventing missing-claim = skipped verification).

And we implement the official `token_use` verification rule in (4).

> Check the `token_use` claim. If you accept only access tokens, its value must be `access`. If you use only ID tokens, its value must be `id`. If you use both, it must be either `id` or `access`. (official)

> **Design point (SRP)**: I separate `fetch_jwks` (key fetching) and `verify_token` (verification). Key fetching is the responsibility of I/O and caching; verification is the responsibility of pure cryptography and claim matching. Mixing them breaks both testing and the caching strategy.

---

## 4. JWKS caching and periodic refresh: a production singleton

**Calling Chapter 3's `fetch_jwks` per request is out of the question.** The external HTTP to the JWKS endpoint becomes a bottleneck, and rate limits or transient failures bring down login as a whole (a Single Point of Failure).

On the other hand, the official drives this nail in.

> Cognito may rotate the User Pool's signing keys. As a best practice, cache the public keys in your app keyed by `kid`, and **refresh the cache periodically.** Compare a received token's `kid` with the cache, and **if you receive an unknown `kid` despite a correct issuer, the key may have been rotated, so refresh the cache from `jwks_uri`.**

So the requirements are 2: **(a) cache to reduce external I/O**, **(b) follow rotation.** In the lumber-distribution DX, I implemented this by caching it with a **double-checked-locking singleton** and **refreshing at a 6-hour interval (pre-warming at startup).** Generalizing this is the following code.

```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]
```

The intent of double-checked locking is clear. **In the normal case where freshness hasn't expired, don't take the lock** (fast and concurrent), and only take the lock when it has, and moreover **re-check freshness after acquiring the lock** to avoid a double fetch if another thread updated it while waiting. The "unknown kid → forced refresh → retry" at key rotation is exactly the official's recommended flow.

> **Both cost efficiency and reliability**: the 6-hour interval is the balance point of "sufficiency of following rotation" and "fewness of external I/O." Because there's immediate refresh on an unknown kid, there's no need to shorten the interval too much and keep hitting the endpoint (YAGNI). Pre-warming is an investment to eliminate "only the first person is slow."

---

## 5. Where to verify: the two layers of API Gateway authorizer × in-app verification

"Where should token verification be done" is a design decision. Let me organize the options.

| Place to verify | Pros | Cons | Suited for |
| --- | --- | --- | --- |
| **API Gateway authorizer** (JWT/Lambda) | Can reject before reaching each service. Easy to standardize and operate. Verifies signature, iss, aud, exp together | Platform-dependent. Fine-grained authorization (industry, role) is hard | The first line of defense for all APIs |
| **In-app verification** | Can step into **domain-specific authorization** like scope, `cognito:groups`, industry role | Implementation needed in each service (tends to be a DRY violation) | The layer needing fine-grained authorization |
| **Both (two layers)** | First defense (GW) + domain authorization (app) for defense in depth | Implementation cost | **Production B2B SaaS** |

In the lumber-distribution DX, I adopted **both.** I **first-defended all 221 endpoints with the API Gateway authorizer** (verifying signature, `iss`, `aud`, `exp`, `token_use`, rejecting anything other than `token_use == id`), and **made industry-based authorization strict in the router layer** via `frozenset` whitelist matching (a mismatch is 403, suppressing ID enumeration attacks).

This "two layers" works because **the responsibilities differ.** Verification of signature, issuer, and expiry is the **authentication** question of "is this token genuine?" Matching of industry and role is the **authorization** question of "may this genuine user do this operation?" By standardizing the former at the GW and placing the latter in the app's domain layer, changes to the authorization logic don't ripple to the GW config (ETC: changes are localized).

> **Turning fail-open into fail-closed**: in the security audit, I corrected all the "let it through on error (fail-open)" behaviors around Webhooks and JWTs to **reject on error (fail-closed).** An implementation that swallows an exception in verification code and `return True`s is the worst kind of bug. **When unsure, close** — this is security's default value.

---

## 6. The recommended implementation: leave it to aws-jwt-verify

I've assembled it by hand up to here, but **what's recommended in production is using [`aws-jwt-verify`](https://github.com/awslabs/aws-jwt-verify), which the official names by name.** Let me quote the official documentation's description.

> For Node.js apps, AWS recommends the `aws-jwt-verify` library. You can set the claim values you want to verify on `CognitoJwtVerifier`. What it can check includes: that the access/ID token is intact, not expired, and has a valid signature; that it came from the correct User Pool and App Client; that it contains the correct OAuth 2.0 scope; that the signing key's `kid` matches a key at the JWKS URI; and so on.

The recommended implementation in TypeScript (the same stack as this site) is as follows. The point is to **create the verifier just once outside the handler**, and this reuses the JWKS cache (you no longer need to write Chapter 4 yourself).

```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");
  }
}
```

The 3 parameters of `CognitoJwtVerifier.create` connect directly to this article's 5 pillars (official README).

- **`userPoolId`**: **auto-derives** the issuer and the JWKS endpoint. You can leave `iss` verification and key fetching to it.
- **`tokenUse`**: verifies whether the `token_use` claim matches the expected value (`"id"` / `"access"`).
- **`clientId`**: matches `aud` for an ID token, `client_id` for an access token.

The library **memory-caches JWKS per issuer** and selects the key by `kid`. JWKS is fetched only once at first, and **re-fetched only when an unknown `kid` (= key rotation) appears** — the logic we hand-crafted in Chapter 4 is provided as standard, as-is.

> **Why it's recommended over a self-implementation (DRY / reliability)**: the official README states it "makes it safe to use easily by **forcing the caller to explicitly specify** security-critical claims like `issuer` and `audience`." **Verification logic is an area where reinventing the wheel makes you easily step on pitfalls.** Understand Chapter 3, and in production lean on a battle-tested library — this is the right answer. For Python, use an equivalent `python-jose` + self-verification, or a verifier per token kind.

---

## 7. Security pitfalls (this is the dividing line in production)

Verification code is not "safe if you write it." **Step on even one famous loophole, and the entire verification becomes meaningless.**

### 7.1 `alg=none` / algorithm-confusion attack

The most classic and fatal attack. The attacker rewrites the token header's `alg` to `none`, empties the signature, and sends it — **if the library trusts the token's `alg`, it skips signature verification and lets it through.** There's also the algorithm-confusion attack of changing `alg` to `HS256` (HMAC) and **making it use the public key (which anyone can know) as the HMAC secret.**

**The countermeasure is one**: **whitelist-pin the algorithm** on the verifying side. Chapter 3's `algorithms=["RS256"]` is this. **Never trust the token header's `alg`.** Since Cognito uses only `RS256` (official), there's no reason to accept anything other than `RS256`.

### 7.2 Not verifying `token_use`

As in Chapter 1, **ID and access differ in purpose and key.** If you don't verify `token_use`, then, for example, an ID token (which has no scope) passes instead of an access token on "an API that needs scope," or vice versa. The official explicitly notes `token_use` verification as an independent step because this is **a standard verification omission.** **Pin the kind you receive with `token_use`.**

### 7.3 Not verifying `aud` / `client_id`

If you look only at `iss` (issuer) and not at `aud` / `client_id`, **a token issued for another App Client of the same User Pool gets diverted.** In a multi-client environment this is fatal. The ID token uses `aud`, the access token uses `client_id` — **match the correct claim per kind** (official: the two point to the same value).

### 7.4 Fetching JWKS without verification or defenselessly

Fetch JWKS over HTTPS (with certificate verification), and **on a fetch failure, abort verification (fail-closed).** Swallowing a fetch failure and going "no key, so skip verification" disables verification itself. Also, **re-fetching without limit every time you hit an unknown kid** invites a self-induced DoS on the endpoint, so limit re-fetching to "once only, on a cache premise" (Chapter 4).

### 7.5 Using claims before signature verification

Reading the header with `jwt.get_unverified_header` is only for key selection. **You must not use the payload's claims (`sub`・`email`・`cognito:groups`, etc.) before signature verification completes.** Even just "reading `sub` first for logging" is an entrance that **flows an unverified value into business logic or an authorization decision.** Thoroughly enforce the discipline of using only the claims of the return value that passed verification.

### 7.6 Revocation and rotation

`exp` is "expiry," not "revocation." For cases where you want to immediately lock out a user (withdrawal, privilege revocation, leak), `exp` alone is insufficient. Cognito supports token revocation, and revoking a refresh token **invalidates the access/ID tokens with the same `origin_jti`** (official). If immediate revocation is a requirement, combine a revocation check using `origin_jti` / `jti` with a short `exp` (Cognito can be set from 5 minutes to 1 day).

### 7.7 Clock skew

Clocks between servers always drift somewhat. Comparing `exp` / `iat` strictly to the second causes the accident of **mistakenly rejecting legitimate tokens as "expired" or "future-issued."** Like Chapter 3's `leeway=60`, put in **an allowance (leeway) of tens of seconds to a few minutes.** But making it too large delays revocation, so keep it to a **minimum.**

---

## 8. Division of roles: this article is "the verification implementation," design & SSO are separate articles

To avoid confusion, let me clarify the roles relative to related articles.

- **The design of authentication (how to assemble complex authorization requirements)** is handled in [Cognito Design for Complex Authentication Requirements](/blog/aws-cognito-complex-authentication-design). It's the story of the **design** of groups, roles, and tenant isolation.
- **Enterprise SSO (SAML / OIDC federation)** is handled in [SAML/OIDC Enterprise SSO](/blog/aws-cognito-saml-oidc-enterprise-sso). It's the story of **federation design** with external IdPs.
- **This article** specializes in **"the implementation and security" by which the backend correctly verifies the JWT** issued by those.

Design & SSO go there, the token-verification implementation goes here — a configuration that complements without overlap.

---

## 9. Summary: the verification cheat sheet

A quick reference for when you're unsure.

- **Do all 5 pillars**: ① signature (RS256, JWKS's `kid`) ② `iss` ③ `aud` / `client_id` ④ `exp` / `iat` (+ clock skew) ⑤ `token_use`. Miss even one and verification is incomplete.
- **Whitelist-pin `alg` to `RS256`**: never trust the token header's `alg` (`alg=none` / confusion-attack countermeasure).
- **Don't mix up the token kind**: match the kind you receive with `token_use`. ID uses `aud`, Access uses `client_id`. The keys differ too, so **verify independently.**
- **JWKS: cache + periodic refresh + immediate refresh on an unknown kid**: pre-warm at startup, a 6-hour interval is one practical solution. A fetch failure is fail-closed.
- **Lean verification on a library**: in production, `aws-jwt-verify` (officially recommended). Create the verifier just once outside the handler and reuse the JWKS cache.
- **Where to verify**: first-defend at the API Gateway authorizer, and put domain authorization (industry, role, scope) in the app layer. Two layers for defense in depth.
- **When unsure, close**: reject all exceptions, fetch failures, and missing claims (fail-closed).

JWT verification boils down to the one-line principle of "trust the signed claims only after confirming the signature." But in production, mixing up `kid`, overlooking `token_use`, missing the `alg` pin, the JWKS caching strategy, the fail-open trap — it's a delicate area where **stepping on even one collapses the entire verification.**

In an invite-only B2B SaaS (a lumber-distribution DX that won the METI Minister's Award), I designed and implemented — on a foundation of **Cognito's RS256 + JWKS, protecting all 221 endpoints together with the API Gateway authorizer** — everything up to pinning `token_use`, a JWKS singleton cache, and turning fail-open into fail-closed. As a result, I prove **0 missing-authorization findings across all 221 endpoints in a third-party penetration test (the actual 15 roles).** I built all of this **fast and safely, with one person × generative AI (Claude Code).**

**"Is your own Cognito authentication really verifying correctly?"** — from a design review of token verification through the authorization design of the whole API and the remediation of penetration-test findings, I can accompany you end-to-end. Feel free to consult us.

---

### Reference (Official Documentation)

- [Verifying JSON web tokens (Amazon Cognito)](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html) — the verification procedure, JWKS URL, and the canonical rules for `kid` and `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) — the positioning of ID/access tokens, 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) — the ID token's claim list (`token_use: id`・`aud`・`iss`, etc.)
- [Understanding the access token](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-access-token.html) — the access token's claim list (`token_use: access`・`client_id`・`scope`, etc.)
- [aws-jwt-verify (GitHub, AWS Labs)](https://github.com/awslabs/aws-jwt-verify) — the AWS-officially-recommended JWT verification library, the `CognitoJwtVerifier` API, JWKS caching
