はじめに
在庫・出荷管理アプリのデモサイトを公開後、重大なセキュリティ問題に気づきました。
「前の訪問者のログイン状態が次の訪問者に引き継がれる」
デモサイトとして致命的な問題です。この記事では、問題の発見から解決までのプロセスを詳しく解説します。
問題の発見
きっかけ
デモサイトの動作確認中、ふと疑問が浮かびました。
「これって、誰かがログインしたら、次の人もそのままログイン状態になるんじゃ…?」
試しに確認してみると、やっぱりそうでした。
問題のシナリオ
- 訪問者Aが「demo-trader」(流通業者アカウント)でログイン
- ダッシュボードで作業
- ブラウザを閉じる(ログアウトせずに)
- 訪問者Bが同じURL(shipment-demo.cropgarage.com)にアクセス
- 訪問者Bが自動的にtraderとしてログイン状態に!
デモサイトなので実害は少ないものの、他人のアカウントで勝手にログインされるのは問題です。
原因の分析
Supabase認証の仕組み
Supabaseの認証システムは、ログイン情報をブラウザのlocalStorageに保存します。
// localStorageに保存される情報
{
"sb-xxxxx-auth-token": {
"access_token": "eyJhbGc...",
"refresh_token": "...",
"user": {
"id": "...",
"email": "demo-trader@example.com"
}
}
}
問題の本質
- localStorageは同じドメインで共有される
- ブラウザを閉じてもlocalStorageは消えない
- 次の訪問者が来ても、セッション情報が残ったまま
つまり、デモサイトでは:
- 全訪問者が同じドメイン(shipment-demo.cropgarage.com)を共有
- 前の人のセッションが残っていれば、次の人も自動ログイン
これは設計上の問題ではなく、デモサイト特有の課題です。
解決策の検討
3つの案を検討しました。
案1: トップページアクセス時に強制ログアウト
useEffect(() => {
const isDemoMode = process.env.NEXT_PUBLIC_DEMO_MODE === "true";
if (isDemoMode) {
await supabase.auth.signOut();
setUserProfile(null);
return;
}
// 本番版は既存処理
}, []);
メリット:
- シンプル
- 実装が簡単
デメリット:
- トップページを経由しないと効果がない
- ブラウザを閉じただけではログアウトされない ❌
案2: セッションタイムアウト(5分間操作なし)
useEffect(() => {
if (isDemoMode && userProfile) {
let timeoutId: NodeJS.Timeout;
const resetTimeout = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(async () => {
await supabase.auth.signOut();
window.location.href = '/';
}, 5 * 60 * 1000); // 5分
};
// マウス移動や操作でタイマーリセット
const events = ['mousemove', 'keydown', 'click', 'scroll'];
events.forEach(event => {
window.addEventListener(event, resetTimeout);
});
resetTimeout();
return () => {
clearTimeout(timeoutId);
events.forEach(event => {
window.removeEventListener(event, resetTimeout);
});
};
}
}, [userProfile]);
メリット:
- 操作中はログイン維持(使いやすい)
- 放置されたセッションは自動クリア
デメリット:
- 5分以内に次の訪問者が来たら引き継がれる可能性
案3: ブラウザを閉じたら即座にログアウト
useEffect(() => {
if (isDemoMode && userProfile) {
const handleBeforeUnload = async () => {
await supabase.auth.signOut();
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}
}, [userProfile]);
メリット:
- ブラウザを閉じたら確実にログアウト
- 次の訪問者に影響しない
デメリット:
- ページリロードでもログアウトされる可能性
採用した解決策:案2 + 案3の組み合わせ
実装内容
- ブラウザを閉じたら即座にログアウト(案3)
- 5分間操作なしで自動ログアウト(案2)
// デモ版:ブラウザを閉じたら即座にログアウト + 5分間操作なしでもログアウト
useEffect(() => {
const isDemoMode = process.env.NEXT_PUBLIC_DEMO_MODE === "true";
if (isDemoMode && userProfile) {
let timeoutId: NodeJS.Timeout;
// 5分間操作なしでログアウト
const resetTimeout = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(async () => {
await supabase.auth.signOut();
setUserProfile(null);
setError(null);
window.location.href = '/';
}, 5 * 60 * 1000); // 5分
};
// ブラウザを閉じる時に即座にログアウト
const handleBeforeUnload = async () => {
await supabase.auth.signOut();
setUserProfile(null);
setError(null);
};
// 操作検知でタイマーリセット
const events = ['mousemove', 'keydown', 'click', 'scroll'];
events.forEach(event => {
window.addEventListener(event, resetTimeout);
});
// ブラウザを閉じる時のイベント
window.addEventListener('beforeunload', handleBeforeUnload);
resetTimeout(); // 初回タイマー開始
return () => {
clearTimeout(timeoutId);
events.forEach(event => {
window.removeEventListener(event, resetTimeout);
});
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}
}, [userProfile]);
エラー画面の修正
当初、ログアウト後に「エラーが発生しました」画面が表示される問題がありました。
原因: セッションがない状態をcheckUser関数がエラーとして扱っていた
解決: エラー状態をクリアして正常にトップページを表示
useEffect(() => {
const checkUser = async () => {
try {
setIsLoading(true);
setError(null); // ✅ エラーをクリア
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
setUserProfile(null);
setIsLoading(false);
return; // ✅ 正常に終了(トップページ表示)
}
// ... ユーザー情報取得処理 ...
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "エラーが発生しました";
setError(errorMessage);
} finally {
setIsLoading(false);
}
};
checkUser();
}, []);
実装の詳細
修正箇所1: checkUser関数の改善
// 修正前
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
setUserProfile(null);
setIsLoading(false);
return;
}
// 修正後
setError(null); // ✅ エラー状態をクリア
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
setUserProfile(null);
setIsLoading(false);
return;
}
修正箇所2: タイムアウト処理の追加
checkUserのuseEffectの直後に新しいuseEffectを追加:
// デモ版:セキュリティ対策
useEffect(() => {
const isDemoMode = process.env.NEXT_PUBLIC_DEMO_MODE === "true";
if (isDemoMode && userProfile) {
// タイムアウトとbeforeunloadの処理
}
}, [userProfile]);
動作確認
テストシナリオ
シナリオ1: ブラウザを閉じる
- demo-traderでログイン
- ダッシュボード表示
- ブラウザを閉じる
- 再度アクセス
- 結果: トップページ(ランディングページ)が表示 ✅
シナリオ2: 5分間放置
- demo-traderでログイン
- ダッシュボード表示
- 5分間何も操作しない
- 結果: 自動的にログアウト → トップページへリダイレクト ✅
シナリオ3: 操作中
- demo-traderでログイン
- ダッシュボードで作業(クリック、スクロールなど)
- 5分経過
- 結果: タイマーがリセットされ、ログイン状態維持 ✅
本番版への影響
本番版は何も変わりません。
const isDemoMode = process.env.NEXT_PUBLIC_DEMO_MODE === "true";
if (isDemoMode && userProfile) {
// デモ版のみこの処理が実行される
}
環境変数 NEXT_PUBLIC_DEMO_MODE で動作を切り替えているため、本番版では:
- ログイン状態維持
- 自動ログアウトなし
- ブラウザを閉じてもセッション継続
学んだこと
1. localStorageの特性
特徴:
- 同じドメインで共有される
- ブラウザを閉じても消えない
- 手動で削除するまで残り続ける
デモサイトでの注意点:
- 公開デモは不特定多数が同じドメインを使う
- セッション管理を適切に行わないとセキュリティリスク
2. beforeunloadイベントの活用
window.addEventListener('beforeunload', handleBeforeUnload);
用途:
- ブラウザを閉じる直前の処理
- タブを閉じる直前の処理
- ページを離れる直前の処理
注意点:
- 非同期処理(async/await)は完了しない可能性
- 確実に実行されるとは限らない(ベストエフォート)
- ただしログアウト処理は軽量なので実用上問題なし
3. イベントリスナーのクリーンアップ
ReactのuseEffectでは、クリーンアップ関数でイベントリスナーを削除することが重要:
useEffect(() => {
// イベントリスナー登録
window.addEventListener('beforeunload', handleBeforeUnload);
// クリーンアップ(コンポーネント破棄時に実行)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [userProfile]);
理由:
- メモリリークを防ぐ
- 意図しない動作を防ぐ
- パフォーマンスの向上
4. 環境変数による動作切り替え
const isDemoMode = process.env.NEXT_PUBLIC_DEMO_MODE === "true";
同じコードベースで:
- デモ版: 自動ログアウト有効
- 本番版: 自動ログアウト無効
環境変数で動作を切り替えることで、コードの重複を避けられます。
今後の改善案
案1: より短いタイムアウト
現在は5分ですが、デモサイトなら3分でも良いかもしれません。
timeoutId = setTimeout(async () => {
await supabase.auth.signOut();
window.location.href = '/';
}, 3 * 60 * 1000); // 3分
案2: ログアウト前に警告表示
自動ログアウトの30秒前に警告を表示:
// 4分30秒後に警告表示
setTimeout(() => {
alert('30秒後に自動ログアウトします');
}, 4.5 * 60 * 1000);
// 5分後にログアウト
setTimeout(async () => {
await supabase.auth.signOut();
window.location.href = '/';
}, 5 * 60 * 1000);
ただし、デモサイトでは警告は不要かもしれません(操作中はタイマーリセットされるため)。
案3: セッション情報の完全削除
現在はsupabase.auth.signOut()のみですが、念のためlocalStorageも手動でクリア:
const handleBeforeUnload = async () => {
await supabase.auth.signOut();
// 念のため手動でもクリア
Object.keys(localStorage)
.filter(key => key.startsWith('sb-'))
.forEach(key => localStorage.removeItem(key));
};
まとめ
デモサイト特有のセキュリティ問題を、二重の防御で解決しました。
実装内容
- ✅ ブラウザを閉じたら即座にログアウト
- ✅ 5分間操作なしで自動ログアウト
- ✅ エラー画面の修正(正常にトップページ表示)
重要なポイント
- localStorageの特性を理解する
- beforeunloadイベントを活用する
- 環境変数で動作を切り替える
- 本番環境への影響を最小限に
デモサイトと本番サイトの違い
| 項目 | デモ版 | 本番版 |
|---|---|---|
| 自動ログアウト | ✅ 有効 | ❌ 無効 |
| ブラウザを閉じる時 | 即座にログアウト | セッション継続 |
| 5分間操作なし | 自動ログアウト | セッション継続 |
| 対象ユーザー | 不特定多数 | 特定ユーザー |
公開デモサイトでは、セキュリティとユーザビリティのバランスが重要です。今回の実装で、安心してデモを体験してもらえる環境が整いました。
デモサイト公開後の気づき
実は、この記事で紹介したセキュリティ問題はデモサイト公開後に気づいたものです。
公開URL: https://shipment-demo.cropgarage.com
当初は「とりあえず動けばOK」という感覚で公開しましたが、実際に使ってみると様々な問題が見えてきました:
- ✅ タイムゾーンバグ(前回記事で解決)
- ✅ ログイン状態の引き継ぎ問題(今回解決)
- ⚠️ モバイル表示の微調整が必要
- ⚠️ OCR精度の改善余地あり
- ⚠️ ローディング表示の最適化
デモサイトは「完成品」ではなく「成長するプロダクト」だと実感しています。
実際に公開してみて初めて分かる問題も多く、随時更新しながら改善を続けています。
「完璧になってから公開」ではなく、「公開しながら改善」のスタイルで進めることで、リアルな開発プロセスを体験できています。
引き続き、気づいた問題を一つずつ解決していきます!
