コンテンツへスキップ
記事一覧に戻る

一度のサインインで、どこでも検証できる:Cognitoトークンの仕組み

公開日: 2026-10-12

基本的なルールはこうです。アイデンティティは検証済みのトークンから得るものであり、クライアントやモデルの言い分から得るものでは決してありません。Cognitoはユーザーが誰なのかを一度だけ証明します。その後は、各サービスが署名付きトークンを自分自身でチェックします。そして、そこから取り出したsubクレームだけが、リクエストが誰のデータに触れられるかを決めます。

本稿では基礎に絞って、Cognitoが何を発行し、サービスがそれをどのようにチェックするのかを扱います。Strobe Assistant自体の中でトークンがどのように流れるのかは、パート4で追いかけます。

「auth」は実は3つの役割

私は、別々の3つの役割をまとめて「auth」と呼びがちだと気づきました。これらを分けて考えると、アーキテクチャ全体がずっと理解しやすくなります:

問い名前誰が答えるかStrobeでは
あなたは誰?認証(Authentication)Cognitoが一度だけ、パスワードを使ってA1の認証Lambda → Cognito InitiateAuth
このトークンは本物で、有効期限内か?検証(Verification)各サービスがそれぞれ、公開鍵を使ってAPI GatewayのJWTオーソライザー、auth.ts、FastMCPのJWTVerifier
このユーザーは何に触れてよいか?認可(Authorization)アプリ自身のコードが、検証済みのsubを使ってfilter={"userId": sub}、post.userId == sub

Cognitoが担うのは1つ目の役割だけです。Cognitoは投稿や写真やベクトルについては何も知らないので、認可は常にアプリ自身の役割です。

Cognitoトークンの仕組み。クライアントはメールアドレスとパスワードでCognitoユーザープールに一度だけサインインし、署名付きの3つのトークン(ID、アクセス、リフレッシュ)を受け取る。その後IDトークンを受け取ったサービスは、キャッシュしたプールの公開鍵を使って、Cognitoを呼び出すことなくローカルで検証し(kidの照合、RS256署名、iss、aud、exp、token_use)、subをユーザーのアイデンティティとして信頼する。下部では、認証・検証・認可が3つの別々の問いとして示されている

サインイン後にCognitoが返すもの

ユーザープールは、ユーザーディレクトリとトークン発行者を兼ねたものです。サインインに成功すると、3つのトークンを返します:

  • IDトークン:ユーザーが誰なのか(sub、email、cognito:username)と、どのアプリクライアント向けに発行されたのか(aud)。
  • アクセストークン:アプリが何を呼び出してよいか(scope)。audの代わりにclient_idを持ちます。
  • リフレッシュトークン:新しいIDトークンとアクセストークンに交換するための、有効期間の長いトークン(デフォルトは30日)。検証できるJWTではありません。暗号化されていてCognitoだけが読むことができ、Cognitoは取り消すことができます。

IDトークンとアクセストークンは**JWT**(JSON Web Token)です。base64urlでエンコードされた3つの部分をドットでつないだもので、header.payload.signatureという形をしています。

StrobeがアクセストークンではなくIDトークンを使う理由。 Strobe Assistantの検証側(エージェントのauth.tsとMCPサーバーのJWTVerifier)は、audをアプリクライアントIDと照合します。Cognitoのアクセストークンにはaudクレームがないため、このチェックは設計上アクセストークンを拒否します。また、IDトークンにはエージェントが表示名に使うユーザー情報も含まれています。どちらのファイルもこのことをdocstringで説明しています。両方がトークンを検証する理由はパート3で解説しています。

サービスがCognitoに問い合わせずにトークンを信頼できる仕組み

Cognitoはすべてのトークンを、Cognitoの外に決して出ない秘密鍵で署名し、対応する公開鍵をJWKS(JSON Web Key Set)として既知のURLで公開しています:

https://cognito-idp.ap-southeast-2.amazonaws.com/<poolId>/.well-known/jwks.json

これらの鍵があれば、サービスはリクエストのたびにCognitoを呼び出すことなく、ローカルでトークンを検証できます:

  1. ヘッダーからkidを読み取り、JWKSから一致する公開鍵を選びます(一度ダウンロードした後はキャッシュします)。
  2. RS256署名をチェックします。ヘッダーやペイロードが1バイトでも変わっていれば失敗します。
  3. issは自分のプールのURLでなければなりません。これにより、クラスメイトのものなど、他のプールのトークンを拒否します。
  4. audは自分のアプリクライアントIDでなければなりません。これにより、別のアプリ向けに発行されたトークンを拒否します。
  5. expは未来の時刻でなければなりません。
  6. token_useは"id"でなければなりません(エージェントが使うaws-jwt-verifyはこれもチェックします)。

期待される値(プールのURL、クライアントID、鍵)はすべて事前にわかっているため、リクエストごとにCognitoを呼び出す必要はありません。すべてのチェックに通れば、クレームは事実として扱うことができ、subをユーザーのアイデンティティとして安全に使えます。

