🔐

クロスドメイン認証の完全ガイド:JWT・Cookie・CloudFront実装

49
securityauthenticationAWS

クロスドメイン認証の完全ガイド: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.comapp-2.app-1.com は別扱い
  • ポートが違えば別オリジン: :443:3000 は別扱い
  • プロトコルが違えば別オリジン: httpshttp は別扱い

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
  }
}

メリット・デメリット

✅ メリット:

  1. 運用面

    • デプロイ完全独立(app-1 チーム、app-2 チームが互いに影響しない)
    • 障害分離(一方のアプリがダウンしても他方は正常動作)
    • 設定変更リスクなし(CloudFront 設定は即座に反映、ロールバック可能)
  2. 技術面

    • sessionStorage 自動共有(Same-Origin Policy により認証情報が自動共有)
    • パフォーマンス最高(CloudFront の全世界エッジサーバーで高速配信)
    • コスト最適化(S3 + CloudFront は従量課金、サーバー維持費なし)
  3. 開発面

    • 開発環境も同じ構成可能(dev.app-1.com/app-2/* での検証可能)
    • A/B テスト対応(CloudFront Lambda@Edge で割合制御可能)
    • 監視・ログ分離(アプリごとに独立したメトリクス取得)

❌ デメリット:

  1. 初期設定の複雑さ

    • CloudFront 設定の学習コスト
    • S3 バケットポリシー設定
    • SSL 証明書管理(ACM)
  2. デバッグの複雑さ

    • キャッシュによる混乱(更新が反映されない場合)
    • ログ分散(CloudFront ログと S3 ログが分かれている)
    • 404 エラー処理(SPA のルーティング用に Custom Error Page 設定が必要)
  3. 制約事項

    • サーバーサイド処理不可(静的ファイル配信のみ、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マネージド(スケーリング・監視・セキュリティ)
コスト: 従量課金(固定費なし)

なぜこれが最適解?

  1. AWS 環境の特性を活かす

    • CloudFront Behavior による振り分け
    • S3 の独立性とスケーラビリティ
  2. React SPA の特性を活かす

    • 静的ファイル配信なのでサーバースケーリング不要
    • 処理はブラウザ側で実行
  3. 運用面のリアリティ

    • 実際のデプロイフローの独立性
    • 障害時の影響範囲の限定

6.2 実装時のチェックリスト

セキュリティ要件

  • HTTPS 必須
  • JWT 有効期限設定(15 分-1 時間)
  • リフレッシュトークン実装
  • CORS 適切に設定
  • XSS/CSRF 対策

UX 要件

  • ログイン状態の永続化
  • 自動ログアウト機能
  • エラーハンドリング
  • ローディング状態
  • 認証が必要なページの保護

運用要件

  • ログ監視
  • メトリクス収集(ログイン成功率等)
  • 障害時の対応手順
  • ユーザーサポート体制

技術選択チェック

  • ドメイン構成を決定(同ドメイン/サブドメイン/別ドメイン)
  • JWT 保存場所を決定(sessionStorage/localStorage/Cookie)
  • バックエンド API 構成を決定(共通/別々)
  • 外部認証サービスの採用検討(Auth0/Okta/自作)

参考資料

OAuth 2.0 と OpenID Connect

Cookie 設定

AWS CloudFront

IDaaS