
Cognito認証 × Next.js × FastAPIで実際にハマった7つの落とし穴と解決策
突然ですが、こんな経験はありませんか?
「Cognitoの認証、とりあえず動いてるけど……なんか不安」
認証まわりの実装って、公式ドキュメントを読んで「なんとなく動いた」状態になりやすいんですよね。
でも実際に本番運用してみると、思いもよらない落とし穴にはまることが多いです。
筆者は最近、AIチャットアプリのリアーキテクチャ対応で FastAPI(BFF)× Next.js × AWS Cognito という構成の認証を再設計しました。
その過程で、ドキュメントには書いていない「実際にハマったこと」がたくさんありました。
この記事では、そのときに遭遇した 具体的な問題とその解決パターンを7つ、まとめてご紹介します。
こんな方に読んでほしい記事です
- Cognito + SPA/BFF構成で認証を実装しているエンジニア
- 「なんとなく動いている」認証をちゃんと作り直したい方
- AWS Cognitoのセキュリティ・UXまわりで悩んでいる方

アーキテクチャの概要(前提の共有)
まず今回の構成を整理しておきます。
Browser (Next.js)
↕ Cookie (session_id)
FastAPI (BFF)
↕ Redis/Valkey(トークン保管)
Cognito (IdP)ポイントは 「トークンをフロントエンドに持たせない設計」 にしたことです。
よくある構成では、JWTトークンをlocalStorageやCookieに直接保存しますが、今回はセッションIDだけをブラウザのCookieに持たせ、実際のトークンはサーバーサイドのRedis/Valkeyに保管しています。
また、Cognito SSOとローカル認証のハイブリッド構成にすることで、開発環境ではローカル認証で素早くテストができるようにしています。
7つのハマりどころと解決策
1. JWKS公開鍵キャッシュと鍵ローテーションの罠
何が起きたか
JWTの検証には、CognitoのJWKS(JSON Web Key Set)エンドポイントから公開鍵を取得する必要があります。
最初のうちは検証のたびにJWKSを毎回取得していたのですが、レスポンスが遅くなるという問題が発生しました。
じゃあキャッシュしよう、と思ってキャッシュを実装すると、今度は Cognito側で鍵ローテーションが起きたときに古い鍵を使い続けてしまう問題が出てきます。
なぜ起きたか
JWKSを長期間キャッシュすると、Cognitoが鍵をローテーションした後も古い公開鍵で検証しようとしてしまうためです。
どう直したか
TTL付きキャッシュ(1時間)+ kid(鍵ID)不一致時の即時再取得フォールバックという方式で解決しました。
class CognitoAuthenticator:
async def _get_public_key(self, kid: str):
# キャッシュに該当するkidがあればそれを使う
if kid in self._jwks_cache:
return self._jwks_cache[kid]
# kidが見つからなければJWKSを再取得(鍵ローテーション対応)
await self._refresh_jwks()
if kid in self._jwks_cache:
return self._jwks_cache[kid]
raise ValueError("公開鍵が見つかりませんでした")通常はキャッシュを使って高速に処理しつつ、kidが一致しないときだけ再取得することで、速度とセキュリティを両立できます。
補足:未知のkidを連発するトークンを送られるとJWKS再取得が繰り返しトリガーされうるため、本番では「同一JWKS URIへの再取得は10秒に1回まで」程度のレート制限を入れておくと安心です(公式の
aws-jwt-verifyも同様の制限を持っています)。
2. トークン有効期限の「3重管理」同期問題
何が起きたか
Cognito認証では、3種類の「期限」が存在します。
トークン | デフォルトの有効期限 |
|---|---|
access_token / id_token | 1時間(5分〜1日で設定可) |
refresh_token | 30日(60分〜10年で設定可) |
セッションTTL | (自分で設定) |
補足:今回はアプリの要件に合わせて、refresh_tokenの有効期限を7日に設定して運用しています。Cognitoのデフォルトは30日なので、表のデフォルト値とは別物です。以降の図では「今回設定した値(7日)」を前提に説明します。
これら3つの期限がバラバラだと、「access_tokenは切れているけどrefresh_tokenはまだ有効」「セッションは切れているのにrefresh_tokenは残っている」という状態が生まれ、ユーザーが意図せずログアウトされてしまいます。
なぜ起きたか
それぞれの期限が独立して管理されており、どこかで同期されていなかったためです。
どう直したか
ミドルウェアで透過的にリフレッシュを行い、セッションTTLをrefresh_tokenの期限に合わせることで解決しました。
access_token(1h) ──┐
├── ミドルウェアが自動リフレッシュ
refresh_token(7d)──┘ ※今回の設定値
↕ 同期
セッションTTL(7d)ユーザーは意識せずに認証状態が維持されるようになりました。
3. withCredentials: true と CORS 設定の相性
何が起きたか
フロントエンドからCookieを自動送信するために withCredentials: true を設定したところ、CORSエラーが発生しました。
なぜ起きたか
withCredentials: true を使う場合、サーバー側の Access-Control-Allow-Origin に *(ワイルドカード)は使えません。明示的なオリジンを指定する必要があります。
どう直したか
FastAPI側のCORS設定を以下のように変更しました。
app.add_middleware(
CORSMiddleware,
allow_origins=["https://your-frontend-domain.com"], # 明示的に指定
allow_credentials=True, # これが必要
allow_methods=["*"],
allow_headers=["*"],
)また、開発環境と本番環境で allow_origins を切り替えられるように、環境変数で管理するようにしています。
補足:厳密には、
allow_credentials=TrueのときはAccess-Control-Allow-Originだけでなく、Access-Control-Allow-HeadersとAccess-Control-Allow-Methodsも仕様上 が使えません。上のコードでallow_methods=[""]/allow_headers=["*"]が動くのは、Starlette/FastAPIのCORSMiddlewareが credentials有効時に実際のリクエストのメソッド・ヘッダーを反映(reflect)して返すためです。次のセクション4で認証チェックをスキップするカスタムヘッダー(本記事では仮にX-Custom-Auth-Skipとします)を使いますが、これもプリフライト時にミドルウェアが許可ヘッダーとして反映してくれます。
4. 401レスポンス時の aggressive な localStorage.clear()
何が起きたか
認証切れ(401エラー)を検知したときに、localStorage.clear() でストレージを全消去していたところ、テーマ設定や言語設定なども一緒に消えてしまうという問題が発生しました。
ユーザーからすると「ログインしたら設定がリセットされた」という残念な体験になってしまいます。
補足:これはリアーキテクチャ前(旧実装)の話です。旧実装ではトークンや認証情報をlocalStorageに保存していました。今回のBFF化でトークン自体はサーバー側(Redis/Valkey)に移し、フロントが持つのはセッションIDのCookieだけになっています。以下では、BFF化後の構成に合わせた改善版を紹介します。
なぜ起きたか
エラーハンドリングで全消去する実装が、認証関連以外のデータまで巻き込んでいたためです。
どう直したか
BFF構成では、ログアウト時にクリアすべきなのはサーバー側のセッションとセッションCookieです。localStorage.clear() で全消去するのではなく、認証に関係する状態だけを、保存先ごとにピンポイントで消すようにしました。
async function clearAuthState() {
// サーバー側セッション + セッションCookieを破棄
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
// タブ単位のセッションフラグ → sessionStorage
sessionStorage.removeItem('authSessionActive');
// 永続側のフラグ・表示用情報 → localStorage(テーマ等は残す)
['hasLoggedInBefore', 'auth_user_display'].forEach(
key => localStorage.removeItem(key)
);
}ポイントは、キーごとに保存先(sessionStorage / localStorage)を正しく分けて削除することです。authSessionActive は「タブ単位のログイン中フラグ」なので sessionStorage に置いており、localStorage 側で消そうとしても空振りしてしまいます。
トークンそのものはフロントに存在しないため、消す対象はあくまで「認証フラグ」や「サーバーセッション」です。テーマ設定などのユーザー設定は巻き込まれません。
また、認証チェックをスキップするカスタムヘッダー(本記事では仮に X-Custom-Auth-Skip とします。実際のヘッダー名は任意です)をつけたAPIリクエストは認証不要として、そのAPIが401を返しても clearAuthState() を呼ばないようにしています。なおこれはあくまでフロント側の制御用フラグで、サーバー側の認証をバイパスするものではありません。
5. SSOコールバックのCSRFリスク(stateパラメータ)
何が起きたか
OAuth2のSSOコールバック実装を見直したところ、**state パラメータの検証がない**ことに気づきました。
これはCSRF攻撃のリスクにつながります。
なぜ起きたか
state パラメータの存在は知っていましたが、「SameSite Cookieを設定しているから大丈夫」と思い込んでいたためです。
しかし、SameSite Cookieだけでは防げないケースもあります(たとえばサブドメイン間のリダイレクトなど)。
どう直したか
1. ログイン開始時:
stateをランダム生成 → セッションに紐づけて保存 → Cognito認証URLに付与
2. コールバック時:
callbackのstateパラメータ
↕ 検証
セッションに保存したstate(一致しなければ拒否)セッションに紐づいた state を生成し、コールバックで検証するフローに変更しました。
6. リフレッシュトークン期限切れ時のUX設計
何が起きたか
7日ぶり(=今回設定したrefresh_tokenの期限)にアプリを開いたユーザーが、何の説明もなく突然ログイン画面に飛ばされるという問題がありました。
「あれ?なんで?バグ?」と戸惑ってしまうケースがありました。
なぜ起きたか
セッション期限切れ時の処理が、単純なリダイレクトになっていたためです。
どう直したか
「セッションの有効期限が切れました」というモーダルを表示してから、再ログインに誘導するフローに変更しました。
// 永続側(localStorage):以前ログインしていたかの記録
const wasLoggedIn = localStorage.getItem('hasLoggedInBefore') === 'true';
// セッション側(sessionStorage):このタブでセッションが生きているか
const isSessionActive = sessionStorage.getItem('authSessionActive') === 'true';
if (!isSessionActive && wasLoggedIn) {
// 「以前ログイン済み」かつ「今はセッションが無効」→ 期限切れとみなす
showSessionExpiredModal();
} else {
// 初回訪問など、未ログイン状態として通常リダイレクト
router.push('/login');
}補足:
authSessionActiveはsessionStorageに持たせているため、タブを閉じて開き直すと消えます。そのため「期限切れモーダルを出すか/普通にリダイレクトするか」の分岐は、実質的に永続側のwasLoggedIn(localStorageのhasLoggedInBefore)に依存します。このフラグはログイン成功時にtrueをセットし、明示的なログアウト時に削除します。なお、ここでlocalStorageに持たせているのは「過去にログインしたことがあるか」という真偽値だけで、トークンは保存していません(BFF構成の方針どおりです)。
「何が起きたか」をユーザーに伝えるだけで、戸惑いや問い合わせを減らせる効果が期待できます。
7. ハイブリッド認証(Cognito + ローカル)でのトークン判別
何が起きたか
開発環境ではローカル認証、本番ではCognito認証を使うハイブリッド構成にしたところ、どちらのプロバイダで検証すべきかサーバーが判断できないという問題が起きました。
なぜ起きたか
CognitoのJWTとローカル認証の独自トークンが、同じ Authorization ヘッダーで送られてくるためです。
どう直したか
最初は「ドット区切りで3パートならJWT(Cognito)」という構造での判別を考えました。
def detect_token_provider_simple(token: str) -> str:
# 簡易策:ドット区切り3パートならCognitoとみなす
return 'cognito' if len(token.split('.')) == 3 else 'local'ただしこの方法は脆いです。ローカル認証側もJWTでトークンを発行していると同じ3パート構造になり、Cognitoと誤判定してしまいます。自前認証でJWTを使うのはよくあるので、現実的に踏みやすい罠です。
そこで本番では、JWTペイロードの iss(発行者)で判定する方法に変更しました。Cognitoが発行するトークンの iss は https://cognito-idp.{region}.amazonaws.com/{poolId} という決まった形式になります。
import json, base64
def detect_token_provider(token: str) -> str:
parts = token.split('.')
if len(parts) != 3:
return 'local' # JWT構造でなければローカル確定
try:
# ペイロード部をデコードして iss を確認(※検証は別途行う)
padded = parts[1] + '=' * (-len(parts[1]) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded))
iss = payload.get('iss', '')
except Exception:
return 'local'
# Cognitoの発行者URLかどうかで判定
if iss.startswith('https://cognito-idp.') and 'amazonaws.com' in iss:
return 'cognito'
return 'local'補足:構造(ドット3分割)での判別はあくまで簡易策です。本番ではこの
iss判定がおすすめです。あるいは、ローカルトークンにlocal_などの接頭辞を付けて明示的に分岐する方法も堅牢です。なお、ここでの判別は「どのプロバイダで検証するか」を振り分けるだけなので、実際の署名検証は振り分け先で必ず行ってください。
開発環境ではローカル認証で素早くテストでき、本番はCognitoで検証できるため、開発体験が大きく向上しました。
全体を通して学んだ設計指針
7つのハマりどころを紹介してきましたが、共通している教訓をまとめると以下の4点です。
① トークンはフロントに露出させない
セッションストア方式を採用することで、JWTが直接ブラウザに渡らなくなり、XSSのリスクを大幅に下げられます。
② キャッシュには必ずフォールバックを用意する
JWKS公開鍵のように、外部サービス依存のキャッシュは「キャッシュミス時に再取得できる」設計が必須です。
③ 認証エラーのUXは「何が起きたか」をユーザーに伝える
「なぜログアウトされたのか」が分からないのは、ユーザーにとって不親切な体験です。モーダルやメッセージで状況を説明しましょう。
④ テストはユニット + E2Eの両輪で
JWTの検証ロジックはユニットテストでしっかりカバーし、認証フロー全体(ログイン → API呼び出し → トークンリフレッシュ)はE2Eテストで確認するのが理想的です。
おわりに
認証は「動いている」と「正しく実装されている」の差がとても大きい領域です。
ちょっとした設定ミスやUXの抜け漏れが、セキュリティリスクやユーザー離れにつながることもあります。
この記事で紹介したハマりどころが、皆さんの実装の参考になれば幸いです!


