ALB + Cognito + Djangoコンテナ構成でステートレス認証構成を組んでみた
はじめに
クラウド事業統括本部の浅野です。
Amazon ECS Fargate上のWebアプリケーション(Django)をApplication Load Balancer(ALB)配下で複数AZ・複数タスクに分散させて運用する構成(AutoScaling前提)に、ログイン機能と「一般ユーザー/管理者」の権限分けを追加したい。ただし、認証やセッション管理のコードはできるだけアプリ側に書きたくない。そんなシチュエーションを想定します。
この構成でまず課題になるのが、認証のセッション状態をどこに持つかです。アプリ側でセッションを持つと、タスクが複数に分散する構成ではスティッキーセッションや共有セッションストアの設計が必要になり、認証周りの実装・運用の負担も増えます。
ALBにはauthenticate-cognitoというアクションがあり、これを使うと認証のセッション状態はALBとAmazon Cognito側(インフラ層)だけで保持され、アプリケーション側は認証状態を一切持たない完全ステートレスな設計にできます。セッション状態そのものをAWSマネージドなインフラ層が持つため、タスクを複数AZ・複数タスクに分散させてもスティッキーセッションを気にする必要がありません。
さらに、Cognitoのグループ機能を組み合わせると、「一般ユーザー」「管理者」といった権限の区別まで、アプリ側にほとんどコードを書かずインフラ層に寄せて実現できます。今回は、この2点(認証セッションと権限管理をインフラ層に寄せる構成)が実際にどういうアーキテクチャ・画面の見え方になるのかを、Terraformで構築して検証していきます。
実現したいこと
- ALB + Cognitoの認証機能だけでログイン状態を管理し、Djangoアプリ側はセッションや認証状態を一切持たない構成にする
- ECSタスクが複数AZ・複数タスクに分散する構成(AutoScaling可能な構成)でも、認証のセッション状態はインフラ層(ALB・Cognito)だけで保持し、ターゲットグループのスティッキーセッションを考慮しなくていい構成にする
- 一般ユーザー・管理者という権限情報の管理をCognitoのグループ機能に寄せて、アプリ側の権限管理の実装・運用を楽にする
このように整理したうえで、次の2つはアプリ側の責務になります。「ALBが認証済みと言っているリクエストが本当にCognito発行のものかを検証すること」と「cognito:groupsを見て管理者かどうかを判定すること」です。この2つの実装は、後述の「Djangoアプリの実装ポイント」で紹介します。
構成

