クロスドメイン認証の完全ガイド:JWT 共有から実装パターンまで
はじめに
🤔 そもそも「クロスドメイン認証」とは何か?
クロスドメイン認証とは、異なるドメイン(Web サイト)間でユーザーのログイン状態を共有する仕組みのことです。
🏠 身近な例で理解するクロスドメン認証
あなたがGoogleでログインした後、
YouTube、Gmail、Google Drive に移動しても
再度ログインする必要がない
これがクロスドメイン認証の身近な例です!
🎯 なぜクロスドメイン認証が必要なのか?
従来の問題:
app-1.com でログイン → app-2.com に移動 → またログインが必要(面倒!)
クロスドメイン認証の解決:
app-1.com でログイン → app-2.com に移動 → 自動でログイン済み(快適!)
複数の Web アプリケーション間で認証情報を共有したい場合、どのような方法があるのか? この記事では、クロスドメイン認証の基礎から実装パターン、AWS CloudFront を使った実践的な方法まで、段階的に解説する。
この記事で学べること
- 認証の基礎(JWT、Cookie、sessionStorage)
- クロスドメインで認証情報を共有する方法
- OAuth 2.0 と OpenID Connect の仕組み
- AWS CloudFront を使った実装パターン
- 各パターンの比較と選び方
想定読者
- React 等のフロントエンド開発経験がある
- 認証の基礎知識はあるが、クロスドメイン認証は初めて
- 複数アプリ間での認証共有を実装したい
1. 認証の基礎を理解する
1.1 認証とは:家の鍵で考える
認証(Authentication) = 「あなたが本人であることを証明すること」
🏠 身近な例で理解する認証
オフィスビルでの入館システム:
あなた:「私は田中です」(主張)
警備員:「本当に田中さん?証明してください」(確認)
あなた:社員証を見せる(証明)
警備員:「確認できました。これがあなたの入館証です」(トークン発行)
この「入館証」が JWT に相当します。
🔑 なぜ認証が必要なのか?
認証がない場合:
誰でもシステムにアクセスできる
→ 個人情報が盗まれる
→ 不正な操作が行われる
→ システムが危険になる
認証がある場合:
本人確認済みのユーザーのみアクセス可能
→ 安全にシステムを利用できる
→ 個人情報が保護される
→ 信頼できるサービスになる
1.2 JWT とは:デジタルな入館証
JWT(JSON Web Token) = デジタルな入館証
🎫 JWT の構造を理解する
JWT は 3 つの部分で構成されています:
JWT = Header(ヘッダー) + Payload(ペイロード) + Signature(署名)
↓ ↓ ↓
暗号化方式の情報 ユーザー情報 改ざん防止の署名
📋 JWT の中身を見てみよう
// JWTの例(デコードすると以下のような情報が入っている)
{
"userId": "12345", // ユーザーID
"email": "user@example.com", // メールアドレス
"name": "田中太郎", // 表示名
"exp": 1234567890, // 有効期限(Unix時間)
"iat": 1234567800 // 発行時刻(Unix時間)
}
🔍 JWT の特徴を詳しく解説
1. 改ざん不可能性
サーバーが秘密鍵で署名している
→ クライアント側で改ざんすると署名が無効になる
→ サーバーで検証時に「改ざんされた」と判明する
2. 有効期限の管理
exp: 1234567890 // 2025年12月31日 23:59:59
→ 期限切れのJWTは自動的に無効
→ セキュリティが向上(盗まれても期限で無効化)
3. 自己完結型
JWTに必要な情報が全て含まれている
→ サーバーに問い合わせなくても検証可能
→ パフォーマンスが向上(DBアクセス不要)
1.3 トークンの保存場所:3 つの選択肢
JWT をブラウザのどこに保存するか?
🏪 身近な例で理解する保存場所
sessionStorage = 一時的なメモ帳(タブを閉じると消える)
localStorage = 永続的なメモ帳(タブを閉じても残る)
Cookie = 金庫(最も安全だが取り出しにくい)
選択肢 1:sessionStorage
// 保存
sessionStorage.setItem("accessToken", jwt);
// 取得
const token = sessionStorage.getItem("accessToken");
// 削除
sessionStorage.removeItem("accessToken");
特徴を詳しく解説:
✅ メリット:
- タブごとに独立: 複数タブで異なるユーザーでログイン可能
- 自動削除: タブを閉じると自動で消える(セキュリティ向上)
- 簡単な操作: JavaScript で簡単に読み書き可能
❌ デメリット:
- XSS に弱い: 悪意のある JavaScript で盗まれる可能性
- タブ間で共有不可: 別タブで開いたアプリでは認証情報が共有されない
- リロードで消える: ページをリロードすると消える場合がある
選択肢 2:localStorage
// sessionStorageと同じAPI
localStorage.setItem("accessToken", jwt);
特徴を詳しく解説:
✅ メリット:
- 永続保存: タブを閉じても残る(ユーザー体験が良い)
- タブ間で共有: 同じドメインの別タブでも認証情報が共有される
- 簡単な操作: JavaScript で簡単に読み書き可能
❌ デメリット:
- XSS に弱い: 悪意のある JavaScript で盗まれる可能性
- 手動削除が必要: ログアウト時に手動で削除しないと残る
- プライバシー問題: ブラウザを閉じても残るため、共有 PC で問題になる可能性
選択肢 3:HttpOnly Cookie
// バックエンドで設定
res.cookie("accessToken", jwt, {
httpOnly: true, // JavaScriptから読めない(XSS対策)
secure: true, // HTTPS必須
sameSite: "lax", // CSRF対策
});
// フロントエンドでは自動送信される
fetch("/api/user/me", {
credentials: "include", // Cookieを自動送信
});
特徴を詳しく解説:
✅ メリット:
- 最高のセキュリティ: JavaScript から読めない(XSS 攻撃を防ぐ)
- 自動送信: ブラウザが自動でリクエストに含めてくれる
- サブドメイン共有: 設定によりサブドメイン間で共有可能
- サーバー管理: サーバー側で有効期限や削除を制御可能
❌ デメリット:
- JavaScript から操作不可: フロントエンドから直接読み書きできない
- 設定が複雑: バックエンドでの設定が必要
- CSRF 攻撃のリスク: 適切な設定が必要(SameSite 等)
📊 3 つの選択肢の比較表
| 項目 | sessionStorage | localStorage | HttpOnly Cookie |
|---|---|---|---|
| XSS 対策 | ❌ | ❌ | ✅ |
| CSRF 対策 | ✅ | ✅ | ⚠️(SameSite 必要) |
| タブ間共有 | ❌ | ✅ | ✅ |
| サブドメイン共有 | ❌ | ❌ | ✅ |
| セキュリティ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 実装の簡単さ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ |
| ユーザー体験 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
🎯 どの選択肢を選ぶべきか?
開発初期・プロトタイプ:
sessionStorage または localStorage
→ 実装が簡単、素早く開発できる
本格運用・セキュリティ重視:
HttpOnly Cookie
→ 最高のセキュリティ、本格的なサービスに適している
クロスドメイン認証が必要:
HttpOnly Cookie(サブドメイン設定)
→ 複数アプリ間で認証情報を安全に共有できる
2. クロスドメイン認証の課題
2.1 Same-Origin Policy とは:ブラウザのセキュリティルール
Same-Origin Policy = 「異なるドメインのデータは読めない」というブラウザのルール
🏠 身近な例で理解する Same-Origin Policy
銀行Aの金庫の鍵は、銀行Bでは使えない
→ セキュリティのため、異なる銀行間では情報を共有しない
Webでも同じ:
app-1.com の認証情報は、app-2.com では使えない
→ セキュリティのため、異なるドメイン間では情報を共有しない
🔍 実際の動作を確認してみよう
// app-1.comでログイン
sessionStorage.setItem("accessToken", "abc123");
// app-2.app-1.comにアクセス
console.log(sessionStorage.getItem("accessToken"));
// → null(取得できない!)
// なぜ取得できないのか?
// → ブラウザが「セキュリティ上危険」と判断してブロック
🤔 なぜこのルールがあるのか?
Same-Origin Policy がない場合の問題:
悪意のあるサイト evil.com が
→ あなたの銀行サイト bank.com の情報を盗み取る
→ あなたのSNSサイト sns.com の情報を盗み取る
→ あなたの個人情報が全て漏洩する
Same-Origin Policy がある場合:
evil.com は bank.com の情報にアクセスできない
→ セキュリティが保たれる
→ 安心してWebサービスを利用できる
📋 オリジンの定義を詳しく解説
オリジン = プロトコル + ドメイン + ポート
オリジン = https://app-1.com:443
↓ ↓ ↓
プロトコル ドメイン ポート
具体例:
| URL | プロトコル | ドメイン | ポート | オリジン |
|---|---|---|---|---|
https://app-1.com |
https | app-1.com | 443 | オリジン A |
https://app-1.com:3000 |
https | app-1.com | 3000 | オリジン B(ポートが違う) |
https://app-2.app-1.com |
https | app-2.app-1.com | 443 | オリジン C(ドメインが違う) |
http://app-1.com |
http | app-1.com | 80 | オリジン D(プロトコルが違う) |
重要ポイント:
- サブドメインでも別オリジン:
app-1.comとapp-2.app-1.comは別扱い - ポートが違えば別オリジン:
:443と:3000は別扱い - プロトコルが違えば別オリジン:
httpsとhttpは別扱い
2.2 なぜ複数アプリで認証を共有したいのか
🎯 実際のビジネスケース
例 1: 企業の複数サービス
会社の管理システム:
├── 人事管理システム(app-1.com)
├── 経費管理システム(app-2.com)
└── プロジェクト管理システム(app-3.com)
→ 1回ログインすれば全てのシステムが使える
例 2: エコシステムサービス
プラットフォームサービス:
├── メインアプリ(app-1.com)
├── 管理画面(admin.app-1.com)
└── 開発者向けAPI(api.app-1.com)
→ ユーザーは1回のログインで全機能を利用可能
📊 ユーザー体験の比較
❌ 認証共有なしの場合:
ユーザーの操作フロー:
1. app-1.com でログイン(パスワード入力)
2. app-2.com に移動
3. またログイン画面(パスワード再入力)
4. app-3.com に移動
5. またログイン画面(パスワード再入力)
結果:ユーザーは3回もパスワードを入力する必要がある!
✅ 認証共有ありの場合:
ユーザーの操作フロー:
1. app-1.com でログイン(パスワード入力)
2. app-2.com に移動 → 自動でログイン済み
3. app-3.com に移動 → 自動でログイン済み
結果:1回のログインで全てのサービスが利用可能!
💼 ビジネス上のメリット
1. ユーザー体験の向上
- ログイン回数の削減
- 離脱率の低下
- ユーザー満足度の向上
2. 運用効率の向上
- 認証システムの一元管理
- セキュリティポリシーの統一
- メンテナンスコストの削減
3. 開発効率の向上
- 認証機能の重複開発を回避
- 共通の認証ライブラリの活用
- テスト工数の削減
2.3 ドメイン構成の 3 つの選択肢
🏗️ ドメイン構成の基本パターン
| 構成 | 例 | Same-Origin? | 認証共有の難易度 |
|---|---|---|---|
| A. 同ドメイン(パスベース) | app-1.com + app-1.com/app-2 |
✅ 同一 | ⭐(最も簡単) |
| B. サブドメイン | app-1.com + app-2.app-1.com |
❌ 異なる | ⭐⭐⭐(中程度) |
| C. 別ドメイン | app-1.com + app-1-app-2.com |
❌ 異なる | ⭐⭐⭐⭐⭐(困難) |
📋 各構成の詳細解説
A. 同ドメイン(パスベース)
構成例:
├── app-1.com/ ← メインアプリ
├── app-1.com/app-2/ ← 新アプリ
└── app-1.com/admin/ ← 管理画面
メリット:
✅ sessionStorageが自動共有される
✅ 実装が最も簡単
✅ デプロイが単純
デメリット:
❌ 独立性が低い(個別デプロイが困難)
❌ スケーリングが困難
B. サブドメイン
構成例:
├── app-1.com ← メインアプリ
├── app-2.app-1.com ← 新アプリ
└── admin.app-1.com ← 管理画面
メリット:
✅ 各アプリが独立してデプロイ可能
✅ Cookieで認証情報を共有可能
✅ スケーリングが容易
デメリット:
❌ Cookie設定が必要
❌ CORS設定が必要
❌ 実装が複雑
C. 別ドメイン
構成例:
├── app-1.com ← メインアプリ
├── app-1-app-2.com ← 新アプリ
└── admin-app-1.com ← 管理画面
メリット:
✅ 完全に独立したアプリ
✅ 異なる技術スタックが可能
✅ 最大限のスケーラビリティ
デメリット:
❌ 認証共有が最も困難
❌ 複雑な実装が必要
❌ 運用コストが高い
🎯 どの構成を選ぶべきか?
開発初期・MVP:
同ドメイン(パスベース)
→ 実装が簡単、素早く開発できる
本格運用・複数チーム:
サブドメイン
→ 独立性と認証共有のバランスが良い
大規模・エンタープライズ:
別ドメイン
→ 最大限の独立性とスケーラビリティ
3. 認証情報共有の実装パターン
3.1 パターン 1:同一ドメイン(最もシンプル)
構成: app-1.com + app-1.com/app-2
🎯 このパターンの特徴
最もシンプルで実装が簡単
- 追加の設定やコードが不要
- ブラウザの標準機能を活用
- セキュリティリスクが低い
🔄 認証フローの詳細
1. ユーザーが app-1.com にアクセス
2. Google OAuth でログイン
3. サーバーがJWTを発行
4. フロントエンドがJWTをsessionStorageに保存
5. ユーザーが app-1.com/app-2 に移動
6. 同じドメインなのでsessionStorageが自動共有される
7. 新アプリでJWTを取得 → ログイン済み状態
🏠 身近な例で理解する
同じ建物内の部屋移動:
├── 1階の受付で入館証を受け取る
├── 2階の会議室に移動
└── 同じ建物なので入館証が有効
Webでも同じ:
├── app-1.com でログイン(入館証を受け取る)
├── app-1.com/app-2 に移動(同じ建物内)
└── 同じドメインなので認証情報が有効
💻 実装例
// app-1アプリ(app-1.com)
function app-1App() {
const handleLogin = async () => {
// 1. Google OAuthでログイン
const { accessToken } = await loginWithGoogle();
// 2. JWTをsessionStorageに保存
// 同じドメイン内なら自動で共有される
sessionStorage.setItem("accessToken", accessToken);
console.log("ログイン成功!JWTを保存しました");
};
return (
<div>
<button onClick={handleLogin}>Googleでログイン</button>
<Link to="/app-2">新アプリアプリへ</Link>
</div>
);
}
// 新アプリ(app-1.com/app-2)
function app-2App() {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const checkAuth = async () => {
try {
// 3. 同じドメインなのでsessionStorageから自動取得
const token = sessionStorage.getItem("accessToken");
if (token) {
console.log("認証トークンを発見!ユーザー情報を取得中...");
// 4. トークンを使ってユーザー情報を取得
const userInfo = await fetchUserInfo(token);
setUser(userInfo);
console.log("ログイン済み状態で新アプリを開始");
} else {
console.log("認証トークンが見つかりません");
// ログイン画面にリダイレクト
window.location.href = "/login";
}
} catch (error) {
console.error("認証エラー:", error);
// エラー時はログイン画面にリダイレクト
window.location.href = "/login";
} finally {
setIsLoading(false);
}
};
checkAuth();
}, []);
if (isLoading) {
return <div>認証確認中...</div>;
}
return (
<div>
<h1>新アプリ</h1>
<p>ようこそ {user?.name}さん!</p>
<p>ログイン状態が自動で共有されています</p>
</div>
);
}
🔍 実装のポイント
1. sessionStorage の自動共有
// 同じドメイン内では自動で共有される
app-1.com で保存 → app-1.com/app-2 で取得可能
2. エラーハンドリング
// トークンがない場合の処理
if (!token) {
// ログイン画面にリダイレクト
}
3. 非同期処理の適切な処理
// ユーザー情報取得中はローディング表示
if (isLoading) return <div>認証確認中...</div>;
📊 メリット・デメリットの詳細分析
✅ メリット:
1. 実装コスト最小
追加実装がほぼ不要
→ 既存のsessionStorageをそのまま活用
→ 開発時間の大幅短縮
2. ユーザー体験最高
遅延なしで認証状態が共有される
→ ユーザーは追加のログイン不要
→ 離脱率の低下
3. セキュリティが高い
CORS設定不要
→ セキュリティリスクが低い
→ ブラウザの標準機能を活用
❌ デメリット:
1. デプロイの複雑さ
リバースプロキシが必要
→ NginxやApacheの設定が必要
→ 運用コストが増加
2. 独立性の低さ
個別デプロイが困難
→ 一つのアプリの変更が他に影響
→ チーム開発での制約
3. スケーリングの制約
単一サーバーでの運用が前提
→ 大規模なスケーリングが困難
→ パフォーマンスの制約
🎯 このパターンが適しているケース
✅ 適している:
- 開発初期・MVP 段階
- 小規模なチーム開発
- プロトタイプの検証
- 学習目的の実装
❌ 適していない:
- 大規模な本格運用
- 複数チームでの開発
- 高可用性が求められるシステム
- 独立したデプロイが必要な場合
3.2 パターン 2:サブドメイン + Cookie(セキュア)
構成: app-1.com + app-2.app-1.com
🎯 このパターンの特徴
セキュリティと独立性のバランスが良い
- HttpOnly Cookie で最高のセキュリティ
- 各アプリが独立してデプロイ可能
- サブドメイン間で認証情報を安全に共有
🔄 認証フローの詳細
1. ユーザーが app-1.com にアクセス
2. Google OAuth でログイン
3. サーバーがJWTを発行
4. サーバーがJWTをCookieに保存(domain=.app-1.com)
5. ユーザーが app-2.app-1.com に移動
6. ブラウザが自動でCookieを送信
7. 新アプリでCookieからJWTを取得 → ログイン済み状態
🏠 身近な例で理解する
同じ会社の複数ビル:
├── 本社ビル(app-1.com)で入館証を受け取る
├── 支社ビル(app-2.app-1.com)に移動
└── 同じ会社なので入館証が有効
Webでも同じ:
├── app-1.com でログイン(入館証を受け取る)
├── app-2.app-1.com に移動(同じ会社の別ビル)
└── サブドメインなので認証情報が共有される
🔐 Cookie 設定の詳細解説
HttpOnly = JavaScript から隠す
// HttpOnly: false の場合(危険)
document.cookie; // "accessToken=abc123; ..." が見える
// → XSS攻撃でトークンを盗まれる可能性
// HttpOnly: true の場合(安全)
document.cookie; // "accessToken=abc123" が見えない
// → XSS攻撃があっても安全
Secure = HTTPS 必須
// Secure: false の場合(危険)
// http://example.com でもCookieが送信される
// → 盗聴される可能性
// Secure: true の場合(安全)
// https://example.com でのみCookieが送信される
// → 暗号化されているので安全
SameSite = 他サイトからの攻撃を防ぐ
// SameSite: "lax" の場合
// 悪意のあるサイト evil.com から app-1.com へのリクエスト
// → Cookieが送信されない(CSRF攻撃を防ぐ)
// app-1.com 内でのリンククリック
// → Cookieが送信される(正常な操作)
domain = どのドメインで Cookie を共有するか
// domain指定なし(同ドメインのみ)
res.cookie("token", "abc123", {
// app-1.comでのみ有効
});
// domain=.app-1.com(サブドメイン共有)
res.cookie("token", "abc123", {
domain: ".app-1.com",
// app-1.com, app-2.app-1.com, api.app-1.com で有効
});
💻 実装例
バックエンド(app-1 のログイン時)
app.post("/api/auth/google/callback", async (req, res) => {
try {
const { idToken } = req.body;
// 1. Google IDトークンを検証
const googleUser = await verifyGoogleIdToken(idToken);
console.log("Google認証成功:", googleUser.email);
// 2. app-1独自のJWTを発行
const jwt = generateJWT({
userId: googleUser.id,
email: googleUser.email,
name: googleUser.name,
// その他の必要な情報
});
console.log("JWT発行完了");
// 3. Cookieに保存(サブドメイン全体で共有)
res.cookie("accessToken", jwt, {
domain: ".app-1.com", // サブドメイン全体で共有
httpOnly: true, // XSS対策(JavaScriptから読めない)
secure: true, // HTTPS必須
sameSite: "lax", // CSRF対策
maxAge: 3600000, // 1時間(ミリ秒)
});
console.log("Cookie設定完了 - サブドメイン間で共有可能");
res.json({ success: true });
} catch (error) {
console.error("認証エラー:", error);
res.status(401).json({ error: "認証に失敗しました" });
}
});
フロントエンド(新アプリ)
// 新アプリ(app-2.app-1.com)
function app-2App() {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const checkAuth = async () => {
try {
// 1. Cookieが自動で送信されるAPIを呼び出し
const response = await fetch("https://api.app-1.com/user/me", {
credentials: "include", // Cookieを自動送信
});
if (response.ok) {
const userInfo = await response.json();
setUser(userInfo);
console.log("認証成功!ユーザー情報を取得:", userInfo.name);
} else {
console.log("認証失敗 - ログイン画面にリダイレクト");
window.location.href = "https://app-1.com/login";
}
} catch (error) {
console.error("認証エラー:", error);
window.location.href = "https://app-1.com/login";
} finally {
setIsLoading(false);
}
};
checkAuth();
}, []);
if (isLoading) {
return <div>認証確認中...</div>;
}
return (
<div>
<h1>新アプリ</h1>
<p>ようこそ {user?.name}さん!</p>
<p>Cookie経由で認証情報が共有されています</p>
</div>
);
}
🔍 実装のポイント
1. Cookie 設定の重要性
// サブドメイン間で共有するには domain の設定が必須
domain: ".app-1.com"; // ドット(.)を付けることでサブドメイン全体で共有
2. セキュリティ設定
httpOnly: true, // XSS攻撃を防ぐ
secure: true, // HTTPS必須
sameSite: "lax", // CSRF攻撃を防ぐ
3. credentials: "include" の重要性
// Cookieを送信するには credentials: "include" が必須
fetch("/api/user/me", {
credentials: "include", // これがないとCookieが送信されない
});
Cookie 設定の詳細解説
HttpOnly = JavaScript から隠す
// HttpOnly: false の場合(危険)
document.cookie; // "accessToken=abc123; ..." が見える
// → XSS攻撃でトークンを盗まれる可能性
// HttpOnly: true の場合(安全)
document.cookie; // "accessToken=abc123" が見えない
// → XSS攻撃があっても安全
Secure = HTTPS 必須
// Secure: false の場合(危険)
// http://example.com でもCookieが送信される
// → 盗聴される可能性
// Secure: true の場合(安全)
// https://example.com でのみCookieが送信される
// → 暗号化されているので安全
SameSite = 他サイトからの攻撃を防ぐ
// SameSite: "lax" の場合
// 悪意のあるサイト evil.com から app-1.com へのリクエスト
// → Cookieが送信されない(CSRF攻撃を防ぐ)
// app-1.com 内でのリンククリック
// → Cookieが送信される(正常な操作)
domain = どのドメインで Cookie を共有するか
// domain指定なし(同ドメインのみ)
res.cookie("token", "abc123", {
// app-1.comでのみ有効
});
// domain=.app-1.com(サブドメイン共有)
res.cookie("token", "abc123", {
domain: ".app-1.com",
// app-1.com, app-2.app-1.com, api.app-1.com で有効
});
メリット・デメリット
✅ メリット:
- セキュリティ最高(HttpOnly で XSS 対策)
- ユーザー体験最高(遅延なし)
- 実装コスト低
- 独立性高(各アプリが独立してデプロイ可能)
❌ デメリット:
- Cookie 移行が必要(sessionStorage から変更)
- CORS 設定が必須
- JavaScript から直接 JWT を読めない
3.3 パターン 3:Authorization Code Flow(標準的)
構成: app-1.com + app-2.app-1.com(sessionStorage 維持)
認証フロー
1. app-1(app-1.com)で一時コード生成
2. 新アプリにリダイレクト
window.location.href = "https://app-2.app-1.com/auth/callback?code=ABC123"
3. 新アプリでコード検証 → JWT取得
4. sessionStorageに保存 → ログイン済み!
バックエンド API 仕様
1. 一時コード生成 API
POST /api/auth/generate-code
Headers:
Authorization: Bearer <JWT_TOKEN>
Response:
{
"code": "abc123xyz789",
"expiresAt": "2025-10-22T12:05:00Z",
"redirectUrl": "https://app-2.app-1.com/auth/callback?code=abc123xyz789"
}
要件:
- コードは 1 回のみ使用可能(使用後は無効化)
- 有効期限は 5-10 分
- ユーザー ID とコードを紐付けて保存(Redis 等)
2. コード検証 & JWT 返却 API
POST /api/auth/exchange-code
Body: { "code": "abc123xyz789" }
Response:
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "...",
"expiresIn": 3600
}
実装例
// app-1アプリ(リダイレクト)
function useRedirectToapp-2() {
const redirectToapp-2 = useCallback(async () => {
try {
const { redirectUrl } = await generateAuthCode();
window.location.href = redirectUrl;
} catch (error) {
console.error("Failed to redirect:", error);
}
}, []);
return { redirectToapp-2 };
}
// 新アプリ(コールバック処理)
function AuthCallback() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
useEffect(() => {
const handleAuthCallback = async () => {
const code = searchParams.get("code");
if (!code) {
navigate("/login");
return;
}
try {
const { accessToken } = await exchangeAuthCode(code);
sessionStorage.setItem("accessToken", accessToken);
navigate("/");
} catch (error) {
console.error("Failed to exchange code:", error);
navigate("/login");
}
};
handleAuthCallback();
}, [searchParams, navigate]);
return <div>認証中...</div>;
}
メリット・デメリット
✅ メリット:
- sessionStorage 維持(既存実装を変更不要)
- JavaScript からトークン操作が可能
- OAuth 2.0 標準パターン
- 独立性高
❌ デメリット:
- 実装コスト中(バックエンドで API 追加必要)
- ユーザー体験:わずかなリダイレクト遅延(1-2 秒)
- CORS 設定必須
3.4 各パターンの比較と選び方
総合比較表
| 選択肢 | ドメイン | JWT 保存 | 実装コスト | UX | セキュリティ | 独立性 | 推奨度 |
|---|---|---|---|---|---|---|---|
| 1 | 同ドメイン | sessionStorage | 最小 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐ | ⭐⭐⭐ |
| 2 | サブドメイン | Cookie | 低 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ 🏆 |
| 3 | サブドメイン | sessionStorage | 中 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
選択フローチャート
Q1: Cookieへの移行が可能?
├─ はい → パターン2(サブドメイン + Cookie)🏆
└─ いいえ → Q2へ
Q2: sessionStorageを維持したい?
├─ はい → パターン3(Auth Code Flow)
└─ いいえ → パターン2へ
4. 外部認証サービスの活用
4.1 OAuth 2.0:パスワードを教えずに代理アクセス
OAuth 2.0 とは? 「パスワードを教えずに、他のサービスに代わりに何かをやってもらう仕組み」
日常の例で理解する
あなた(ユーザー) = 家の主人
Google(認証プロバイダー) = あなたの秘書
Netflix(クライアントアプリ) = 配達員
シナリオ:
1. 配達員「荷物を届けたいです」
2. あなた「秘書に確認してください」
3. 秘書「本人確認します。パスワードをどうぞ」
4. あなた、秘書にパスワードを入力
5. 秘書「確認できました」→ 配達員に「入場許可証」を渡す
6. 配達員、許可証を見せて家に入る
→ 配達員はあなたのパスワードを知らない!
OAuth 2.0 の実際の流れ
sequenceDiagram
participant U as ユーザー
participant A as アプリ(Netflix)
participant G as Google
U->>A: 1. ログインボタンクリック
A->>G: 2. リダイレクト(client_id付き)
G->>U: 3. Google認証画面表示
U->>G: 4. パスワード入力
G->>A: 5. 認証コード返却
A->>G: 6. 認証コード+秘密鍵で交換
G->>A: 7. アクセストークン発行
A->>G: 8. トークンでAPI呼び出し
G->>A: 9. ユーザー情報返却
実装例
// 1. Googleログインボタン
function GoogleLoginButton() {
const handleLogin = () => {
const clientId = "your-client-id";
const redirectUri = "https://yourapp.com/auth/callback";
const scope = "openid email profile";
const authUrl =
`https://accounts.google.com/o/oauth2/v2/auth?` +
`client_id=${clientId}&` +
`redirect_uri=${redirectUri}&` +
`scope=${scope}&` +
`response_type=code`;
window.location.href = authUrl;
};
return <button onClick={handleLogin}>Googleでログイン</button>;
}
// 2. コールバック処理
function AuthCallback() {
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get("code");
if (code) {
// バックエンドに送ってトークン取得
exchangeCodeForToken(code);
}
}, []);
}
// 3. バックエンドでトークン交換
app.post("/auth/google/callback", async (req, res) => {
const { code } = req.body;
// Googleにトークン交換リクエスト
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
code,
grant_type: "authorization_code",
}),
});
const { access_token } = await tokenResponse.json();
// ユーザー情報取得
const userResponse = await fetch(
"https://www.googleapis.com/oauth2/v2/userinfo",
{
headers: { Authorization: `Bearer ${access_token}` },
}
);
const googleUser = await userResponse.json();
// 自分のJWT発行
const jwt = generateJWT(googleUser);
res.json({ accessToken: jwt });
});
4.2 OpenID Connect:ユーザー情報の取得
OpenID Connect とは? OAuth 2.0 の上に「ユーザー情報取得」機能を追加したもの
OAuth 2.0 = 「代理アクセス許可」
OpenID Connect = OAuth 2.0 + 「ユーザー身元確認」
なぜ OpenID Connect が必要?
// OAuth 2.0だけだと...
const accessToken = 'abc123';
// でも「これは誰のトークン?」がわからない
// OpenID Connectだと...
const { access_token, id_token } = await response.json();
// id_tokenにユーザー情報が入ってる!
// ID Tokenの中身
{
"iss": "https://accounts.google.com", // 発行者
"sub": "1234567890", // ユーザーID
"email": "user@example.com", // メールアドレス
"name": "田中太郎", // 表示名
"picture": "https://...", // プロフィール画像
"iat": 1516239022, // 発行時刻
"exp": 1516242622 // 有効期限
}
OAuth 2.0 vs OpenID Connect
| 項目 | OAuth 2.0 | OpenID Connect |
|---|---|---|
| 目的 | 代理アクセス許可 | ユーザー認証 + 代理アクセス |
| 返却内容 | access_token | access_token + id_token |
| ユーザー情報 | 別途 API 呼び出し必要 | id_token に含まれる |
| 用途 | API 連携 | ログイン |
4.3 IDaaS(Auth0、Okta):認証をサービス化
IDaaS とは? Identity as a Service = 認証機能をクラウドサービスとして提供
自分で作る場合:
OAuth実装 + JWT管理 + ユーザーDB + セキュリティ対策 + ...
IDaaSを使う場合:
Auth0/Oktaが全部やってくれる!
Auth0 の実装例
// 1. セットアップ
import { Auth0Provider } from "@auth0/auth0-react";
function App() {
return (
<Auth0Provider
domain="your-tenant.auth0.com"
clientId="your-client-id"
redirectUri={window.location.origin}
>
<MyApp />
</Auth0Provider>
);
}
// 2. 認証フック
function MyApp() {
const {
loginWithRedirect,
logout,
user,
isAuthenticated,
isLoading,
getAccessTokenSilently,
} = useAuth0();
// 3. APIコール時のトークン取得
const callApi = async () => {
const token = await getAccessTokenSilently({
audience: "https://your-api.com",
});
const response = await fetch("/api/protected", {
headers: { Authorization: `Bearer ${token}` },
});
};
if (isLoading) return <div>Loading...</div>;
return (
<div>
{isAuthenticated ? (
<div>
<img src={user.picture} alt={user.name} />
<h2>{user.name}</h2>
<button onClick={callApi}>Call API</button>
<button onClick={() => logout()}>Logout</button>
</div>
) : (
<button onClick={() => loginWithRedirect()}>Login</button>
)}
</div>
);
}
Auth0 vs Okta vs 自作
| 項目 | Auth0 | Okta | 自作 |
|---|---|---|---|
| 開発速度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| カスタマイズ性 | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| セキュリティ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 運用コスト | 低 | 中 | 高 |
| 初期コスト | 低 | 中 | 高 |
| 学習コスト | 低 | 中 | 高 |
コスト比較
Auth0:
- 無料枠: 7,000 MAU/月
- 有料: $23/月(1,000 MAU)+ 追加 $0.0235/MAU
Okta:
- Developer: $2/月/ユーザー(最低 15 ユーザー)
- Workforce: $8/月/ユーザー
自作:
- 開発: 3-6 ヶ月(エンジニア工数)
- 運用: セキュリティ更新、監視、トラブル対応
プロジェクト規模別推奨
スタートアップ・MVP:
- 推奨: Auth0 無料枠
- 理由: 実装工数最小(1-2 日)、無料で 7,000 MAU
中小企業・本格運用:
- 推奨: Auth0 有料プラン or 自作
- 判断基準: MAU 20,000 人以下 → Auth0、20,000 人以上 → 自作検討
大企業・エンタープライズ:
- 推奨: Okta or 自作
- 理由: 既存 AD 連携、コンプライアンス対応
5. AWS CloudFront を使った実装
5.1 CloudFront Behavior とは:交通整理係
CloudFront の基本概念
CloudFront = AWS の CDN(コンテンツ配信ネットワーク)
従来の考え方:
Nginx → アプリA、アプリB に振り分け
AWSの考え方:
CloudFront → S3 BucketA、S3 BucketB に振り分け
Behavior = 交通整理
日常の例:コンビニの案内係
お客さん: 「パンはどこ?」
案内係: 「/bread/* なので3番棚へ」
お客さん: 「飲み物はどこ?」
案内係: 「/drinks/* なので5番棚へ」
お客さん: 「その他は?」
案内係: 「/* なので1番棚へ」
CloudFront Behavior の実際
{
"behaviors": [
{
"pathPattern": "/app-2/*", // 新アプリ関連のURL
"targetOrigin": "S3-app-2-bucket", // 新アプリのS3
"precedence": 1 // 優先度1(最優先)
},
{
"pathPattern": "/*", // その他すべてのURL
"targetOrigin": "S3-app-1-bucket", // app-1アプリのS3
"precedence": 2 // 優先度2
}
]
}
実際の動作
ユーザーのアクセス → CloudFrontの判定
app-1.com/login → /* にマッチ → app-1アプリ(S3-app-1-bucket)
app-1.com/stream → /* にマッチ → app-1アプリ(S3-app-1-bucket)
app-1.com/app-2 → /app-2/* にマッチ → 新アプリ(S3-app-2-bucket)
app-1.com/app-2/puzzle → /app-2/* にマッチ → 新アプリ(S3-app-2-bucket)
5.2 完全に独立したデプロイの実現
リポジトリ構成(完全分離可能)
📦 app-1-frontend/ ← 別リポジトリ
├── src/
├── package.json
└── .github/workflows/
└── deploy.yml → S3: app-1-bucket
📦 app-2-frontend/ ← 別リポジトリ
├── src/
├── package.json
└── .github/workflows/
└── deploy.yml → S3: app-2-bucket
📦 app-1-backend/ ← 別リポジトリ
└── (ECS用)
📦 app-2-backend/ ← 別リポジトリ
└── (ECS用)
デプロイフロー(完全独立)
app-1 フロントエンド:
# app-1-frontend/.github/workflows/deploy.yml
name: Deploy app-1 Frontend
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build
run: |
npm ci
npm run build
- name: Deploy to S3
run: |
aws s3 sync dist/ s3://app-1-bucket --delete
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id E1234567890 \
--paths "/*" --exclude "/app-2/*"
新アプリフロントエンド:
# app-2-frontend/.github/workflows/deploy.yml
name: Deploy app-2 Frontend
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build
run: |
npm ci
npm run build
- name: Deploy to S3
run: |
aws s3 sync dist/ s3://app-2-bucket --delete
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id E1234567890 \
--paths "/app-2/*"
なぜ完全に独立?
📁 S3構成:
app-1-bucket/ ← app-1アプリ専用
├── index.html
├── static/
└── assets/
app-2-bucket/ ← 新アプリ専用
├── index.html
├── static/
└── assets/
= 物理的に完全分離!
影響範囲:
- app-1アプリ更新 → app-1-bucketのみ変更 → app-2-bucketは無関係
- 新アプリ更新 → app-2-bucketのみ変更 → app-1-bucketは無関係
各チームの独立性
app-1 チーム:
# 自分たちだけで完結
git clone https://github.com/company/app-1-frontend
cd app-1-frontend
npm run dev # ローカル開発
git push # デプロイ自動実行 → S3: app-1-bucket
app-2 チーム:
# 自分たちだけで完結
git clone https://github.com/company/app-2-frontend
cd app-2-frontend
npm run dev # ローカル開発
git push # デプロイ自動実行 → S3: app-2-bucket
5.3 静的サイトのスケーリング
なぜスケーリングが不要?
従来のサーバーアプリケーション:
ユーザー → サーバー(処理実行)→ レスポンス
↑
CPU・メモリ消費!
ユーザー増加 = サーバー負荷増加
↓
オートスケーリング必要(サーバー台数増減)
React SPA + S3 + CloudFront:
ユーザー → 最寄りのエッジサーバー → 静的ファイル配信
↑
ファイル送信のみ!処理なし!
ユーザー増加 = ファイルダウンロード回数増加
↓
AWSが自動で全世界にキャッシュ・配信
CDN の仕組み
東京のユーザー → 東京エッジサーバー
大阪のユーザー → 大阪エッジサーバー
ニューヨークのユーザー → ニューヨークエッジサーバー
= 自動的に負荷分散!
= 設定不要!
= AWSが勝手にスケール!
負荷の違い
従来のサーバー(Node.js 等):
// サーバーで処理が発生
app.get("/api/users", async (req, res) => {
const users = await database.query("SELECT * FROM users"); // DB処理
const processed = users.map((user) => ({
// CPU処理
...user,
fullName: `${user.firstName} ${user.lastName}`,
}));
res.json(processed); // メモリ使用
});
// ユーザー1000人同時アクセス = サーバー負荷1000倍!
React SPA + S3:
<!-- S3に保存された静的ファイル -->
<!DOCTYPE html>
<html>
<head>
<script src="/static/js/main.12345.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
<!-- ユーザー1000人同時アクセス = ファイル配信1000回 -->
<!-- でもAWSが全世界のエッジサーバーで分散処理! -->
CloudFront 設定例
{
"DistributionConfig": {
"Comment": "app-1 + app-2 Apps Distribution",
"DefaultRootObject": "index.html",
"Origins": [
{
"Id": "app-1-origin",
"DomainName": "app-1-bucket.s3.amazonaws.com"
},
{
"Id": "app-2-origin",
"DomainName": "app-2-bucket.s3.amazonaws.com"
}
],
"DefaultCacheBehavior": {
"TargetOriginId": "app-1-origin",
"ViewerProtocolPolicy": "redirect-to-https"
},
"CacheBehaviors": [
{
"PathPattern": "/app-2/*",
"TargetOriginId": "app-2-origin",
"ViewerProtocolPolicy": "redirect-to-https",
"Precedence": 1
}
],
"Enabled": true
}
}
メリット・デメリット
✅ メリット:
-
運用面
- デプロイ完全独立(app-1 チーム、app-2 チームが互いに影響しない)
- 障害分離(一方のアプリがダウンしても他方は正常動作)
- 設定変更リスクなし(CloudFront 設定は即座に反映、ロールバック可能)
-
技術面
- sessionStorage 自動共有(Same-Origin Policy により認証情報が自動共有)
- パフォーマンス最高(CloudFront の全世界エッジサーバーで高速配信)
- コスト最適化(S3 + CloudFront は従量課金、サーバー維持費なし)
-
開発面
- 開発環境も同じ構成可能(dev.app-1.com/app-2/* での検証可能)
- A/B テスト対応(CloudFront Lambda@Edge で割合制御可能)
- 監視・ログ分離(アプリごとに独立したメトリクス取得)
❌ デメリット:
-
初期設定の複雑さ
- CloudFront 設定の学習コスト
- S3 バケットポリシー設定
- SSL 証明書管理(ACM)
-
デバッグの複雑さ
- キャッシュによる混乱(更新が反映されない場合)
- ログ分散(CloudFront ログと S3 ログが分かれている)
- 404 エラー処理(SPA のルーティング用に Custom Error Page 設定が必要)
-
制約事項
- サーバーサイド処理不可(静的ファイル配信のみ、API 処理は別途 ECS 等が必要)
- リアルタイム更新制約(キャッシュクリアに数分かかる場合)
コスト比較
CloudFront + S3 構成:
- CloudFront: $0.085/GB(転送量)
- S3: $0.023/GB(ストレージ)+ $0.0004/1000リクエスト
- 月額固定費: $0
ELB + EC2 構成:
- ELB: $22.5/月
- EC2: $58.4/月(t3.medium × 2台)
- 月額固定費: $80.9/月
→ 小規模〜中規模なら CloudFront + S3 が圧倒的に安い
6. まとめと実装チェックリスト
6.1 各パターンの推奨度
更新された技術評価
| 構成 | 実装コスト | 運用コスト | UX | セキュリティ | 推奨度 |
|---|---|---|---|---|---|
| 同ドメイン + CloudFront | 低 | 低 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ 🏆 |
| サブドメイン + Cookie | 低 | 低 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| サブドメイン + sessionStorage | 中 | 低 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
最終推奨構成
🏆 推奨: 同ドメイン + CloudFront Behavior + sessionStorage
app-1.com (CloudFront Distribution)
├── /* → S3: app-1-bucket (app-1 UI)
└── /app-2/* → S3: app-2-bucket (新アプリ UI)
認証: sessionStorage自動共有(Same-Origin)
デプロイ: 完全独立(別S3 + 別GitHub Actions)
運用: AWSマネージド(スケーリング・監視・セキュリティ)
コスト: 従量課金(固定費なし)
なぜこれが最適解?
-
AWS 環境の特性を活かす
- CloudFront Behavior による振り分け
- S3 の独立性とスケーラビリティ
-
React SPA の特性を活かす
- 静的ファイル配信なのでサーバースケーリング不要
- 処理はブラウザ側で実行
-
運用面のリアリティ
- 実際のデプロイフローの独立性
- 障害時の影響範囲の限定
6.2 実装時のチェックリスト
セキュリティ要件
- HTTPS 必須
- JWT 有効期限設定(15 分-1 時間)
- リフレッシュトークン実装
- CORS 適切に設定
- XSS/CSRF 対策
UX 要件
- ログイン状態の永続化
- 自動ログアウト機能
- エラーハンドリング
- ローディング状態
- 認証が必要なページの保護
運用要件
- ログ監視
- メトリクス収集(ログイン成功率等)
- 障害時の対応手順
- ユーザーサポート体制
技術選択チェック
- ドメイン構成を決定(同ドメイン/サブドメイン/別ドメイン)
- JWT 保存場所を決定(sessionStorage/localStorage/Cookie)
- バックエンド API 構成を決定(共通/別々)
- 外部認証サービスの採用検討(Auth0/Okta/自作)