"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), 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" 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
kidand the signing algorithmalg. Cognito usesRS256(RSA + SHA-256) foralg. - 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
kidand the ID token'skiddo 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.
{
"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'skid.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
"""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).
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'salg; accept only the RS256 we permit.issuer=cfg.issuer: the official's "issmust match the User Pool."audience=...: the ID token usesaud(PyJWT can verify it). The access token usesclient_id, notaud, so we don't passaudienceand 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_useclaim. If you accept only access tokens, its value must beaccess. If you use only ID tokens, its value must beid. If you use both, it must be eitheridoraccess. (official)
Design point (SRP): I separate
fetch_jwks(key fetching) andverify_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'skidwith the cache, and if you receive an unknownkiddespite a correct issuer, the key may have been rotated, so refresh the cache fromjwks_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.
"""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 Trues 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, which the official names by name. Let me quote the official documentation's description.
For Node.js apps, AWS recommends the
aws-jwt-verifylibrary. You can set the claim values you want to verify onCognitoJwtVerifier. 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'skidmatches 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).
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 leaveissverification and key fetching to it.tokenUse: verifies whether thetoken_useclaim matches the expected value ("id"/"access").clientId: matchesaudfor an ID token,client_idfor 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
issuerandaudience." 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 equivalentpython-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. 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. 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
algtoRS256: never trust the token header'salg(alg=none/ confusion-attack countermeasure). - Don't mix up the token kind: match the kind you receive with
token_use. ID usesaud, Access usesclient_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) — the verification procedure, JWKS URL, and the canonical rules for
kidandtoken_use - Understanding user pool JSON web tokens (JWTs) — the positioning of ID/access tokens, claims, refresh/revoke
- Understanding the identity (ID) token — the ID token's claim list (
token_use: id・aud・iss, etc.) - Understanding the access token — the access token's claim list (
token_use: access・client_id・scope, etc.) - aws-jwt-verify (GitHub, AWS Labs) — the AWS-officially-recommended JWT verification library, the
CognitoJwtVerifierAPI, JWKS caching