跳至主要內容
Skip to content

前端登入狀態設計

前端登入狀態最容易混亂的地方,是把「使用者資料」、「登入憑證」、「權限資料」、「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. 前端登入狀態不等於安全真相

前端可能有:

typescript
const isLogin = ref(true)

但真正能不能存取 API,要看 server 是否接受目前憑證。

所以前端狀態只是:

text
目前 UI 認為使用者可能已登入。

安全真相在後端:

text
session 是否有效
token 是否有效
帳號是否停用
權限是否足夠

延伸讀:登出與 Session 管理

2. 分清楚四種資料

類型例子前端能不能存
憑證session cookie、access token、refresh token要謹慎
使用者資料id、email、displayName可以,但要可重新取得
權限資料roles、permissions、menus可以,但後端仍要授權
UI 狀態loading、error、redirect可以

壞味道:

typescript
authStore = {
  token,
  refreshToken,
  password,
  user,
  permissions,
  loading,
  formDraft,
}

這會讓安全邊界變得混亂。

3. 前端不應保存密碼

登入送出後,密碼不應該:

  • 放 localStorage。
  • 放 Pinia。
  • 放 URL query。
  • 放 analytics。
  • 放 error log。
  • 放跨頁共享狀態。

密碼只應短暫存在登入表單裡。


二、 憑證放哪裡?

Session Cookie 或 refresh token cookie:

http
Set-Cookie: __Host-sid=...; HttpOnly; Secure; SameSite=Lax; Path=/

前端看不到 cookie。

流程:

text
頁面載入
-> 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:

typescript
const accessToken = ref<string | null>(null)

優點:

  • 刷新頁面後消失。
  • XSS 長期偷取難度較高。

缺點:

  • 重新整理後要靠 refresh token 或 session 恢復。
  • 多 tab 狀態要同步。

適合:

text
access token 短效
refresh token 在 HttpOnly Cookie 或安全儲存

3. localStorage

localStorage 優點是簡單:

typescript
localStorage.setItem('accessToken', token)

但最大問題是 XSS:

javascript
localStorage.getItem('accessToken')

攻擊者一旦能執行 JS,就可能偷 token。

所以不建議把長效 refresh token 放 localStorage。若不得不用,access token 必須短效,而且要非常重視 XSS 防護。

常見誤解:

text
Cookie 一定安全
localStorage 一定不安全

更精準地說:

儲存主要風險
HttpOnly CookieCSRF、XSS 代發請求、Cookie 設定錯誤
localStorageXSS 直接讀取 token
memory刷新後消失、跨 tab 管理較難

選型要看架構與威脅模型。


三、 Auth Store 放什麼?

1. 建議狀態

以 Vue / Pinia 為例:

typescript
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 恢復。

所以不一定需要:

typescript
persist: true

特別不要把敏感 token 跟著 store persist 到 localStorage。

3. 權限只是 UI 狀態,不是安全邊界

前端可以用 permissions:

  • 顯示 menu。
  • 顯示 button。
  • 路由守衛。
  • disabled 控制。

但 API 還是要後端授權。

延伸讀:Vue 權限與路由守衛設計


四、 啟動時恢復登入狀態

1. App 初始化

typescript
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 狀態很重要

頁面初始時:

text
不知道是否登入

不應立刻導 login。

可以顯示:

  • skeleton。
  • app loading。
  • 保留目前頁面。

/me 回來後再判斷。

3. /me 應該回什麼?

json
{
  "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 初始化

typescript
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 頁要標記:

typescript
meta: {
  public: true,
}

或守衛明確放行 login。

3. 權限守衛

typescript
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 失效。

前端要做:

text
嘗試 refresh,若可行
-> 重試原請求
若 refresh 失敗
-> 清 auth state
-> 導 login

2. Refresh mutex

多個請求同時 401 時,不要同時 refresh。

typescript
let refreshPromise: Promise<void> | null = null

async function refreshOnce() {
  if (!refreshPromise) {
    refreshPromise = authApi.refresh().finally(() => {
      refreshPromise = null
    })
  }

  return refreshPromise
}

API client:

typescript
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。

處理:

text
顯示無權限
或導 403 頁

七、 跨 Tab 同步

1. 為什麼需要?

使用者在 Tab A 登出,Tab B 還顯示已登入,可能造成:

  • UI 狀態錯誤。
  • 送出請求後才 401。
  • 敏感資料仍留在畫面上。

2. BroadcastChannel

typescript
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:

typescript
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 時狀態不同。

常見錯誤:

text
server render: 未登入
client hydrate: 已登入

可能造成 hydration mismatch 或畫面閃爍。

SSR app 應在 server request 中讀 cookie:

text
request cookie
-> 查 session
-> 注入 initial auth state
-> render HTML
-> client hydrate 使用同一份 state

不要在 SSR render 時直接依賴 browser-only localStorage。

3. 不要把敏感 token 注水到 HTML

可以注水:

json
{
  "user": {
    "id": "user_123",
    "displayName": "Hirimu"
  }
}

不應注水:

json
{
  "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:

typescript
queryClient.clear()

3. 登出 API 失敗怎麼辦?

如果使用者點登出,但 API 失敗:

text
前端應清本地狀態
提示可能需要重試
下次 API 仍由 server 判斷

理想情況下登出 API 要盡量冪等:

text
session 已不存在 -> 仍回成功

十、 常見錯誤

1. isLogin = !!localStorage.token

Token 存在不代表有效。

可能:

  • 過期。
  • 被撤銷。
  • 使用者停用。
  • 權限變更。
  • refresh token 已失效。

應該透過 /me 或 token verify 流程確認。

2. 把 user profile 永久存在 localStorage

會造成:

  • 使用者資料過期。
  • 登出後資料殘留。
  • 多帳號切換錯亂。
  • SSR 狀態不一致。

可以暫存,但要有清理與重新驗證策略。

3. 401 全部導 login

有些 401 可以 refresh,有些代表 session 真的失效。

流程要分清:

text
access token expired -> refresh
refresh failed -> login
session revoked -> login

4. 非冪等請求自動重試

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。
typescript
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

typescript
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 是狀態機

不要只有:

typescript
isLogin: boolean

更成熟的是:

text
unknown
anonymous
authenticating
authenticated
refreshing
expired
forbidden

狀態機越清楚,路由守衛、loading、refresh、logout 就越不容易亂。

2. 安全和體驗要分層

前端負責:

  • 顯示登入狀態。
  • 控制路由體驗。
  • 觸發 refresh。
  • 清理 UI cache。
  • 同步跨 tab。

後端負責:

  • 驗證 session / token。
  • 授權。
  • 撤銷。
  • 稽核。

不要讓前端 store 承擔安全責任。

3. 架構越高風險,越應考慮 BFF

如果你不想把 token 暴露給瀏覽器 JS,可以考慮:

text
Browser -> BFF session cookie -> BFF -> API

BFF 持有 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
跨 tabBroadcastChannel / storage event
SSRserver 讀 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。前端狀態只改善體驗,真正的身份驗證與授權仍由後端保證。

延伸閱讀