前端登入狀態設計
前端登入狀態最容易混亂的地方,是把「使用者資料」、「登入憑證」、「權限資料」、「UI 狀態」全部塞在同一個 store 裡。結果刷新頁面後狀態不一致、跨 tab 登出不同步、token refresh race、SSR hydration mismatch、甚至把敏感 token 暴露在 JavaScript 裡。
一句話回答:前端登入狀態設計要先分清楚憑證、使用者資料、權限與 UI 狀態:HttpOnly Cookie 模式下前端不讀 token,而是透過 /me 恢復使用者狀態;Bearer Token 模式下 access token 優先短效並放 memory,refresh token 要更嚴格保護。Store 裡應保存可展示的 user/profile/permissions/loading/error,不應保存密碼或長效敏感憑證;同時要處理 401、refresh mutex、跨 tab logout、路由守衛、SSR hydration 與登出清理。
一、 必考觀念
1. 前端登入狀態不等於安全真相
前端可能有:
const isLogin = ref(true)但真正能不能存取 API,要看 server 是否接受目前憑證。
所以前端狀態只是:
目前 UI 認為使用者可能已登入。安全真相在後端:
session 是否有效
token 是否有效
帳號是否停用
權限是否足夠延伸讀:登出與 Session 管理。
2. 分清楚四種資料
| 類型 | 例子 | 前端能不能存 |
|---|---|---|
| 憑證 | session cookie、access token、refresh token | 要謹慎 |
| 使用者資料 | id、email、displayName | 可以,但要可重新取得 |
| 權限資料 | roles、permissions、menus | 可以,但後端仍要授權 |
| UI 狀態 | loading、error、redirect | 可以 |
壞味道:
authStore = {
token,
refreshToken,
password,
user,
permissions,
loading,
formDraft,
}這會讓安全邊界變得混亂。
3. 前端不應保存密碼
登入送出後,密碼不應該:
- 放 localStorage。
- 放 Pinia。
- 放 URL query。
- 放 analytics。
- 放 error log。
- 放跨頁共享狀態。
密碼只應短暫存在登入表單裡。
二、 憑證放哪裡?
1. HttpOnly Cookie 模式
Session Cookie 或 refresh token cookie:
Set-Cookie: __Host-sid=...; HttpOnly; Secure; SameSite=Lax; Path=/前端看不到 cookie。
流程:
頁面載入
-> GET /me
-> server 根據 cookie 查 session
-> 回 user profile
-> 前端更新 auth store優點:
- JavaScript 不能直接偷 cookie。
- 適合 Web app。
- 前端不必手動管理 token。
缺點:
- 要處理 CSRF。
- 跨域 cookie / CORS 設定較複雜。
- 前端不能單靠讀 cookie 判斷登入。
延伸讀:Session 與 Cookie 登入機制、Cookie 安全設計。
2. Memory access token
Access token 放在 memory:
const accessToken = ref<string | null>(null)優點:
- 刷新頁面後消失。
- XSS 長期偷取難度較高。
缺點:
- 重新整理後要靠 refresh token 或 session 恢復。
- 多 tab 狀態要同步。
適合:
access token 短效
refresh token 在 HttpOnly Cookie 或安全儲存3. localStorage
localStorage 優點是簡單:
localStorage.setItem('accessToken', token)但最大問題是 XSS:
localStorage.getItem('accessToken')攻擊者一旦能執行 JS,就可能偷 token。
所以不建議把長效 refresh token 放 localStorage。若不得不用,access token 必須短效,而且要非常重視 XSS 防護。
4. Cookie vs localStorage 不是絕對答案
常見誤解:
Cookie 一定安全
localStorage 一定不安全更精準地說:
| 儲存 | 主要風險 |
|---|---|
| HttpOnly Cookie | CSRF、XSS 代發請求、Cookie 設定錯誤 |
| localStorage | XSS 直接讀取 token |
| memory | 刷新後消失、跨 tab 管理較難 |
選型要看架構與威脅模型。
三、 Auth Store 放什麼?
1. 建議狀態
以 Vue / Pinia 為例:
export const useAuthStore = defineStore('auth', () => {
const user = ref<UserProfile | null>(null)
const permissions = ref<string[]>([])
const status = ref<'unknown' | 'authenticated' | 'anonymous'>('unknown')
const loading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => status.value === 'authenticated')
return {
user,
permissions,
status,
loading,
error,
isAuthenticated,
}
})重點:
unknown表示尚未確認。anonymous表示確認未登入。authenticated表示/me成功或登入成功。
不要一開始就把 isLogin 預設成 false,否則頁面可能先閃到 login 再跳回來。
2. Store 不一定要持久化
User profile 可以從 /me 恢復。
權限也可以從 /me 或 /permissions 恢復。
所以不一定需要:
persist: true特別不要把敏感 token 跟著 store persist 到 localStorage。
3. 權限只是 UI 狀態,不是安全邊界
前端可以用 permissions:
- 顯示 menu。
- 顯示 button。
- 路由守衛。
- disabled 控制。
但 API 還是要後端授權。
延伸讀:Vue 權限與路由守衛設計。
四、 啟動時恢復登入狀態
1. App 初始化
async function bootstrapAuth() {
const authStore = useAuthStore()
authStore.loading = true
try {
const me = await authApi.me()
authStore.user = me.user
authStore.permissions = me.permissions
authStore.status = 'authenticated'
} catch (error) {
if (isUnauthorized(error)) {
authStore.status = 'anonymous'
return
}
authStore.error = '無法確認登入狀態'
authStore.status = 'unknown'
} finally {
authStore.loading = false
}
}2. unknown 狀態很重要
頁面初始時:
不知道是否登入不應立刻導 login。
可以顯示:
- skeleton。
- app loading。
- 保留目前頁面。
等 /me 回來後再判斷。
3. /me 應該回什麼?
{
"user": {
"id": "user_123",
"email": "user@example.com",
"displayName": "Hirimu"
},
"permissions": ["order:read", "profile:update"],
"session": {
"mfaVerified": true,
"lastAuthenticatedAt": "2026-05-24T10:00:00Z"
}
}避免回:
- passwordHash。
- reset token。
- mfaSecret。
- refresh token。
- 過多內部資料。
五、 路由守衛
1. 守衛要等待 auth 初始化
router.beforeEach(async to => {
const authStore = useAuthStore()
if (authStore.status === 'unknown') {
await authStore.bootstrap()
}
if (to.meta.public) {
return true
}
if (!authStore.isAuthenticated) {
return {
path: '/login',
query: {
redirect: to.fullPath,
},
}
}
return true
})2. 避免無限重導
login 頁要標記:
meta: {
public: true,
}或守衛明確放行 login。
3. 權限守衛
if (to.meta.permission) {
const allowed = authStore.permissions.includes(to.meta.permission)
if (!allowed) {
return '/403'
}
}但再次提醒:前端守衛只改善體驗,後端 API 必須授權。
六、 401 與 Refresh Race
1. 401 處理
API 回 401 可能代表:
- session 過期。
- access token 過期。
- refresh token 失效。
- 使用者被踢下線。
- 密碼被重設後 session 失效。
前端要做:
嘗試 refresh,若可行
-> 重試原請求
若 refresh 失敗
-> 清 auth state
-> 導 login2. Refresh mutex
多個請求同時 401 時,不要同時 refresh。
let refreshPromise: Promise<void> | null = null
async function refreshOnce() {
if (!refreshPromise) {
refreshPromise = authApi.refresh().finally(() => {
refreshPromise = null
})
}
return refreshPromise
}API client:
async function request(input: RequestInfo, init?: RequestInit) {
const response = await fetch(input, init)
if (response.status !== 401) {
return response
}
await refreshOnce()
return fetch(input, init)
}實務上還要避免:
- refresh API 自己 401 時無限重試。
- 非冪等 POST 自動重試造成重複提交。
- refresh token rotation 被並發請求誤判為 reuse。
延伸讀:Access Token 與 Refresh Token。
3. 403 不應 refresh
403 是已登入但無權限。
不要看到 403 就 refresh token。
處理:
顯示無權限
或導 403 頁七、 跨 Tab 同步
1. 為什麼需要?
使用者在 Tab A 登出,Tab B 還顯示已登入,可能造成:
- UI 狀態錯誤。
- 送出請求後才 401。
- 敏感資料仍留在畫面上。
2. BroadcastChannel
const authChannel = new BroadcastChannel('auth')
function broadcastLogout() {
authChannel.postMessage({
type: 'logout',
})
}
authChannel.onmessage = event => {
if (event.data.type === 'logout') {
authStore.clear()
router.push('/login')
}
}3. storage event
也可以用 localStorage event:
localStorage.setItem('auth:event', String(Date.now()))
window.addEventListener('storage', event => {
if (event.key === 'auth:event') {
authStore.clear()
}
})即使 token 不放 localStorage,也可以只用它傳事件。
4. Server-side 仍是最終真相
跨 tab 同步只是改善體驗。
Tab B 若沒收到事件,下一次 API 仍應因 session 失效而 401。
八、 SSR 與 Hydration
1. SSR 最大問題:server 和 client 狀態一致
SSR 時 server render 可能不知道 client cookie,或知道但 client hydrate 時狀態不同。
常見錯誤:
server render: 未登入
client hydrate: 已登入可能造成 hydration mismatch 或畫面閃爍。
2. SSR 應由 server 讀 cookie
SSR app 應在 server request 中讀 cookie:
request cookie
-> 查 session
-> 注入 initial auth state
-> render HTML
-> client hydrate 使用同一份 state不要在 SSR render 時直接依賴 browser-only localStorage。
3. 不要把敏感 token 注水到 HTML
可以注水:
{
"user": {
"id": "user_123",
"displayName": "Hirimu"
}
}不應注水:
{
"accessToken": "...",
"refreshToken": "..."
}SSR 初始狀態會進入 HTML,不能放敏感憑證。
延伸讀:Vue SSR 的實現原理。
九、 登出清理
1. 前端要清哪些?
登出成功後:
- user。
- permissions。
- in-memory access token。
- pending requests。
- sensitive cache。
- tabs / visited views。
- form draft,視情況。
- query cache。
不要只清一個 isLogin。
2. 敏感頁面要清快取
例如:
- 醫療資料。
- 財務資料。
- 後台客戶資料。
- 個人文件。
登出後應清除 memory cache 或 query cache:
queryClient.clear()3. 登出 API 失敗怎麼辦?
如果使用者點登出,但 API 失敗:
前端應清本地狀態
提示可能需要重試
下次 API 仍由 server 判斷理想情況下登出 API 要盡量冪等:
session 已不存在 -> 仍回成功十、 常見錯誤
1. isLogin = !!localStorage.token
Token 存在不代表有效。
可能:
- 過期。
- 被撤銷。
- 使用者停用。
- 權限變更。
- refresh token 已失效。
應該透過 /me 或 token verify 流程確認。
2. 把 user profile 永久存在 localStorage
會造成:
- 使用者資料過期。
- 登出後資料殘留。
- 多帳號切換錯亂。
- SSR 狀態不一致。
可以暫存,但要有清理與重新驗證策略。
3. 401 全部導 login
有些 401 可以 refresh,有些代表 session 真的失效。
流程要分清:
access token expired -> refresh
refresh failed -> login
session revoked -> login4. 非冪等請求自動重試
POST 付款、建立訂單、提交表單,如果 401 後自動 refresh 再重試,可能造成重複提交。
要有 idempotency key 或要求使用者確認。
5. SSR 直接讀 localStorage
server 沒有 localStorage。
SSR 中要透過 request cookie 或 server context 處理登入狀態。
十一、 追問題庫
Q1:前端怎麼判斷使用者是否登入?
不要只看本地是否有 token。HttpOnly Cookie 模式下應呼叫 /me;Token 模式下也要確認 token 有效或透過 API 回應判斷。前端狀態只是 UI 狀態,後端驗證才是安全真相。
Q2:Token 應該放 localStorage 嗎?
Web app 中不建議放長效 refresh token。localStorage 容易被 XSS 讀取。常見更安全做法是 access token 放 memory,refresh token 放 HttpOnly Cookie,或使用 BFF。
Q3:為什麼需要 unknown auth state?
頁面剛載入時還不知道 session 是否有效。如果直接設未登入,可能造成登入頁閃爍或錯誤重導。unknown 讓 app 可以先等待 /me。
Q4:多個請求同時 401 怎麼辦?
用 refresh mutex,讓多個請求共用同一個 refresh promise,避免同時使用 refresh token,造成 rotation 衝突或 reuse 誤判。
Q5:跨 tab 登出怎麼同步?
可以用 BroadcastChannel 或 storage event 傳遞 logout event。這是體驗優化,server-side session revoke 仍是最終安全保證。
Q6:SSR 登入狀態怎麼處理?
Server render 時應根據 request cookie 查 session,產生初始 user state,client hydrate 使用同一份 state。不要把 access token 或 refresh token 注入 HTML。
十二、 實作題
題目:設計 Vue Auth Store
需求:
- App 啟動時恢復登入狀態。
- 支援 login / logout。
- 支援 401 refresh。
- 支援跨 tab logout。
- 不持久化 refresh token。
export const useAuthStore = defineStore('auth', () => {
const user = ref<UserProfile | null>(null)
const permissions = ref<string[]>([])
const status = ref<'unknown' | 'authenticated' | 'anonymous'>('unknown')
const accessToken = shallowRef<string | null>(null)
const isAuthenticated = computed(() => status.value === 'authenticated')
async function bootstrap() {
try {
const me = await authApi.me()
user.value = me.user
permissions.value = me.permissions
status.value = 'authenticated'
} catch (error) {
if (isUnauthorized(error)) {
clear()
status.value = 'anonymous'
return
}
status.value = 'unknown'
throw error
}
}
function clear() {
user.value = null
permissions.value = []
accessToken.value = null
}
async function logout() {
try {
await authApi.logout()
} finally {
clear()
status.value = 'anonymous'
broadcastLogout()
}
}
return {
user,
permissions,
status,
accessToken,
isAuthenticated,
bootstrap,
logout,
clear,
}
})題目延伸:API client refresh mutex
let refreshPromise: Promise<void> | null = null
async function refreshOnce() {
if (!refreshPromise) {
refreshPromise = authApi.refresh().finally(() => {
refreshPromise = null
})
}
return refreshPromise
}這能避免 refresh token rotation 因並發請求而誤判。
十三、 資深視角
1. 前端 auth state 是狀態機
不要只有:
isLogin: boolean更成熟的是:
unknown
anonymous
authenticating
authenticated
refreshing
expired
forbidden狀態機越清楚,路由守衛、loading、refresh、logout 就越不容易亂。
2. 安全和體驗要分層
前端負責:
- 顯示登入狀態。
- 控制路由體驗。
- 觸發 refresh。
- 清理 UI cache。
- 同步跨 tab。
後端負責:
- 驗證 session / token。
- 授權。
- 撤銷。
- 稽核。
不要讓前端 store 承擔安全責任。
3. 架構越高風險,越應考慮 BFF
如果你不想把 token 暴露給瀏覽器 JS,可以考慮:
Browser -> BFF session cookie -> BFF -> APIBFF 持有 token 或 server-side session,瀏覽器只持有 HttpOnly session cookie。
代價:
- 多一層服務。
- API 代理與部署更複雜。
- 前後端邊界要重新設計。
但對高安全 Web app 很有價值。
總結
| 問題 | 回答重點 |
|---|---|
| 前端登入狀態 | UI 狀態,不是安全真相 |
| 憑證儲存 | Cookie、memory、localStorage 各有風險 |
| Auth Store | 存 user、permissions、status,不存密碼 |
| App 啟動 | 用 /me 恢復狀態 |
| 401 | 可 refresh 再重試,但要避免無限循環 |
| Refresh race | 用 mutex 避免並發 refresh |
| 跨 tab | BroadcastChannel / storage event |
| SSR | server 讀 cookie,不把 token 注入 HTML |
| 登出 | 清前端狀態 + server revoke + 清 cache |
一句完整的面試回答可以是:
前端登入狀態我會分成憑證、使用者資料、權限和 UI 狀態。HttpOnly Cookie 模式下前端不讀 token,app 啟動時呼叫
/me,由 server 根據 cookie 回 user 和 permissions;Token 模式下 access token 優先放 memory,refresh token 要更嚴格保護,不建議長效 refresh token 放 localStorage。Auth store 裡保存 user、permissions、status、loading/error,不保存密碼或長效敏感憑證。初始狀態要有 unknown,避免還沒確認 session 就重導 login。API client 遇到 401 時可以 refresh 後重試,但要用 refresh mutex 避免多請求同時刷新;refresh 失敗才清狀態並導 login。跨 tab 登出用 BroadcastChannel 或 storage event 同步。SSR 時應由 server 讀 request cookie 建立初始 auth state,不要把 access token 或 refresh token 注入 HTML。前端狀態只改善體驗,真正的身份驗證與授權仍由後端保證。