😂

ログアウト後のブラウザバックを完全に防ぐ ─ Laravel × Inertia.js 実践ガイド

に公開

ログアウト後のブラウザバック防止完全ガイド

前回の記事でかっこつけて、Laravel×Inertia.jsでブラウザバックを防ぐという記事を書いたのですが、完全に不完全でした(泣)かなり沼ったので、あとで見返せるように記事にしておきます。

対象読者
・Laravel + Inertia.js で SPA を構築している人
・「ログアウト後に戻るボタンでダッシュボードへ戻れてしまう」問題に悩んでいる人

目次

  1. 問題の本質
  2. ブラウザキャッシュと履歴の仕組み
  3. 従来のアプローチとその限界
  4. 決定版ソリューション
  5. 実装手順
  6. ベストプラクティス
  7. まとめ

問題の本質

SPAアプリケーションにおいて、以下のようなセキュリティ上の問題が発生することがあります:

  1. ユーザーが保護されたページ(例:ダッシュボード)にアクセス
  2. ログアウトを実行
  3. ブラウザの戻るボタンをクリック
  4. キャッシュされた保護ページが表示されてしまう

この問題は以下の2つの要因が組み合わさることで発生します:

  1. ブラウザ履歴(History API)
    • 直前に表示した DOM をメモリに保持
  2. Inertia.js のクライアントキャッシュ
    • ページ遷移時の JSON レスポンスをキャッシュし、戻る操作を瞬時に再描画

ブラウザキャッシュと履歴の仕組み

キャッシュの基本動作

ブラウザは以下の目的でページをキャッシュします:

  • 高速なページ表示
  • サーバー負荷の軽減
  • ネットワークトラフィックの削減

History APIとInertia.jsの相互作用

従来のアプローチとその限界

多層防御アプローチ

レイヤー 内容 限界
1️⃣ キャッシュコントロール Cache-Control: no-store, no-cache... を返す 履歴に残る DOM まで消せない
2️⃣ サーバーミドルウェア auth チェックで未認証なら 302 ブラウザバックではヒットしない
3️⃣ クライアント監視 React フックで auth.user を監視 描画済みページの復元を防げない

[!WARNING]
これらの対策を組み合わせても、「一瞬だけダッシュボードが見える」 もしくは 完全に防げない ケースが残ります。

従来の実装例

// app/Http/Middleware/HandleInertiaRequests.php
public function handle(Request $request, Closure $next)
{
    $response = parent::handle($request, $next);

    if ($request->user()) {
        $response->header('Cache-Control', 'no-store, no-cache, must-revalidate');
        $response->header('Pragma', 'no-cache');
        $response->header('Expires', '0');
    }

    return $response;
}
// resources/js/hooks/useAuth.ts
export function useAuth() {
    const { auth } = usePage().props;
    const navigate = useNavigate();

    useEffect(() => {
        if (!auth.user) {
            navigate('/login');
        }
    }, [auth.user]);

    return auth;
}

決定版ソリューション

核心は「履歴エントリを残さない & 戻る操作を横取り」の二段構え

  1. logout リクエストを replace で送信

    • router.post(route('logout'), { replace: true })
    • /dashboard の履歴を / に上書きし「戻る」で再表示できなくする
  2. popstate / navigate イベントで 最新auth 状態を再評価

    • Inertia が保持する router.page.props.auth を確認
    • 未認証 & /dashboard 配下なら即 loginwindow.location.replace

[!TIP]
replace と push の違い

  • push (デフォルト): 新しい履歴エントリを追加
  • replace: 現在のエントリを上書き
    今回の目的は「戻る対象そのものを消す」こと なので replace が鍵となります。

初心者向け解説:ブラウザの戻る操作の制御

📚 popstateイベントとは?

ブラウザには「履歴」という機能があります。これは私たちがウェブサイトを見ている時の「足跡」のようなものです。

popstateイベントは、ユーザーが以下のような操作をした時に発生します:

  1. 🔙 ブラウザの「戻る」ボタンをクリック
  2. 🔜 ブラウザの「進む」ボタンをクリック
  3. 📜 JavaScriptでhistory.back()history.forward()を実行

🎮 具体例で理解する

たとえば、こんな状況を想像してください:

  1. あなたがゲームをプレイしていて、セーブデータがある
  2. ゲームを終了(ログアウト)する
  3. 「戻る」ボタンでゲームに戻ろうとする

💡 なぜpopstateイベントが必要?

  1. セキュリティのため

    • ログアウト後に古い画面を見せない
    • 個人情報の保護
    • 不正アクセスの防止
  2. ユーザー体験のため

    • 間違った情報を表示しない
    • 適切な画面に誘導する
    • エラーを防ぐ

🔧 実際のコードではこう動く

// ブラウザの戻る/進むボタンが押された時
window.addEventListener('popstate', (e) => {
    // セーブデータ(ログイン情報)をチェック
    const auth = (router as any).page?.props?.auth;

    // もしログアウト済み&ゲーム画面なら
    if (!auth?.user && location.pathname.startsWith('/dashboard')) {
        // タイトル画面(ログイン画面)に強制移動
        location.replace(route('login'));
    }
});

