ログアウト後のブラウザバックを完全に防ぐ ─ Laravel × Inertia.js 実践ガイド
ログアウト後のブラウザバック防止完全ガイド
前回の記事でかっこつけて、Laravel×Inertia.jsでブラウザバックを防ぐという記事を書いたのですが、完全に不完全でした(泣)かなり沼ったので、あとで見返せるように記事にしておきます。
対象読者
・Laravel + Inertia.js で SPA を構築している人
・「ログアウト後に戻るボタンでダッシュボードへ戻れてしまう」問題に悩んでいる人
目次
問題の本質
SPAアプリケーションにおいて、以下のようなセキュリティ上の問題が発生することがあります:
- ユーザーが保護されたページ(例:ダッシュボード)にアクセス
- ログアウトを実行
- ブラウザの戻るボタンをクリック
- キャッシュされた保護ページが表示されてしまう
この問題は以下の2つの要因が組み合わさることで発生します:
-
ブラウザ履歴(History API)
- 直前に表示した DOM をメモリに保持
-
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;
}
決定版ソリューション
核心は「履歴エントリを残さない & 戻る操作を横取り」の二段構え
-
logout リクエストを
replaceで送信router.post(route('logout'), { replace: true })-
/dashboardの履歴を/に上書きし「戻る」で再表示できなくする
-
popstate/navigateイベントで 最新 のauth状態を再評価- Inertia が保持する
router.page.props.authを確認 - 未認証 &
/dashboard配下なら即loginへwindow.location.replace
- Inertia が保持する
[!TIP]
replace と push の違い
push(デフォルト): 新しい履歴エントリを追加replace: 現在のエントリを上書き
今回の目的は「戻る対象そのものを消す」こと なのでreplaceが鍵となります。
初心者向け解説:ブラウザの戻る操作の制御
📚 popstateイベントとは?
ブラウザには「履歴」という機能があります。これは私たちがウェブサイトを見ている時の「足跡」のようなものです。
popstateイベントは、ユーザーが以下のような操作をした時に発生します:
- 🔙 ブラウザの「戻る」ボタンをクリック
- 🔜 ブラウザの「進む」ボタンをクリック
- 📜 JavaScriptで
history.back()やhistory.forward()を実行
🎮 具体例で理解する
たとえば、こんな状況を想像してください:
- あなたがゲームをプレイしていて、セーブデータがある
- ゲームを終了(ログアウト)する
- 「戻る」ボタンでゲームに戻ろうとする
💡 なぜpopstateイベントが必要?
-
セキュリティのため
- ログアウト後に古い画面を見せない
- 個人情報の保護
- 不正アクセスの防止
-
ユーザー体験のため
- 間違った情報を表示しない
- 適切な画面に誘導する
- エラーを防ぐ
🔧 実際のコードではこう動く
// ブラウザの戻る/進むボタンが押された時
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 アプリケーションを実現します。
🎬 映画館のチケットで例えると...
あなたが映画館でチケットを持っている状態を「ログイン状態」、チケットを返却した状態を「ログアウト状態」とします。
-
チケットを持っている時(ログイン状態)
- 映画館内(ダッシュボード)に入れる
- 自由に映画を見られる(機能を使える)
-
チケットを返却した後(ログアウト状態)
- 映画館内には入れない
- でも、もし裏口から入ろうとしたら...?(ブラウザバックで戻ろうとしたら...?)
🔍 どうやって防ぐの?
このように、警備員(popstateイベント)が常に見張っていて:
-
チケットチェック(認証状態の確認)
const auth = (router as any).page?.props?.auth;これは「今、有効なチケットを持っていますか?」というチェック
-
不正入場の防止(未認証時のリダイレクト)
if (!auth?.user && location.pathname.startsWith('/dashboard')) { location.replace(route('login')); }これは「チケットがない人は自動的にエントランス(ログイン画面)に案内」という処理
🎯 実際の動作
-
ユーザーがログアウト
- チケットを返却(認証情報を削除)
-
ブラウザの戻るボタンを押した時
- 裏口から入ろうとする行為に相当
-
popstateイベント = 警備員が作動 - チケットがないことを確認
- 自動的にエントランス(ログイン画面)に案内
-
通常の画面遷移時
- 正面入口から入ろうとする行為に相当
-
navigateイベント = 受付での確認 - 同じくチケットチェックを実施
[!NOTE]
このように、実際の警備システムのように、複数の場所(popstateとnavigate)でチケット(認証状態)をチェックすることで、不正なアクセスを確実に防ぐことができます。
実装手順
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 直叩き対策)
実装時の注意点
-
イベントハンドラの適切な設定
-
popstateとnavigateの両方を監視 - イベントの重複登録に注意
-
-
パス判定の正確性
- 保護対象のパスを正確に定義
- ワイルドカードの使用は慎重に
-
エラーハンドリング
- 予期せぬエラーでリダイレクトが失敗しないよう対策
- ユーザーへの適切なフィードバック
まとめ
| 従来案 | 新ソリューション |
|---|---|
| キャッシュ削除 + ミドルウェア + フックのみ | 履歴上書き (replace) + 監視イベントで認証再評価 |
| 履歴エントリは残るため戻ると表示の可能性 | 履歴自体を消す&戻る操作を横取りで完全遮断 |
この二段構えアプローチにより:
-
完全な防止を実現
- ログアウト後のダッシュボード再表示を100%防止
- 一瞬の表示すら許さない
-
ユーザー体験の向上
- スムーズなリダイレクト
- 不正なアクセスの確実な防止
-
保守性の向上
- 明確な実装パターン
- デバッグのしやすさ
このソリューションを導入することで、セキュアで信頼性の高いSPAアプリケーションを実現できます。
Discussion