VPCはPublic Subnetのみの2AZ構成です。ECS Fargateタスクはnginx + Djangoの2コンテナ構成で、2タスクを2AZに配置し、ターゲットグループのスティッキーセッションは意図的に無効化したまま検証しています。
実際のアプリケーションではRDS等のデータストアが必要になりますが、今回は認証・認可の確認が目的のPoCのため構築していません。Djangoは完全にステートレスな構成です(DBを持つ場合の設計については後述します)。
認証・認可のフロー
それぞれのコンポーネントの役割
| 役割 | Cognito | ALB | アプリ(Django) |
|---|---|---|---|
| すること | ユーザー認証・グループ管理・トークン発行・Hosted UIの提供 | 未認証リクエストのリダイレクト・セッションCookieの発行/検証・トークンの透過的な更新・認証済みリクエストへのヘッダー付与 | アクセストークンの署名検証・cognito:groupsによる認可判定 |
| 持つ情報 | ユーザー・グループの真実の源 | 何も永続化しない(Cookieに一時保持のみ) | 何も持たない(DBなし、リクエストごとに都度検証) |
認証(ログインさせる処理)自体は、Djangoのコードには一切登場しません。
Terraformについて
今回構成した認証・認可に関わるリソースの設定だけをピックアップして紹介します。
ALBリスナールール: パスごとに認証要否を分岐
認証が必要なパス(/dashboard*・/admin*)だけを条件にしたリスナールールを作成し、authenticate-cognitoアクション(order 1)→ターゲットグループへのforward(order 2)の順で処理させる設定にしています。/(トップページ)はこのルールの対象外なので、ALBのデフォルトアクションでそのままforwardされ、認証不要でアクセスできます。
resource "aws_lb_listener_rule" "dashboard_auth" {
listener_arn = aws_lb_listener.https.arn
priority = 10
condition {
path_pattern { values = ["/dashboard*", "/admin*"] }
}
action {
type = "authenticate-cognito"
order = 1
authenticate_cognito {
user_pool_arn = aws_cognito_user_pool.main.arn
user_pool_client_id = aws_cognito_user_pool_client.alb.id
user_pool_domain = aws_cognito_user_pool_domain.main.domain
}
}
action {
type = "forward"
order = 2
target_group_arn = aws_lb_target_group.app.arn
}
}
ターゲットグループ: スティッキーセッションを明示的に無効化
認証のセッション状態はALB・Cognito側で保持されるため、ターゲットグループ側でスティッキーセッションを有効にする必要はありません。それを示すために、stickinessを明示的に無効化した設定にしています。この設定のまま複数タスクにリクエストが分散した場合の実際の見え方は、後述の「やってみた」で紹介します。
resource "aws_lb_target_group" "app" {
name = "${local.name}-tg"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
target_type = "ip"
stickiness {
enabled = false
}
}
Cognito: ユーザープール + ALB用App Client
Cognito側で作成しているリソースは4種類です。
aws_cognito_user_pool: ユーザーディレクトリの本体。ユーザーの登録先であり、パスワードポリシーやMFA等の認証設定もここに集約されるaws_cognito_user_pool_domain: Hosted UI(ログイン画面)を配信するドメイン。これを設定するだけでログイン・サインアップ等の画面が使える状態になるaws_cognito_user_pool_client: ALBのauthenticate-cognitoアクションから参照するApp Client。ALBと連携するための要件として、クライアントシークレットの生成(generate_secret = true)と認可コードフロー(allowed_oauth_flows = ["code"])を設定する。コールバックURLはALB認証で固定の/oauth2/idpresponseを指定するaws_cognito_user_group: 権限区別用のadmin/userグループ。管理者/一般ユーザーの区別はこのグループで管理し、Django側はcognito:groupsクレームを見て判定するだけにする
resource "aws_cognito_user_pool" "main" {
name = "${local.name}-pool"
}
resource "aws_cognito_user_pool_domain" "main" {
domain = "alb-auth-poc-${random_id.suffix.hex}"
user_pool_id = aws_cognito_user_pool.main.id
}
resource "aws_cognito_user_pool_client" "alb" {
name = "${local.name}-alb-client"
user_pool_id = aws_cognito_user_pool.main.id
generate_secret = true
allowed_oauth_flows_user_pool_client = true
allowed_oauth_flows = ["code"]
allowed_oauth_scopes = ["openid", "email"]
callback_urls = ["https://${local.domain_name}/oauth2/idpresponse"]
}
resource "aws_cognito_user_group" "admin" {
name = "admin"
user_pool_id = aws_cognito_user_pool.main.id
}
resource "aws_cognito_user_group" "user" {
name = "user"
user_pool_id = aws_cognito_user_pool.main.id
}
Djangoアプリの実装ポイント
認証そのもの(ログインさせる処理)はインフラ層が担うため、アプリ側で実装するのは認可の部分だけです。認証についてアプリが行うのは、ALBが認証済みとして転送してきたトークンの署名検証のみです。この「署名検証」と「認可判定」のコードを抜粋します。
ALBから届くヘッダーとアクセストークンの署名検証
ALBは認証済みのリクエストにx-amzn-oidc-accesstoken(Cognitoが発行した生のアクセストークン)等のヘッダーを付与してターゲットに転送します。各ヘッダーの仕様は以下に説明があります。
ここで重要なのは、ヘッダー自体は平文で届くため、アプリ側で「本当にCognitoが発行したトークンか」を署名検証してから使う必要がある点です。
検証にはJWKS(JSON Web Key Set、検証用公開鍵の集合)を使います。Cognitoはユーザープールごとにhttps://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.jsonでJWKSを公開しており、トークンのヘッダーにあるkid(鍵ID)に対応する公開鍵で署名を検証する、という手順が以下のドキュメントに説明されています。
公開鍵はほとんど変わらないため、プロセス内にキャッシュしてリクエストのたびに取得し直さないようにしています。
# Cognitoのアクセストークン検証用JWKSクライアント。モジュールレベルで1つだけ保持することで、
# 署名鍵をプロセス内にキャッシュし、リクエストのたびにJWKSエンドポイントへ問い合わせるのを避ける
_jwks_client = None
def _get_jwks_client():
global _jwks_client
if _jwks_client is None:
region = os.environ.get("AWS_REGION", "ap-northeast-1")
user_pool_id = os.environ.get("COGNITO_USER_POOL_ID")
jwks_url = f"https://cognito-idp.{region}.amazonaws.com/{user_pool_id}/.well-known/jwks.json"
_jwks_client = jwt.PyJWKClient(jwks_url, cache_keys=True)
return _jwks_client
def _verify_access_token(access_token):
client = _get_jwks_client()
signing_key = client.get_signing_key_from_jwt(access_token)
return jwt.decode(access_token, signing_key.key, algorithms=["RS256"])
cognito:groupsによる認可判定
検証済みのクレームからcognito:groupsを取り出し、adminが含まれるかどうかで200/403を返します。認可判定はこれだけです。
def admin_panel(request):
access_token = request.headers.get("x-amzn-oidc-accesstoken")
if not access_token:
return HttpResponse("ALBの認証ヘッダーがありません", status=400)
try:
claims = _verify_access_token(access_token)
except Exception:
return HttpResponse("アクセストークンの検証に失敗しました", status=400)
groups = claims.get("cognito:groups", [])
if "admin" not in groups:
return HttpResponse("403 Forbidden", status=403)
return HttpResponse("管理者ページ")
今回のPoCではユーザーテーブルもロールテーブルもセッションも持たず、判定材料はすべて署名検証済みのトークンから得ています(リクエスト側でヘッダーを細工しても署名検証で弾かれます)。ただしこれはPoCとして認証・認可の流れに絞ったための構成で、実際のアプリケーションではDBを持つ設計になります。
やってみた
未認証アクセスとサインアップ
/dashboard/に未ログインの状態でアクセスすると、ALBによって自動的にCognitoのログイン画面にリダイレクトされました。

