実践編: AWS で公開 Web アプリケーションを構築する
Cookie と CSRF のアンチパターン集 と RFC 10017 で挙げた論点を、実際の AWS 構成に落とす。
同じ「認証付きの公開 Web アプリ」でも、セキュリティ・可用性・コストのどこを取るかで構成が変わる。ここでは代表的な4案を比較する。
このドキュメントの目的
構成を選ぶ判断材料を持つことが目標です。
- 各案が防げる脅威・防げない脅威
- フレームワーク / ライブラリの選択が何を決めるか
- 可用性とコストの増減要因
AWS の料金は変動し、リージョン・使用量・契約形態で大きく変わります。ここでは**何に対して課金されるか(コスト要因)**と相対的な大小のみ扱います。実際の見積もりは AWS Pricing Calculator を使ってください。
1. 決めることは2つ
構成の違いは、独立した2つの判断の組み合わせで説明できます。
| 判断すること | 決まるもの | |
|---|---|---|
| ① | トークンをどこに置くか | アプリ層の脅威。XSS で盗まれるか、即時失効できるか |
| ② | 画面をサーバで描くか、ブラウザで組むか | データ露出・CSP・BFF の責務。画面と BFF が一体か分離か |
本質的な分岐は①です。②は BFF を選んだあとの設計判断で、詳しくは7章で扱います。
4案はこの組み合わせ
| 案 | ① トークンの置き場所 | ② 画面と BFF |
|---|---|---|
| A ブラウザ直結 | ブラウザ(JS から読める) | 静的 SPA(BFF なし) |
| B SSR 一体型 BFF | サーバ(セッションストア) | 一体。Next.js が UI と BFF を兼ねる |
| C SPA + 独立 BFF | サーバ(セッションストア) | 分離。S3 の SPA と Fargate の BFF |
| D SPA + ステートレス BFF | ブラウザ(暗号化 Cookie・JS からは読めない) | 分離。BFF がストアを持たない |
①は「出す / 出さない」の二択ではありません。案D はその中間で、トークンはブラウザ上にあるが JS からは読めない、という位置に立ちます。
案D は分離型(SPA + 独立 BFF)の代表例として示していますが、トークンを Cookie に載せる選択は一体型でも取れます。実際 Auth.js の既定は JWT 戦略(=ステートレス)なので、案B の構成でそのまま使うと案D と同じ性質になります(4章)。「BFF を置いた = サーバがトークンを保持している」ではありません。
コンテナかサーバーレスか(Fargate / Lambda)、リソースサーバをインターネットから閉じるか、といった判断はアプリ層のセキュリティ特性を変えません。インフラ層の対策は9章にまとめます。
判断フロー
① トークンをどこに置くか
│
├─ ブラウザ(JS から読める)─────────────────────→ 案A
│
├─ サーバ(セッションストアに保持)
│ │
│ └ ② 画面をどう描くか
│ ├─ サーバで描く(SSR)───────────────→ 案B
│ └─ ブラウザで組む(SPA)─────────────→ 案C
│
└─ ブラウザ(暗号化 Cookie・ストアを持たない)───→ 案D
迷ったら案B。トークンをブラウザから外すことが、RFC 10017 が推す方向であり、効果が最も大きい。SSR 一体型は②の打ち手(7章)も自然に安全側へ倒れます。
案C が劣るわけではありません。 フロントとバックを別チームで回せる、画面を CDN に載せられるといった利点があります。ただし分離したぶん、7章の論点を自分で塞ぐ必要があります。
案D はストアを1つ減らす代わりに、即時失効を差し出します。 XSS には HttpOnly で耐えますが、ログアウトしてもトークンは exp まで生き、端末を取られれば Cookie ごと抜かれます(6章)。
2. 前提
登場人物
本ドキュメントは リソースサーバが独立して存在する 前提で書いています。認可サーバ(IdP)を使う以上、保護対象の API が別に居るのが通常の構成です。
| 本文の呼び方 | OAuth のロール | 役割 |
|---|---|---|
| IdP / 認可サーバ | Authorization Server | トークンを発行する |
| BFF | Client(confidential) | トークンを取得し、提示する |
| API / RS | Resource Server | トークンを検証し、リソースを返す |
| ブラウザ | User Agent | ロールではない |
ブラウザ ──────→ BFF ──Bearer──→ API
│ ↑
Client Resource Server
│
└──認可コードフロー──→ 認可サーバ
**BFF(Backend For Frontend)**とは、認可サーバとのやり取りを引き受け、トークンをブラウザの JS から隔離するサーバのことです。ブラウザに返すのはセッション Cookie(案B・C)か、暗号化したトークン入り Cookie(案D)です。OAuth のロールとしては confidential クライアントで、フロントエンドアプリの構成要素にあたります(リバースプロキシや API Gateway とは別物)。
リソースサーバは自社の API とは限りません。IdP の利用者が運用する API であることも多く、その場合は自分たちの裁量が及ぶのは BFF までになります。
脅威は2層に分かれる
| 層 | 脅威 | 主な対策の置き場所 |
|---|---|---|
| アプリ層 | XSS、CSRF、トークン窃取、セッション乗っ取り | フレームワーク / ライブラリ / 構成の選択 |
| インフラ層 | DDoS、ボット、脆弱性スキャン、TLS | WAF / CloudFront / Shield / ACM |
この2層は代替関係にありません。 WAF を入れても XSS が消えるわけではなく、CSP を入れても DDoS は止まりません。両方要ります。
認証だけに OAuth を使う構成は対象外
ログイン(OIDC)だけに使い、保護された API を呼ばない構成では、リソースサーバもアクセストークンも登場しません。その場合は本ドキュメントの比較軸の多くが当てはまらないため、セッションセキュリティを参照してください。
3. 案A: ブラウザ直結(BFF なし)
ブラウザ自身が OAuth クライアントになり、アクセストークンを直接リソースサーバに提示する。
ブラウザ(Client)
│
├── CloudFront ──── S3(静的 SPA)
│
├──Bearer──→ API Gateway ──── Lambda(Resource Server)
│
└──認可コードフロー(PKCE)──→ 認可サーバ
リソースサーバはインターネットに公開されている必要があります。ブラウザが直接叩くためです。
別サイトでも成立します。 Authorization ヘッダは JS が明示的に付けるため、Cookie のような同一サイト制約がありません(CORS の設定は要る)。BFF 構成が同一サイトに収める必要があるのと対照的です。
言い換えると、Cookie を使わないことで構成の自由度を得て、代わりにトークンをブラウザに晒しているということです。RFC 10017 が「すべての攻撃シナリオに脆弱」と評価する構成にあたります(詳細は 8.1 の比較表)。
フレームワーク / ライブラリ
| 役割 | 選択肢 | 注意点 |
|---|---|---|
| SPA | React / Vue / Svelte(静的ビルド) | — |
| OAuth クライアント | oidc-client-ts、@auth0/auth0-spa-js | state / PKCE / nonce を自前で管理する |
| 認可サーバ | Cognito User Pool、Auth0、idp-server | — |
可用性・コスト
| 可用性 | 高い。CloudFront / S3 / API Gateway / Lambda はいずれもマネージドで冗長化済み |
| コスト | 最小。リクエスト課金のみで、アイドル時はほぼゼロ |
| 運用負荷 | 低い。サーバの面倒を見る箇所が無い |
向くケース
- 公開情報中心で、扱う権限が読み取り主体
- アクセストークンの寿命を数分に絞れる
- トラフィックが読めない、またはゼロの時間帯が長い
選ぶ場合は代償を減らす
トークンがブラウザに出ることを自覚したうえで、影響範囲を絞ります。
- アクセストークンの寿命を数分にする
- リフレッシュトークンを発行しない(またはローテーション + 再利用検知)
- スコープを最小化する
resource(RFC 8707)で単一リソースに限定する- リソースサーバ側で
audを検証する(RFC 9068)。ブラウザが直接叩く以上、宛先の確認はリソースサーバの責務になる
ただし保存場所を絞るほどリロードで認証状態が消えます。メ モリのみに置く構成は窃取の窓が最も狭い一方、リロードのたびに認証をやり直すことになります(7章)。
4. 案B: SSR 一体型 BFF
BFF がトークンを保持し、リソースサーバへ提示する。ブラウザにはセッション Cookie だけを渡す。画面も同じサーバが描く。
ブラウザ
│ https://app.example.com(単一オリジン)
│ セッション Cookie(HttpOnly)
▼
CloudFront ──── ALB ──── ECS Fargate(Next.js + Auth.js)※ Client
│ UI と Route Handler の両方を提供
│
├── ElastiCache( セッション)
│
├──Bearer──→ リソースサーバ
│
└──認可コードフロー──→ 認可サーバ
守る対象がトークンからセッションに移ります。セッションはサーバ側で即座に無効化できるぶん、取り消せないトークンより扱いやすい。代わりに Cookie を使う以上、CSRF 対策が必須になります。
Next.js が UI と Route Handler の両方を提供するため、画面と BFF が同じプロセスに同居します(②が「一体」)。ブラウザから見えるオリジンは1つで、画面はサーバ側で描かれます。この結合が何を楽にするかは7章で扱います。
フレームワーク / ライブラリ
| 役割 | 選択肢 | 備考 |
|---|---|---|
| フロント + BFF | Next.js(App Router) | Route Handler が BFF(Client)になる |
| 認証 | Auth.js(NextAuth) | CSRF・セッションが組み込み |
| セッションストア | JWT 戦略(Cookie に JWE)/ Database 戦略(ElastiCache・RDS) | 下記参照 |
Auth.js は D-3 の signed double submit cookie を実装しています。ランダム値をサーバの secret と連結して SHA256 し、<token>|<hash> を HttpOnly / host-only Cookie に格納。素の double submit と違い、攻撃者が Cookie を仕込んでも hash を作れません。
Java で組む場合は Spring Boot + Spring Security が同じ位置に来ます。サーバーサイドセッションと CSRF 対策が既定で有効なので、Cookie ベースの前提に自然に乗ります(8.6 で比較)。
セッションストアの選択(BFF 共通)
Database 戦略を選んだら、次はどこに置くかです。案B / C 共通の判断で、可用性・即時失効・コストが決まります。左端の Cookie(JWE)列だけは性質が違い、これを選ぶとストアを持たない構成=案D(6章)になります。
| Cookie(JWE) | ElastiCache(ノードベース) | ElastiCache Serverless | DynamoDB | |
|---|---|---|---|---|
| 即時失効 | できない | できる | できる | できる |
| 可用性 | ス トアが無いので落ちない | 構成次第。既定の単一ノードは単一障害点 | 3 AZ に自動で冗長化(99.99% SLA) | 3 AZ に自動レプリケーション(99.99% SLA) |
| Multi-AZ の代償 | — | 別 AZ にレプリカが必要 → ノード数と料金が倍 | 不要(既定で有効) | 不要(既定で有効) |
| フェイルオーバー時 | 影響なし | 直近の書き込みが失われうる(非同期レプリケーション) | 同左(非同期) | — |
| 課金 | 無し | ノード稼働時間 | 従量(下限あり) | 従量(オンデマンド) |
| アイドル時のコスト | 無し | かかる | 小さい | ほぼ無し |
| サイズ制限 | 4KB | 実質なし | 実質なし | 400KB / 項目 |
| VPC への配置 | 不要 | 必要 | 必要 | 不要(VPC エンドポイント可) |
| 期限切れの掃除 | Cookie の expiry | TTL で即時 | TTL で即時 | TTL は遅延あり |
判断に効くのは次の4点です。
- ElastiCache は「Multi-AZ にして初めて」可用性が確保される。 単一ノードのままだとセッションストアが単一障害点になり、落ちれば全ユーザーが一斉にログアウトします。Multi-AZ にはシャードごとにプライマリ+別 AZ のレプリカが必要で、ノード数と料金が倍になります。DynamoDB と ElastiCache Serverless は既定で 3 AZ に冗長化されるため、この判 断自体が発生しません。
- フェイルオーバーでセッションが消えることがある。 ElastiCache のレプリケーションは非同期なので、昇格の直前に書かれたセッションは失われます。「ログイン直後のユーザーだけが弾かれる」という形で出ます。ゼロデータロスが要るなら Valkey 9.0 以降の durability(Multi-AZ トランザクションログによる同期書き込み)を検討します。
- アイドル時のコストは、コンピュートよりセッションストアで決まることがある。 Fargate を止められなくても、ElastiCache を DynamoDB に替えれば常時課金は減ります。案B / C の「アイドル時課金あり」は Fargate だけの話ではありません。Multi-AZ 化でノードが倍になることを考えると差はさらに開きます。
- DynamoDB を使うなら2つ注意する。 TTL による削除は即時ではなく通常48時間以内なので、有効期限の判定はアプリ側で
expires_atを見て行う(TTL は掃除専用と考える)。また既定の読み取りは結果整合性なので、セッション ID 再生成の直後に古い状態を読む可能性があります。強整合性読み取りを使ってください。
Cookie(JWE)はストアを1つも増やさずに済む(=ストア障害という失敗モードが存在しない)代わりに、C-2 の Cookie 4KB 制約と失効不可をそのまま引き受けます。
なお Memcached はセッションストアに使えません。レプリケーションもフェイルオーバーも持たないため、Multi-AZ 構成そのものが提供されていません。
ドメイン構成(BFF 共通)
Cookie を使う以上、Web と BFF をどのドメインに置くかが防御力を左右します。案B / C に共通する判断です。
| 構成 | 例 | Cookie は送られるか | SameSite | CORS |
|---|---|---|---|---|
| 同一オリジン | app.example.com のみ | ○ | Lax でOK | 不要 |
| サブドメイン違い(同一 site) | app.example.com / api.example.com | ○ | Lax でOK | 必要 |
| 別サイト | app.example.com / api.other.com | ✗ | None 必須 | 必要 |
分かれ目は アンチパターン集の前提 にある判定単位のズレです。
- SameSite は site 単位(schemeful eTLD+1)→ サブドメイン違いは同一サイト扱いなので Cookie は送られる
- CORS はオリジン単位(
scheme://host:port)→ サブドメインが違えば別オリジンなので CORS が要る
案B は Next.js が UI と Route Handler の両方を提供するため、自然に単一オリジンになります。案C のように画面と BFF を分ける場合も、CloudFront のパス振り分け(/* → S3、/api/* → ALB)で単一オリジンに保てます。
可用性・コスト
| 可用性 | Multi-AZ で確保。Fargate タスクを複数 AZ に配置し、ALB で分散。セッションストアを Multi-AZ にしないとそこが単一障害点として残る(上記) |
| コスト | 中。Fargate と ALB は起動 している限り課金される。アイドル時もゼロにならない |
| 運用負荷 | 中。コンテナイメージの更新、タスク数の調整、セッションストアの運用 |
コスト要因:
| 要素 | 課金 |
|---|---|
| ALB | 時間課金 + LCU |
| Fargate | vCPU / メモリ × 稼働時間 |
| ElastiCache | ノード稼働時間(Database 戦略時)。Multi-AZ にするとレプリカ分が上乗せ |
| NAT Gateway | 時間課金 + データ処理量。トラフィックが少なくても定額が乗る |
NAT Gateway は見落とされやすい項目です。Fargate をプライベートサブネットに置いて外部 IdP や外部 API を呼ぶなら必要になります。VPC エンドポイントで代替できる通信(S3 / DynamoDB / ECR など)は置き換えると削減できます。
向くケース
- 状態変更を伴う操作が中心
- 即時ログアウトが要件
- 常時ある程度のトラフィックがある
5. 案C: SPA + 独立 BFF
画面は静的 SPA として S3 から配り、BFF は独立したサービスとして動かす。トークンの扱いは案B と同じで、違うのは画面と BFF が分かれていること。
ブラウザ
│ すべて https://app.example.com(単一オリジン)
│ セッション Cookie(HttpOnly)
▼
CloudFront ──┬── /* → S3(静的 SPA)
│
└── /api/* → ALB ──→ Fargate(BFF)※ Client
├ Cookie を検証しセッションを引く
├ セッションからアクセストークンを取り出す
├ Authorization: Bearer を付けて RS へ転送
│
├── ElastiCache(セッション)
│
├──Bearer──→ リソースサーバ
│
└──認可コードフロー──→ 認可サーバ
CloudFront をパスで振り分けることで、画面と BFF は分離していてもオリジンは1つに保てます。ここを分けるとドメイン構成の問題(4章)を引き受けることになるので、単一オリジンに収めるのが基本です。
案B との違い
| 案B | 案C | |
|---|---|---|
| ① トークンの置き場所 | BFF | BFF(同じ) |
| ② 画面と BFF | 一体(同一プロセス) | 分離(S3 / Fargate) |
| 画面の配信 | Next.js が SSR で返す | S3 から静的配信 |
| デプロイ単位 | 1つ | 2つ |
セキュリティ特性は①の観点では案B と同じです。差が出るのは②の4点(7章)で、いずれも案C 側が自分で塞ぐ側に回ります。
BFF と業務 API の分業
BFF を独立させると、BFF とリソースサーバの責務の違いがはっきりします。
| OAuth ロール | 知っていること | |
|---|---|---|
| BFF | Client | 誰がログインしているか(セッション) |
| 業務 API | Resource Server | 何をしてよいか(トークンのスコープ) |
BFF は業務ロジックを持ちません。 Cookie をトークンに載せ替えて転送するだけです。 業務 API は Cookie を知りません。 トークンだけを見ます。
この分業により、BFF が侵害されても攻撃者が得るのは「BFF ができること」までで、DB への直接アクセスは残りません。ただしその範囲は BFF の API 設計で決まります(7章)。