登入與 Session:不閃爍、不外洩、會過期
session 存哪裡、怎麼在伺服器就知道使用者是誰、token 怎麼續期而不讓使用者被踢出去,以及三種 UI 狀態。
1. 存哪裡:cookie,不是 localStorage
| localStorage | cookie (HttpOnly) | |
|---|---|---|
| JS 讀得到 | 是 → XSS 一發全拿 | 否 |
| 伺服器讀得到 | 否 → 第一幀一定是錯的 | 是 |
| 自動附在請求上 | 否 | 是 |
| CSRF 風險 | 低 | 需要 SameSite |
結論:session token 放 HttpOnly cookie。
cookies.set('session', token, {
httpOnly: true, // JS 讀不到,XSS 偷不走
secure: true, // 只走 HTTPS
sameSite: 'lax', // 擋大部分 CSRF,又不會弄壞外部連結進站
path: '/',
maxAge: 60 * 60 * 24 * 30
});
sameSite: 'lax' 是預設的正確答案:從 Google 點連結進站時 cookie 仍會送出(否則使用者會發現自己「莫名其妙登出了」),但跨站 POST 不會。
2. 在伺服器就知道使用者是誰
這是「登入後畫面閃一下才變成已登入」的根治方法。
// hooks.server.ts
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get('session');
event.locals.user = token ? await verifySession(token) : null;
return resolve(event);
};
// +layout.server.ts
export const load = ({ locals }) => ({ user: locals.user });
於是伺服器輸出的 HTML 一開始就是正確的狀態。Next.js 在 Server Component 裡讀 cookies(),Nuxt 用 server middleware,概念相同。
反面示範(每個專案都會寫一次的錯):
// 伺服器渲染時 user 是 null → 先畫出「登入」按鈕
// hydrate 後才讀到 → 換成頭像
// 使用者看到閃爍,而且 CLS 分數變差
useEffect(() => { setUser(JSON.parse(localStorage.getItem('user'))) }, []);
3. 三種 UI 狀態,不是兩種
type SessionState =
| { status: 'authenticated'; user: User }
| { status: 'anonymous' }
| { status: 'loading' };
loading 只會出現在純客戶端的情境(例如登入後的樂觀更新)。如果你照第 2 節做,伺服器渲染的頁面根本不需要 loading 狀態。
三種都要設計:
authenticated→ 頭像 + 名字anonymous→ 登入按鈕loading→ 跟前兩者同尺寸的骨架,不是會轉的圈圈(尺寸不同會造成位移)
4. Token 續期
短命的 access token + 長命的 refresh token 是標準做法。難的是續期時不要讓使用者感覺到。
三個必須處理的細節
一、併發請求只能續一次。 五個請求同時收到 401,如果各自去 refresh,你會產生五個新 session,而且多半有四個會失效。
let refreshing: Promise<string> | null = null;
async function getFreshToken(): Promise<string> {
// 已經有人在續了就等他,不要自己再發一次
refreshing ??= doRefresh().finally(() => { refreshing = null; });
return refreshing;
}
二、續期失敗要乾淨地登出。 清 cookie、清 client 快取、導到登入頁,並且帶上原本要去的網址:
redirect(303, `/login?next=${encodeURIComponent(url.pathname + url.search)}`);
三、next 參數必須驗證。
不驗證就是開放重導向漏洞:
const next = url.searchParams.get('next') ?? '/';
const safe = next.startsWith('/') && !next.startsWith('//') ? next : '/';
5. 登入表單
autocomplete="username"與autocomplete="current-password"—— 沒有這兩個,密碼管理員不會運作,使用者會開始用弱密碼- 註冊用
autocomplete="new-password" - 不要分別提示「帳號不存在」與「密碼錯誤」 —— 那等於送給攻擊者一個帳號列舉工具。統一講「帳號或密碼不正確」
- 錯誤訊息放在一個
aria-live區域,不要每個欄位一個 - 送出時按鈕 disabled,但寬度不能變,否則版面會跳
6. 登出
登出必須是 POST,不是 GET。
GET 登出代表任何人都能用 <img src="/logout"> 把你的使用者登出。而且瀏覽器與各種預抓工具會自動請求 GET 連結 —— 有人真的因此讓所有使用者在瀏覽時被隨機登出。
<form method="POST" action="/logout">
<button type="submit">登出</button>
</form>
伺服器端要做的:清 cookie、讓 session 在資料庫失效(只清 cookie 等於 token 還活著)、清掉客戶端快取。
7. 權限
- 每一個 API 端點都要驗擁有權,不只是驗登入
- 前端隱藏按鈕是體驗,不是安全 —— 後端仍然要擋
- 權限錯誤回 404 而不是 403,可以避免洩漏「這個資源存在」
8. 檢查清單
- session 在 HttpOnly + Secure + SameSite cookie
- 伺服器渲染時就知道使用者是誰,畫面不閃
- 三種 session 狀態都設計了,loading 骨架尺寸正確
- refresh 有併發鎖
- 續期失敗會乾淨登出並記住原本要去的頁面
-
next/redirect參數有驗證 - 登入表單有正確的 autocomplete
- 錯誤訊息不區分帳號與密碼
- 登出是 POST,而且會讓伺服器端 session 失效