このログイン画面・サインアップ画面は、自前で実装したものではなく**Cognitoが標準で提供しているホストされたUI(Hosted UI / マネージドログイン)**です。ユーザープールにドメインを設定するだけでログイン・サインアップ・パスワードリセット・確認コード入力といった一連の画面が使える状態になり、ロゴや配色などのブランディングもCognito側の設定でカスタマイズできます。フロントエンドのログイン画面を1行も書いていない点も、認証をインフラ層に寄せるこの構成の特徴です。
新規ユーザーを作成するため、サインアップ画面から登録します。

登録したメールアドレスに確認コードが届きました。確認コードの送信もCognitoが標準で行うため、メール送信の実装も不要です。

確認コードを入力してサインアップを完了させます。

複数タスクへ分散させてみる
ログイン後の認証後ページには、実際にリクエストに応答したECSタスクのIDとAvailability Zoneを表示するようにしています。ページをリロードすると、スティッキーセッションが無効なままでも、2つのタスク・2つのAZに交互に振り分けられていることが確認できました。

認証状態はアプリケーション側(特定のタスク)に一切紐づいていません。ALBが発行するセッションCookieと、Cognitoが発行したアクセストークンだけでリクエストの正当性を判断しているため、どのタスクが応答してもログイン状態がそのまま維持されます。どのタスクも前述の実装ポイントの通りステートレスに署名検証しているだけなので、「どのタスクに当たるか」が認証結果に影響する余地がありません。
管理者/一般ユーザーのグループ分離
ここからは、権限情報の管理をCognitoのグループ機能に寄せ、アプリ側は届いたグループ情報の判定だけを行う構成を見ていきます。
仕組みはシンプルで、Cognitoのユーザープールにadminグループとuserグループを作成し、ユーザーをどちらかのグループに所属させるだけです。ユーザーがログインすると、そのユーザーが所属するグループ名がアクセストークンのcognito:groupsクレームに自動的に含まれます。
Django側は、ALB経由で受け取ったアクセストークンを検証したあと、このcognito:groupsにadminが含まれているかどうかを見て200か403かを返しているだけです。権限の変更はCognito側でユーザーの所属グループを付け替えるだけで完結します。
DBにroleカラムを持つ一般的な構成でも権限変更にデプロイが不要なのは同じです。この構成ならではの特徴は、アプリ側に権限を管理するためのテーブルも管理画面も持たなくてよいこと(ユーザーと権限の管理インターフェースはCognitoのコンソール・APIをそのまま使える)、そして権限情報が署名付きトークンとして毎リクエスト届くため、アプリはDBを参照せずに改ざん不可能な形で認可判定できることです。実際にコンソールからtestuserをadminグループに、general-userをuserグループに割り当てて動かしてみます。

testuserをadminグループに追加します。

general-userはuserグループに追加します(adminグループには入れません)。

testuserでログインすると、認証後ページにcognito:groups: ['admin']が表示されました。

このユーザーで/admin/にアクセスすると、adminグループに所属しているため200で管理者ページが表示されました。

一方general-userでログインすると、認証後ページにはcognito:groups: ['user']が表示されました。


このユーザーで/admin/にアクセスすると、adminグループに所属していないため403 Forbiddenが返りました。

