- AWS
Amazon Cognito入門|ユーザープール作成とJWT検証コード
Amazon Cognito のユーザープールは、ユーザー登録とログインを受け持つ AWS のサービスです。 ログインに成功すると、JWT(JSON Web Token、署名つきの JSON)の形で3種類のトークンが発行されます。API 側は、受け取ったトークンを JWKS(公開鍵の一覧)で検証してから使います。
| 作るもの・すること | CLI | この記事の見出し |
|---|---|---|
| ユーザープール | aws cognito-idp create-user-pool |
CLI で一式を作る |
| アプリクライアント | aws cognito-idp create-user-pool-client |
同上 |
| ユーザーの作成・パスワード設定 | admin-create-user・admin-set-user-password |
同上 |
| ログイン(トークンの発行) | aws cognito-idp initiate-auth |
同上 |
| API 側でのトークン検証 | (コード) | Python・Java の検証コード |
検証で必ず見るのは、署名・iss・token_use・aud(ID トークン)か client_id(アクセストークン)・exp の5つです。
確認環境:Python 3.11・PyJWT 2.15.1・cryptography 50.0.2、Java 21(OpenJDK 21.0.10)・Jackson 2.14、AWS CLI 1.46.1(Linux)。AWS には接続せず、moto 5.2.4 のサーバーモード(AWS を模したサーバー)を相手に、CLI でユーザープールの作成からログイン・リフレッシュ・パスワード変更のチャレンジまでを動かし、発行されたトークンを JWKS で検証しました。検証コードは、自分で作った RSA 鍵2本と JWKS を手元の HTTP サーバーで配り、正しいトークンと不正なトークン計14通りで、Python と Java の両方の結果を確かめています。実際の Cognito が返すクレームの一覧・有効期限の既定値・エラーの条件は、AWS のドキュメントに基づいて書いています(確認日 2026年10月12日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Amazon Cognito入門|ユーザープール作成とJWT検証コード」
ユーザープールとアプリクライアントとは
| 用語 | 意味 |
|---|---|
| ユーザープール | ユーザーの一覧とログインの設定を持つ「ユーザーの入れ物」。トークンを発行する |
| アプリクライアント | ユーザープールを使うアプリごとの設定。認証の方式・トークンの有効期限・シークレットの有無を決める |
| アプリクライアント ID | トークンの aud・client_id に入る値。検証で照合する |
| ID プール(フェデレーティッドアイデンティティ) | ログインしたユーザーに AWS の一時的な認証情報を渡す別の仕組み。この記事では扱わない |
ユーザープールには機能プラン(Lite・Essentials・Plus)があり、ドキュメントでは、UserPoolTier を指定しないと Essentials になります。--user-pool-tier で指定できます。
CLI で一式を作る
1. ユーザープールを作る
メールアドレスでログインし、パスワードは 12 文字以上にする例です。
POOL_ID=$(aws cognito-idp create-user-pool \
--pool-name my-app-users \
--username-attributes email \
--auto-verified-attributes email \
--policies '{"PasswordPolicy":{"MinimumLength":12,"RequireUppercase":true,"RequireLowercase":true,"RequireNumbers":true,"RequireSymbols":false}}' \
--query UserPool.Id --output text)
echo "$POOL_ID"
--username-attributes email にすると、ユーザーはメールアドレスでログインし、内部のユーザー名には UUID が使われます。手元(moto)でも、admin-create-user の結果の Username は UUID になりました。
2. アプリクライアントを作る
ブラウザやスマートフォンのアプリから使うなら、シークレットなし(--no-generate-secret)にします。
CLIENT_ID=$(aws cognito-idp create-user-pool-client \
--user-pool-id "$POOL_ID" \
--client-name my-spa \
--no-generate-secret \
--explicit-auth-flows ALLOW_USER_SRP_AUTH ALLOW_USER_PASSWORD_AUTH ALLOW_REFRESH_TOKEN_AUTH \
--access-token-validity 60 --id-token-validity 60 --refresh-token-validity 30 \
--token-validity-units AccessToken=minutes,IdToken=minutes,RefreshToken=days \
--prevent-user-existence-errors ENABLED \
--query UserPoolClient.ClientId --output text)
echo "$CLIENT_ID"
| オプション | 意味(ドキュメント) |
|---|---|
--explicit-auth-flows |
許可するログインの方式。指定しないと ALLOW_REFRESH_TOKEN_AUTH・ALLOW_USER_SRP_AUTH・ALLOW_CUSTOM_AUTH |
ALLOW_USER_PASSWORD_AUTH |
パスワードをそのまま送る方式。SRP(パスワードを送らない方式)を使わないときや、CLI での確認に使う |
--generate-secret |
クライアントシークレットを作る。サーバー側・マシン間のアプリ向け |
--prevent-user-existence-errors ENABLED |
存在しないユーザーでも「ユーザー名かパスワードが違う」と返す。既定は LEGACY |
| トークンの有効期限 | ID・アクセスは既定 1 時間、リフレッシュは既定 30 日 |
ドキュメントでは、新しく作るアプリクライアントは、トークンの取り消し(EnableTokenRevocation)が既定で有効です。
3. ユーザーを作り、パスワードを決める
aws cognito-idp admin-create-user \
--user-pool-id "$POOL_ID" \
--username taro@example.com \
--user-attributes Name=email,Value=taro@example.com Name=email_verified,Value=true \
--message-action SUPPRESS
aws cognito-idp admin-set-user-password \
--user-pool-id "$POOL_ID" \
--username taro@example.com \
--password 'Example-Passw0rd' \
--permanent
admin-create-user の直後、ユーザーの状態は FORCE_CHANGE_PASSWORD でした。--permanent でパスワードを設定すると、そのままログインできます。--message-action SUPPRESS は、招待メールを送らない指定です。
4. ログインしてトークンを受け取る
aws cognito-idp initiate-auth \
--client-id "$CLIENT_ID" \
--auth-flow USER_PASSWORD_AUTH \
--auth-parameters USERNAME=taro@example.com,PASSWORD='Example-Passw0rd' \
> auth.json
jq '.AuthenticationResult | {ExpiresIn, TokenType, IdToken: .IdToken[0:20], AccessToken: .AccessToken[0:20], RefreshToken: .RefreshToken[0:20]}' auth.json
moto で動かした結果です(トークンは先頭 20 文字だけ表示)。
{
"ExpiresIn": 3600,
"TokenType": "Bearer",
"IdToken": "eyJ0eXAiOiJKV1QiLCJr",
"AccessToken": "eyJ0eXAiOiJKV1QiLCJr",
"RefreshToken": "e3be44f0-d996-454b-a"
}
moto のリフレッシュトークンは UUID ですが、実際の Cognito のリフレッシュトークンは暗号化されていて、中身は読めません(ドキュメント)。
5. 初回にパスワード変更を求められた場合
一時パスワードで作ったユーザーは、最初のログインで NEW_PASSWORD_REQUIRED のチャレンジが返ります。トークンは返らず、Session が返ります。
aws cognito-idp admin-create-user --user-pool-id "$POOL_ID" \
--username hanako@example.com \
--user-attributes Name=email,Value=hanako@example.com \
--temporary-password 'Temp-Passw0rd-1' --message-action SUPPRESS
aws cognito-idp initiate-auth --client-id "$CLIENT_ID" \
--auth-flow USER_PASSWORD_AUTH \
--auth-parameters USERNAME=hanako@example.com,PASSWORD='Temp-Passw0rd-1' > challenge.json
jq '{ChallengeName, ChallengeParameters}' challenge.json
aws cognito-idp respond-to-auth-challenge --client-id "$CLIENT_ID" \
--challenge-name NEW_PASSWORD_REQUIRED \
--session "$(jq -r .Session challenge.json)" \
--challenge-responses USERNAME=hanako@example.com,NEW_PASSWORD='New-Passw0rd-2'
{
"ChallengeName": "NEW_PASSWORD_REQUIRED",
"ChallengeParameters": {
"USERNAME": "d20e9ee1-4e74-491c-9ebc-1a9865ffa4a0"
}
}
respond-to-auth-challenge のあと、AuthenticationResult に3つのトークンが返りました。
6. リフレッシュトークンで更新する
aws cognito-idp initiate-auth --client-id "$CLIENT_ID" \
--auth-flow REFRESH_TOKEN_AUTH \
--auth-parameters REFRESH_TOKEN="$(jq -r .AuthenticationResult.RefreshToken auth.json)" \
--query 'keys(AuthenticationResult)'
手元では、新しい AccessToken・IdToken が返り、RefreshToken は返りませんでした。ドキュメントでも、リフレッシュトークンのローテーションを有効にしていなければ、新しい ID・アクセストークンだけが返り、元のリフレッシュトークンはそのまま使えます。
| 更新の方法 | 新しいリフレッシュトークン(ドキュメント) |
|---|---|
InitiateAuth(REFRESH_TOKEN_AUTH) |
返らない。ローテーションを有効にしたクライアントでは、この方式は使えない |
GetTokensFromRefreshToken |
ローテーションが有効なら返る |
OAuth のトークンエンドポイント(grant_type=refresh_token) |
ローテーションが有効なら返る |
ドキュメントでは、セキュリティの面からローテーションの有効化が勧められています。新しいリフレッシュトークンの期限は、最初のリフレッシュトークンの残り時間までで、延びません。
7. アクセストークンでユーザー情報を取る
aws cognito-idp get-user \
--access-token "$(jq -r .AuthenticationResult.AccessToken auth.json)" \
--query 'UserAttributes'
[
{ "Name": "email", "Value": "taro@example.com" },
{ "Name": "email_verified", "Value": "true" },
{ "Name": "sub", "Value": "dc7bc4d1-8c8b-46e3-acf1-4ba57660977e" }
]
シークレットありのクライアントでは SECRET_HASH が要る
--generate-secret で作ったクライアントでは、認証の呼び出しに SECRET_HASH を付けます。値は「ユーザー名+クライアント ID」を、クライアントシークレットを鍵にして HMAC-SHA256 で計算し、Base64 にしたものです。
CLIENT_SECRET='(create-user-pool-client の ClientSecret)'
USERNAME='taro@example.com'
SECRET_HASH=$(printf '%s' "${USERNAME}${CLIENT_ID}" \
| openssl dgst -sha256 -hmac "$CLIENT_SECRET" -binary | base64)
aws cognito-idp initiate-auth --client-id "$CLIENT_ID" \
--auth-flow USER_PASSWORD_AUTH \
--auth-parameters "USERNAME=${USERNAME},PASSWORD=Example-Passw0rd,SECRET_HASH=${SECRET_HASH}"
付け忘れると、Unable to verify secret hash for client のエラーになります(AWS re:Post のナレッジセンター)。
後片付け
aws cognito-idp delete-user-pool-client --user-pool-id "$POOL_ID" --client-id "$CLIENT_ID"
aws cognito-idp delete-user-pool --user-pool-id "$POOL_ID"
ID トークン・アクセストークン・リフレッシュトークンの違い
| 項目 | ID トークン | アクセストークン | リフレッシュトークン |
|---|---|---|---|
| 何のため | ユーザーが誰かをアプリに伝える | API を呼んでよいかを伝える | 新しい ID・アクセストークンをもらう |
| 形 | JWT(署名つき) | JWT(署名つき) | 暗号化されていて中身は読めない |
token_use |
id |
access |
— |
| アプリクライアント ID | aud に入る |
client_id に入る |
— |
| 主な中身 | email・cognito:username・属性 |
scope・username・cognito:groups |
— |
| 有効期限の既定 | 1 時間 | 1 時間 | 30 日(60 分〜10 年) |
| 送り先 | 自分のアプリ(画面表示など) | 自分の API・Cognito の API | Cognito だけ |
API の認可にはアクセストークンを使います。 ID トークンは「ログインした人の情報」を運ぶもので、API に渡すと、属性(メールアドレスなど)が API の利用者にも見えます。
API リファレンスでは、ID・アクセストークンの有効期限の上限は 1 日(86,400 秒)で、単位は TokenValidityUnits で分・時間などを選べます。
デコードした中身
moto が発行した ID トークンの中身(ペイロード)です。
{
"iss": "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_27bc0810cd174a1f96d76285ce9ceadc",
"sub": "dc7bc4d1-8c8b-46e3-acf1-4ba57660977e",
"aud": "84d223e70d5d4cd48ebd14fe03",
"token_use": "id",
"iat": 1791819889,
"auth_time": 1791819889,
"exp": 1791823489,
"jti": "cc6a62c6-88e8-4a84-b8a6-14e8a6342471",
"cognito:username": "dc7bc4d1-8c8b-46e3-acf1-4ba57660977e",
"email": "taro@example.com",
"origin_jti": "5d3ed39d-f2da-40aa-9d5c-ebedb0e77f60"
}
ドキュメントにあるアクセストークンの例(一部)です。
{
"sub": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"cognito:groups": ["testgroup"],
"iss": "https://cognito-idp.us-west-2.amazonaws.com/us-west-2_example",
"version": 2,
"client_id": "xxxxxxxxxxxxexample",
"aud": "https://api.example.com",
"origin_jti": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"token_use": "access",
"scope": "phone openid profile resourceserver.1/appclient2 email",
"auth_time": 1676313851,
"exp": 1676317451,
"iat": 1676313851,
"jti": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"username": "my-test-user"
}
アクセストークンにも aud が入ることがあります。 ドキュメントでは、アプリがリソースのひも付け(resource binding)を求めたときだけ、API の URL が aud に入るとされています。この aud はアプリクライアント ID ではないので、アクセストークンでは client_id を照合します。
コマンドラインで中身を見るには、次のようにします(署名は確かめません)。
jq -r .AuthenticationResult.IdToken auth.json | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
JWT の仕組みそのものは、関連記事「JWTとは|仕組みと、安全に使うための注意点」で扱っています。
検証で見るクレーム
公開鍵の一覧(JWKS)は、次の URL にあります。
https://cognito-idp.<リージョン>.amazonaws.com/<ユーザープールID>/.well-known/jwks.json
| 確かめるもの | 期待する値 | 外したときに通ってしまうもの |
|---|---|---|
ヘッダーの alg |
RS256 だけ |
alg: none の署名なしトークン |
署名(ヘッダーの kid で JWKS の鍵を選ぶ) |
一致する | 中身を書き換えたトークン、別の鍵で作ったトークン |
iss |
https://cognito-idp.<リージョン>.amazonaws.com/<ユーザープールID> |
別のユーザープールが発行したトークン |
token_use |
access(API)か id |
ID トークンを API に使う取り違え |
aud(ID)/client_id(アクセス) |
自分のアプリクライアント ID | 同じプールの別アプリ向けのトークン |
exp |
現在より後(数十秒のずれを許す) | 期限切れのトークン |
iss と aud/client_id を見ないと、署名が正しい「他人向けのトークン」を受け付けてしまいます。 Cognito の署名鍵は、同じユーザープールの全アプリクライアントで共通だからです。
ドキュメントでは、署名と期限の確認だけでは、取り消された(ログアウトした)トークンは見分けられないとされています。取り消しを反映させたいときは、アクセストークンの期限を短くするか、Cognito の API(GetUser など)で状態を確かめます。考え方は、関連記事「JWTでログアウトを実装する方法|トークン無効化の考え方」で扱っています。
Python(PyJWT)の検証コード
pip install "pyjwt[crypto]"
# cognito_jwt.py
import jwt # PyJWT
from jwt import PyJWKClient
REGION = "ap-northeast-1"
USER_POOL_ID = "ap-northeast-1_XXXXXXXXX"
APP_CLIENT_ID = "1example23456789"
ISSUER = f"https://cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}"
JWKS_URL = f"{ISSUER}/.well-known/jwks.json"
# 公開鍵は kid ごとにキャッシュされる。知らない kid が来たら取り直す
_jwks = PyJWKClient(JWKS_URL, cache_keys=True, lifespan=3600)
class TokenError(Exception):
pass
def verify_cognito_token(token: str, token_use: str = "access") -> dict:
"""Cognito の ID トークン / アクセストークンを検証して、クレームを返す"""
try:
signing_key = _jwks.get_signing_key_from_jwt(token) # ヘッダーの kid で鍵を選ぶ
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"], # alg は固定する(none や HS256 を受け付けない)
issuer=ISSUER, # iss
audience=APP_CLIENT_ID if token_use == "id" else None, # ID トークンは aud
options={
"require": ["exp", "iat", "iss", "token_use"],
"verify_aud": token_use == "id",
},
leeway=30, # 時計のずれを 30 秒まで許す
)
except jwt.PyJWTError as e:
raise TokenError(f"{type(e).__name__}: {e}") from e
if claims.get("token_use") != token_use: # ID とアクセスの取り違え
raise TokenError(f"token_use が {token_use} ではない: {claims.get('token_use')}")
if token_use == "access" and claims.get("client_id") != APP_CLIENT_ID:
raise TokenError("client_id が一致しない")
return claims
使う側の例です。Authorization: Bearer <アクセストークン> を受け取る想定です。
from cognito_jwt import verify_cognito_token, TokenError
def lambda_handler(event, context):
auth = (event.get("headers") or {}).get("authorization", "")
token = auth.removeprefix("Bearer ").strip()
try:
claims = verify_cognito_token(token, "access")
except TokenError as e:
print({"auth_error": str(e)})
return {"statusCode": 401, "body": "unauthorized"}
return {"statusCode": 200, "body": f"hello {claims['username']}"}
moto が発行したトークンで確かめた
moto の JWKS(/<ユーザープールID>/.well-known/jwks.json)を使って、CLI のログインで得たトークンを検証しました。
id: {'aud': '84d223e70d5d4cd48ebd14fe03', 'token_use': 'id', 'email': 'taro@example.com'}
access: {'client_id': '84d223e70d5d4cd48ebd14fe03', 'token_use': 'access', 'scope': 'aws.cognito.signin.user.admin'}
IDトークンをaccessとして -> token_use が access ではない: id
アクセストークンをidとして -> MissingRequiredClaimError: Token is missing the "aud" claim
不正なトークン 14 通りで確かめた
自分で RSA 鍵(2048 ビット)を3本作り、key-1 と key-2 だけを JWKS に載せて配りました。そのうえで、次のトークンを作って検証しました。
| トークン | Python(PyJWT)の結果 | Java の結果 |
|---|---|---|
| 正しい ID トークン | OK | OK |
| 正しいアクセストークン | OK | OK |
正しいアクセストークン(key-2 で署名) |
OK | OK |
aud 付きのアクセストークン(resource binding) |
OK | OK |
| 期限切れ(2 分前) | ExpiredSignatureError |
有効期限切れ(exp) |
| 期限切れ(10 秒前、ずれの許容内) | OK | OK |
アクセストークンの client_id が別 |
client_id が一致しない | client_id が一致しない |
ID トークンの aud が別 |
InvalidAudienceError |
aud が一致しない |
| ID トークンをアクセストークンとして検証 | token_use が access ではない | token_use が access ではない |
iss が別のユーザープール |
InvalidIssuerError |
iss が一致しない |
JWKS に無い鍵(kid も無い) |
PyJWKClientError: Unable to find a signing key that matches |
kid に合う公開鍵がない |
別の鍵で署名し、kid だけ key-1 にした |
InvalidSignatureError |
署名が一致しない |
ペイロードを書き換えた(username を admin に) |
InvalidSignatureError |
署名が一致しない |
alg: none(署名なし) |
InvalidAlgorithmError |
alg が RS256 ではない |
PyJWT の既定のまま、aud 付きのアクセストークンを audience なしで decode すると、InvalidAudienceError: Invalid audience になりました。 上のコードで、アクセストークンのときに verify_aud を外しているのはこのためです。代わりに client_id を自分で照合しています。
Java の検証コード(JDK 標準+Jackson)
外部の JWT ライブラリを使わず、JDK の java.net.http.HttpClient と java.security.Signature、JSON の読み取りに Jackson だけを使う版です。依存を増やしたくない Lambda や、仕組みを理解したいときに使えます。
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.RSAPublicKeySpec;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/** Cognito の ID トークン / アクセストークンを JDK 標準+Jackson だけで検証する */
public final class CognitoJwtVerifier {
public static final class TokenException extends Exception {
public TokenException(String message) { super(message); }
}
private static final ObjectMapper JSON = new ObjectMapper();
private static final Base64.Decoder B64URL = Base64.getUrlDecoder();
private static final long LEEWAY_SECONDS = 30; // 時計のずれ
private static final Duration MIN_REFRESH_INTERVAL = Duration.ofMinutes(1);
private final String issuer;
private final String jwksUrl;
private final String appClientId;
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build();
private final Map<String, PublicKey> keys = new ConcurrentHashMap<>(); // kid -> 公開鍵
private volatile Instant lastFetch = Instant.EPOCH;
public CognitoJwtVerifier(String region, String userPoolId, String appClientId) {
this("https://cognito-idp." + region + ".amazonaws.com/" + userPoolId, appClientId, (URI) null);
}
/** テスト用:issuer と JWKS の URL を直接渡す(jwksUrl が null なら issuer + "/.well-known/jwks.json") */
CognitoJwtVerifier(String issuer, String appClientId, URI jwksUrl) {
this.issuer = issuer;
this.appClientId = appClientId;
this.jwksUrl = jwksUrl != null ? jwksUrl.toString() : issuer + "/.well-known/jwks.json";
}
/** tokenUse は "id" か "access" */
public JsonNode verify(String token, String tokenUse) throws TokenException {
String[] parts = token.split("\\.", -1);
if (parts.length != 3) throw new TokenException("JWT の形ではない");
JsonNode header = parseJson(parts[0]);
JsonNode claims = parseJson(parts[1]);
// 1. alg は RS256 だけ
if (!"RS256".equals(header.path("alg").asText())) {
throw new TokenException("alg が RS256 ではない: " + header.path("alg").asText());
}
// 2. kid で公開鍵を選び、署名を確かめる
PublicKey key = findKey(header.path("kid").asText());
try {
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initVerify(key);
sig.update((parts[0] + "." + parts[1]).getBytes(StandardCharsets.US_ASCII));
if (!sig.verify(B64URL.decode(parts[2]))) throw new TokenException("署名が一致しない");
} catch (TokenException e) {
throw e;
} catch (Exception e) {
throw new TokenException("署名を検証できない: " + e);
}
// 3. クレーム
long now = Instant.now().getEpochSecond();
if (!claims.hasNonNull("exp") || claims.get("exp").asLong() + LEEWAY_SECONDS < now) {
throw new TokenException("有効期限切れ(exp)");
}
if (!issuer.equals(claims.path("iss").asText())) {
throw new TokenException("iss が一致しない: " + claims.path("iss").asText());
}
if (!tokenUse.equals(claims.path("token_use").asText())) {
throw new TokenException("token_use が " + tokenUse + " ではない: " + claims.path("token_use").asText());
}
String clientClaim = "id".equals(tokenUse) ? "aud" : "client_id";
if (!appClientId.equals(claims.path(clientClaim).asText())) {
throw new TokenException(clientClaim + " が一致しない");
}
return claims;
}
private PublicKey findKey(String kid) throws TokenException {
PublicKey key = keys.get(kid);
if (key == null && Duration.between(lastFetch, Instant.now()).compareTo(MIN_REFRESH_INTERVAL) > 0) {
refreshKeys(); // 鍵の入れ替え(ローテーション)に備えて取り直す
key = keys.get(kid);
}
if (key == null) throw new TokenException("kid に合う公開鍵がない: " + kid);
return key;
}
private synchronized void refreshKeys() throws TokenException {
try {
HttpRequest req = HttpRequest.newBuilder(URI.create(jwksUrl)).timeout(Duration.ofSeconds(5)).GET().build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new TokenException("JWKS の取得に失敗: HTTP " + res.statusCode());
KeyFactory kf = KeyFactory.getInstance("RSA");
for (JsonNode jwk : JSON.readTree(res.body()).path("keys")) {
if (!"RSA".equals(jwk.path("kty").asText())) continue;
BigInteger n = new BigInteger(1, B64URL.decode(jwk.path("n").asText()));
BigInteger e = new BigInteger(1, B64URL.decode(jwk.path("e").asText()));
keys.put(jwk.path("kid").asText(), kf.generatePublic(new RSAPublicKeySpec(n, e)));
}
lastFetch = Instant.now();
} catch (TokenException e) {
throw e;
} catch (Exception e) {
throw new TokenException("JWKS の取得に失敗: " + e);
}
}
private static JsonNode parseJson(String b64) throws TokenException {
try {
return JSON.readTree(B64URL.decode(b64));
} catch (Exception e) {
throw new TokenException("JSON として読めない: " + e.getMessage());
}
}
}
使う側です。
var verifier = new CognitoJwtVerifier("ap-northeast-1", "ap-northeast-1_XXXXXXXXX", "1example23456789");
try {
JsonNode claims = verifier.verify(accessToken, "access");
String username = claims.path("username").asText();
} catch (CognitoJwtVerifier.TokenException e) {
// 401 を返す
}
コードの要点です。
| 箇所 | 理由 |
|---|---|
split("\\.", -1) |
alg: none のトークンは末尾が . で終わる。-1 が無いと空の要素が消え、形の誤りとして扱われる(手元で確かめた) |
new BigInteger(1, ...) |
JWK の n・e は符号なしの数。1 で正の数として読む |
MIN_REFRESH_INTERVAL |
知らない kid が来るたびに JWKS を取りに行くと、不正なトークンで取得を繰り返させられる。間隔を空ける |
ConcurrentHashMap |
複数のスレッドから同時に検証しても安全にする |
Spring Boot のアプリなら、Spring Security の JWT 検証(issuer-uri の設定)を使う方法もあります。関連記事「Spring Security JWT認証の実装|Boot 4対応の設定コード全文」で扱っています。Node.js では、AWS のドキュメントで aws-jwt-verify ライブラリが勧められています。
import { CognitoJwtVerifier } from "aws-jwt-verify";
const verifier = CognitoJwtVerifier.create({
userPoolId: "ap-northeast-1_XXXXXXXXX",
tokenUse: "access",
clientId: "1example23456789",
});
const payload = await verifier.verify(token);
鍵の入れ替え(ローテーション)とキャッシュ
ドキュメントでは、Cognito が署名鍵を入れ替えることがあるため、公開鍵を kid ごとにキャッシュし、定期的に更新するよう勧められています。iss が正しく、知らない kid のトークンが来たら、JWKS を取り直します。 上の Python(PyJWKClient)も Java のコードも、この動きになっています。
| やり方 | 問題 |
|---|---|
| 呼び出しのたびに JWKS を取りに行く | 遅い。Cognito 側への呼び出しも増える |
| 起動時に1回だけ取り、ずっと使う | 鍵が入れ替わると、正しいトークンが全部失敗する |
kid ごとにキャッシュし、知らない kid で取り直す |
取り直しの間隔に下限を設ける |
よくあるエラーと原因
ログイン・トークン発行のエラー
| エラー | 主な原因 | 対処 |
|---|---|---|
NotAuthorizedException: Incorrect username or password. |
パスワードの誤り。PreventUserExistenceErrors が ENABLED なら、存在しないユーザーでもこれ |
ユーザー名・パスワード・クライアント ID を確かめる |
UserNotFoundException: User does not exist. |
存在しないユーザー(LEGACY の場合) |
ENABLED にして、ユーザーの有無を外に漏らさないようにする |
InvalidParameterException: USER_PASSWORD_AUTH flow not enabled for this client |
アプリクライアントで ALLOW_USER_PASSWORD_AUTH を許可していない |
update-user-pool-client で --explicit-auth-flows に加える |
NotAuthorizedException: Unable to verify secret hash for client |
シークレットありのクライアントで SECRET_HASH が無い・誤り |
上の計算で SECRET_HASH を付ける |
UserNotConfirmedException |
サインアップ後の確認(メールのコード入力)が済んでいない | confirm-sign-up、管理者なら admin-confirm-sign-up |
PasswordResetRequiredException |
管理者がパスワードのリセットを求めた | forgot-password → confirm-forgot-password |
ChallengeName: NEW_PASSWORD_REQUIRED |
一時パスワードのユーザーの初回ログイン | respond-to-auth-challenge で新しいパスワードを送る |
update-user-pool-client は、指定しなかった設定を既定値に戻します。 変えたい項目だけでなく、今の設定を describe-user-pool-client で確かめてから、全部を指定し直します。
検証のエラー(PyJWT のメッセージ)
| メッセージ | 原因 | 対処 |
|---|---|---|
ExpiredSignatureError: Signature has expired |
exp を過ぎた |
リフレッシュトークンで更新する |
InvalidAudienceError: Audience doesn't match |
ID トークンの aud が自分のクライアント ID でない |
発行元のアプリクライアントを確かめる |
InvalidAudienceError: Invalid audience |
アクセストークンに aud があり、audience を渡していない |
アクセストークンは verify_aud を外し client_id を見る |
MissingRequiredClaimError: Token is missing the "aud" claim |
アクセストークンを ID トークンとして検証した | token_use に合わせて検証する |
InvalidIssuerError: Invalid issuer |
別のユーザープール・別リージョンのトークン | ISSUER のプール ID とリージョンを確かめる |
PyJWKClientError: Unable to find a signing key that matches |
JWKS に無い kid。別のプールのトークンか、鍵の入れ替え直後 |
JWKS の URL が正しいか確かめる |
InvalidSignatureError: Signature verification failed |
書き換え・別の鍵での署名 | 拒否する |
InvalidAlgorithmError: The specified alg value is not allowed |
algorithms に無い alg(none など) |
拒否する |
よくある質問
Q. API には ID トークンとアクセストークンのどちらを送りますか。
A. アクセストークンです。ドキュメントでは、アクセストークンは API や Cognito の API・userInfo エンドポイントへのアクセスに使う scope を持ち、ID トークンはユーザーの属性を持つとされています。
Q. exp を確かめる時計のずれはどのくらい許しますか。
A. ドキュメントに決まった値はありません。この記事のコードでは 30 秒にしています。許すほど期限切れのトークンが長く通るので、数十秒程度が目安です。
Q. JWKS はどのくらいキャッシュしますか。
A. ドキュメントでは「定期的に更新」とだけあり、時間の指定はありません。この記事の Python のコードでは 1 時間(lifespan=3600)にし、知らない kid が来たら取り直しています。
Q. ログアウトしたユーザーのアクセストークンを拒否できますか。 A. 署名と期限の確認だけではできません。ドキュメントでは、Cognito の API で状態を確かめるか、アクセストークンの有効期限を短くするよう案内されています。
OAuth 2.0 と OpenID Connect の関係は、関連記事「OAuth 2.0とOpenID Connectの違いをわかりやすく解説」で扱っています。
まとめ
- CLI の流れは
create-user-pool→create-user-pool-client→admin-create-user→admin-set-user-password→initiate-auth - ID トークンは「誰か」、アクセストークンは「API を呼んでよいか」。API にはアクセストークンを送る
- アプリクライアント ID は、ID トークンでは
aud、アクセストークンではclient_id - 検証は
algを RS256 に固定・kidで JWKS の鍵を選んで署名・iss・token_use・aud/client_id・exp - アクセストークンにも
audが入ることがあり、PyJWT の既定ではInvalid audienceになる。アクセストークンはclient_idを照合する - 14 通りのトークンで、Python と Java のコードがどちらも正しいものだけを通すことを確かめた
参考・出典
確認日はいずれも 2026年10月12日です。
- Amazon Cognito デベロッパーガイド「Verifying JSON web tokens」:https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html
- Amazon Cognito デベロッパーガイド「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
- Amazon Cognito デベロッパーガイド「Understanding the access token」:https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-access-token.html
- Amazon Cognito デベロッパーガイド「Refresh tokens」:https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-refresh-token.html
- Amazon Cognito デベロッパーガイド「User pool feature plans」:https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-sign-in-feature-plans.html
- Amazon Cognito API リファレンス「CreateUserPoolClient」:https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_CreateUserPoolClient.html
- AWS re:Post ナレッジセンター「Cognito の Unable to verify secret hash for client エラー」:https://repost.aws/knowledge-center/cognito-unable-to-verify-secret-hash
- PyJWT ドキュメント:https://pyjwt.readthedocs.io/
- moto:https://docs.getmoto.org/
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Amazon Cognito入門|ユーザープール作成とJWT検証コード」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。