StrobeのIDトークンの構造。エンコードされたヘッダー、ペイロード、署名と、デコードしたヘッダー(kid、alg RS256)およびペイロードのクレームの例(sub、iss、aud、token_use、iat、exp、email)、そしてそれぞれについて検証側がチェックする内容(kid、iss、aud、token_use、exp、署名)。base64urlは暗号化ではないこと、トークンをURLに入れないこと、IDトークンとアクセストークンの比較も示されている

署名が証明するのは、トークンが本物であり改ざんされていないということであって、中身を隠すわけではない点に注意してください。この設計には、覚えておくべき2つの帰結があります:

  • 読める ≠ 信頼できる。 base64urlは暗号化ではなくエンコードなので、トークンを持っている人なら誰でもペイロードを読めます。クレームに秘密情報を入れてはいけません。Strobe AssistantのWebUIは、名前を表示するためだけにトークンを自分でデコードしており(main.tsのtokenLabel())、何も検証していません。表示にはそれで問題ありませんが、判断に使ってはいけません。
  • ベアラー = 持っている人。 有効なトークンは、expまでは持っている人なら誰でも使えます。そのため、トークンはTLS上でのみ送り、URLに入れることは決してせず(URLに入れるとログやブラウザ履歴に残ります)、有効期間を短く保つ必要があります。さらに、リクエストごとにCognitoに問い合わせる人はいないので、サービスはサインアウトも知ることができません。リフレッシュトークンを取り消しても、その影響は届きません。AWSも、取り消されたトークンであっても、署名と有効期限だけをチェックするライブラリは通過してしまうと注意しています。だからこそ、IDトークンとアクセストークンは有効期間が短く(Cognitoのデフォルトは60分)、取り消し可能なのはリフレッシュトークンの方なのです。

ユーザーのアイデンティティとワークロードのアイデンティティ

このシステムには2種類のアイデンティティがあり、混同してはいけません:

  • ユーザーのアイデンティティ:CognitoのJWT。 *このリクエストは誰のデータのためのものか?*に答えます。
  • ワークロードのアイデンティティ:IAMロール。 *どのコンポーネントがAWSを呼び出しているのか?*に答えます。エージェントとMCPサーバーのECSタスクロール、そしてLambdaの実行ロールが、すべてのAWS API呼び出しに署名します(SigV4)。

Strobeでは、ユーザーのトークンでAWSが使えるようになることは決してありません。Bedrock、S3 Vectors、DynamoDBにはIAMロールでアクセスします。トークンが決めるのは、それらの呼び出しがどの行を求めるかだけです。

StrobeアプリにおけるCognitoの位置づけ

Assessment 1(Strobe Assistantが拡張している写真共有アプリ。シリーズの紹介記事を参照)では、Cognitoは薄い認証Lambdaの背後にあり、API Gatewayの**JWTオーソライザー**がすべての保護されたルートを守っています。

Strobe(A1)におけるCognitoの位置づけ。サインインはReactクライアントからAPI Gatewayを経由して認証Lambdaに届き、認証Lambdaがシークレットハッシュ付きでCognitoのInitiateAuthを呼び出し、IDトークンだけを返す。保護されたルートでは、API GatewayのJWTオーソライザーがLambdaの実行前にトークンを検証し(失敗すれば401)、アップロードLambdaは検証済みのsubをプレフィックスとするキーを署名付きURLにし、投稿Lambdaはpost.userIdがsubと一致する場合にだけ更新を許可する(一致しなければ403)

サインインはブラウザから直接Cognitoに行くのではなく、Lambdaを経由します。 アプリクライアントはコンフィデンシャルクライアントで、クライアントシークレットを持っており、InitiateAuthを呼び出すたびに、そこから計算したシークレットハッシュが必要です。ブラウザは秘密を保持できないため、POST /v1/auth/login(公開ルート)が認証Lambdaを呼び出し、認証Lambdaがサーバー側でCognitoを呼び出して{ user, token }を返します。このtokenがIDトークンです。

保護されたルートは、コードが動く前にチェックされます。 HTTP APIのJWTオーソライザーは、署名、iss、オーディエンス(aud、アクセストークンの場合はclient_id)、expをプールに照らして検証します。不正なトークンには401が返り、Lambdaは呼び出されません。そのため呼び出しの料金はかからず、チェックを忘れるコードパスもあり得ません。Lambdaはイベントの中で検証済みのクレームを受け取ります。以下はHTTP APIのイベントでの形です。A1のLambdaはA2のリポジトリにはないため、あくまで例示です:

claims = event["requestContext"]["authorizer"]["jwt"]["claims"]
user_id = claims["sub"]   # never read the user id from the request body

subは2つの場所でルールになります:

  • 所有者チェック。 たとえば、投稿を編集するにはpost.userId == subである必要があり、そうでなければ403になります。
  • ストレージのキー。 アップロードLambdaは、キー{sub}/{postId}/{fileId}に対するPUTの署名付きURLを発行します。{sub}の部分をクライアントが選ぶことはありません。A2の取り込みワーカーはこのプレフィックスを信頼するため、これは後で重要になります(パート4を参照)。

Cognitoにはモデレーターグループもあります。グループへの所属は、IDトークンの中でcognito:groupsとして現れます。

参考資料

あわせて読みたい