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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?