[!NOTE]
このように、popstateイベントは「ブラウザの履歴を行き来する時のガードマン」のような役割を果たします。
ユーザーが意図しない(または許可されていない)画面に戻ることを防ぎ、安全な Web アプリケーションを実現します。

🎬 映画館のチケットで例えると...

あなたが映画館でチケットを持っている状態を「ログイン状態」、チケットを返却した状態を「ログアウト状態」とします。

  1. チケットを持っている時(ログイン状態)

    • 映画館内(ダッシュボード)に入れる
    • 自由に映画を見られる(機能を使える)
  2. チケットを返却した後(ログアウト状態)

    • 映画館内には入れない
    • でも、もし裏口から入ろうとしたら...?(ブラウザバックで戻ろうとしたら...?)

🔍 どうやって防ぐの?

このように、警備員(popstateイベント)が常に見張っていて:

  1. チケットチェック(認証状態の確認)

    const auth = (router as any).page?.props?.auth;
    

    これは「今、有効なチケットを持っていますか?」というチェック

  2. 不正入場の防止(未認証時のリダイレクト)

    if (!auth?.user && location.pathname.startsWith('/dashboard')) {
        location.replace(route('login'));
    }
    

    これは「チケットがない人は自動的にエントランス(ログイン画面)に案内」という処理

🎯 実際の動作

  1. ユーザーがログアウト

    • チケットを返却(認証情報を削除)
  2. ブラウザの戻るボタンを押した時

    • 裏口から入ろうとする行為に相当
    • popstateイベント = 警備員が作動
    • チケットがないことを確認
    • 自動的にエントランス(ログイン画面)に案内
  3. 通常の画面遷移時

    • 正面入口から入ろうとする行為に相当
    • navigateイベント = 受付での確認
    • 同じくチケットチェックを実施

[!NOTE]
このように、実際の警備システムのように、複数の場所(popstatenavigate)でチケット(認証状態)をチェックすることで、不正なアクセスを確実に防ぐことができます。

実装手順

1. ログアウト処理を replace 付きで送信

// resources/js/components/user-menu-content.tsx
const handleLogout = () => {
    cleanup(); // ナビゲーション UI の後始末
    router.post(route('logout'), {
        replace: true, // ← これがポイント
        preserveState: false,
        preserveScroll: false,
    });
};

2. popstate & navigate で認証を再チェック

// resources/js/app.tsx
const preventBackAfterLogout = () => {
    // ブラウザバック
    window.addEventListener('popstate', (e) => {
        const auth = (router as any).page?.props?.auth;
        if (!auth?.user && location.pathname.startsWith('/dashboard')) {
            e.preventDefault();
            location.replace(route('login'));
        }
    });

    // 初期表示(リロード直後)にも適用
    if (location.pathname.startsWith('/dashboard')) {
        const auth = (router as any).page?.props?.auth;
        if (!auth?.user) location.replace(route('login'));
    }
};

createInertiaApp({
    // 省略
    setup({ el, App, props }) {
        // Inertia 内部遷移
        router.on('navigate', (event) => {
            const page: any = event.detail.page;
            if (!page.props?.auth?.user && page.url.startsWith('/dashboard')) {
                event.preventDefault();
                location.replace(route('login'));
            }
        });

        preventBackAfterLogout();
        root.render(<App {...props} />);
    },
});

3. サーバーサイドの二重保険

// app/Http/Middleware/HandleInertiaRequests.php
public function handle(Request $request, Closure $next)
{
    $response = parent::handle($request, $next);

    if ($request->user()) {
        $response->header('Cache-Control', 'no-store, no-cache, must-revalidate');
        $response->header('Pragma', 'no-cache');
        $response->header('Expires', '0');
    }

    return $response;
}

ベストプラクティス

[!CHECK] セキュリティチェックリスト

  • router.post(..., { replace: true }) で履歴上書き
  • popstate 監視でブラウザバック検知
  • router.on('navigate') で Inertia 内部遷移を検知
  • サーバー側 Cache-Control: no-store(二重保険として継続)
  • ミドルウェアによる認証チェック(API 直叩き対策)

実装時の注意点

  1. イベントハンドラの適切な設定

    • popstatenavigate の両方を監視
    • イベントの重複登録に注意
  2. パス判定の正確性

    • 保護対象のパスを正確に定義
    • ワイルドカードの使用は慎重に
  3. エラーハンドリング

    • 予期せぬエラーでリダイレクトが失敗しないよう対策
    • ユーザーへの適切なフィードバック

まとめ

従来案 新ソリューション
キャッシュ削除 + ミドルウェア + フックのみ 履歴上書き (replace) + 監視イベントで認証再評価
履歴エントリは残るため戻ると表示の可能性 履歴自体を消す&戻る操作を横取りで完全遮断

この二段構えアプローチにより:

  1. 完全な防止を実現

    • ログアウト後のダッシュボード再表示を100%防止
    • 一瞬の表示すら許さない
  2. ユーザー体験の向上

    • スムーズなリダイレクト
    • 不正なアクセスの確実な防止
  3. 保守性の向上

    • 明確な実装パターン
    • デバッグのしやすさ

このソリューションを導入することで、セキュアで信頼性の高いSPAアプリケーションを実現できます。

Discussion