同じユーザーでも、所属グループが違うだけで200/403の結果が変わることが確認できました。Djangoのコードはtestuserとgeneral-userを区別する分岐を一切持っておらず、両方とも同じ「cognito:groupsにadminが含まれるか」という1行の判定を通っているだけです。権限の実体(誰が管理者か)はCognito側のグループ所属という形でインフラ層に一元管理されており、アプリはその結果を読むだけの立場になっています。
DBを持つ場合の実装
今回はPoCのためDBを持たせていませんが、実際のアプリケーションには業務データ(ユーザーに紐づく投稿・注文・履歴等)を持つDBが必ずあり、「このデータは誰のものか」をSQLで検索・結合する必要があります。つまり、Cognito上のユーザーとDB上のユーザーレコードを紐付ける仕組みが必要になります。
その際の設計例として以下のような方針があります。
- 紐付けキー: Cognitoがユーザーごとに発行する一意の識別子
subを、DB側のusersテーブルのカラム(cognito_sub等)として持ち、これをキーに紐付ける - DB側にも持たせる情報:
roleカラム等でCognitoのグループ情報のコピーを持たせる。SQLでの検索・他テーブルとの結合・画面表示のたびにCognitoのAPIを呼ばずに済ませるため - 権限の唯一の信頼源はCognito: 書き込み(グループの付け替え)は常にCognito側で行い、DB側の
roleはログイン時などに同期される読み取り用のコピーという位置付けを崩さない - 同期タイミング: 初回サインイン時に自動でレコードを作成する方式(初回プロビジョニング)はあくまで一案。どのタイミングでDB側へ同期するかはアプリケーションの運用に合わせて設計する
前述のadmin_panel()にこの方針を組み込む場合、処理の流れは次のようになります。
- アクセストークンの署名検証(今回のPoCと同じ)
subでDBのユーザーレコードを検索- 存在しなければレコードを作成(初回プロビジョニング)
cognito:groupsの値をDB側のroleに同期- 認可判定(今回のPoCと同じ)
注意点: グループ変更は既存トークンに即時反映されない
権限管理をインフラ層に寄せるメリットの裏返しとして、理解しておくべき特性があります。cognito:groupsはアクセストークン発行時点のスナップショットのため、Cognito側でユーザーの所属グループを付け替えても、そのユーザーが既に持っている有効なアクセストークンには反映されません。反映されるのは、アクセストークンの有効期限が切れてALBがリフレッシュトークンで新しいトークンを取得したとき、または再ログインしたときです(アクセストークンの有効期限はデフォルト1時間で、最短5分まで短縮できます)。
これはこの構成特有の欠点ではなく、トークンベース認証(OIDC/OAuth2)全般に共通する性質です。実運用で権限変更の即時性が求められる場合は、次のような対処を組み合わせます。
- アクセストークンの有効期限を短く設定し、権限変更が反映されるまでの最大タイムラグを許容範囲に収める
- 前述の処理フローの手順4(
roleの同期)で、トークン内のcognito:groupsとDB側のroleを突き合わせ、不一致を検知したらそのまま同期せず強制的にログアウト処理(ALBセッションCookieの失効 + Cognitoのログアウトエンドポイントへのリダイレクト)に流す。再ログイン後は最新のグループ情報でトークンが再発行されるため、権限変更を実質即時に強制できる
最後に
今回見てきたポイントは大きく2つです。
- ALB + Cognitoの
authenticate-cognitoアクションを使うことで、認証のセッション状態をALBとCognitoに完全に寄せられ、ターゲットグループのスティッキーセッションを考慮しなくていい構成になること - 一般ユーザー・管理者という権限情報の管理をCognitoのグループ機能に寄せることで、アプリ側の権限管理の実装・運用を楽にできること。権限情報は署名付きのアクセストークンとして毎リクエスト届くため、アプリは改ざんできない形で認可判定だけを行えばよい
一方で、グループ変更が発行済みトークンに即時反映されない点はトークンベース認証共通の特性として理解した上で、トークン有効期限の設計や、アプリ側での不一致検知と強制ログアウトの組み合わせとセットで検討する必要があります。メリットだけでなくこうした特性まで含めて設計判断することが、認証をインフラ層に寄せる構成をうまく使うポイントです。
Djangoアプリ側で実装が必要だったのは、「ALBが認証済みだと言っているリクエストが本当にCognito発行のものかをJWKSで検証すること」と「cognito:groupsを見て管理者かどうかを判定すること」の2つだけで、認証(ログイン処理そのもの)のロジックはアプリのコードに登場しません。AutoScalingを前提としたコンテナ構成に認証・権限管理を組み込みたい場合の一つの選択肢として、参考になれば幸いです。今回は以上です。








