跳至主要內容
Skip to content

Access Token 與 Refresh Token

Access Token 和 Refresh Token 的分工,是現代 token-based authentication 最核心的設計之一。Access Token 負責短時間呼叫 API,Refresh Token 負責在使用者不重新登入的情況下取得新的 Access Token。真正難的地方不是「過期就刷新」,而是如何讓 refresh token 被盜時能被偵測、撤銷與控制損害。

一句話回答:Access Token 應該短效、權限範圍有限,用來呼叫 API;Refresh Token 較長效,用來向授權伺服器換取新的 Access Token,應存 server-side 記錄、可撤銷,並使用 refresh token rotation 與 reuse detection:每次刷新都簽發新的 refresh token 並使舊 token 失效,若舊 token 被再次使用,代表可能外洩,應撤銷整個 token family 或相關 session。


一、 必考觀念

1. 為什麼要分兩種 token?

如果只有一個長效 access token:

text
access token 有效 30 天

一旦被偷,攻擊者就可以長時間呼叫 API。

如果只有一個很短效 access token:

text
access token 有效 10 分鐘

使用者每 10 分鐘就要重新登入,體驗很差。

所以常見設計是:

text
短效 Access Token + 長效且可控的 Refresh Token

2. Access Token 和 Refresh Token 的分工

Token用途壽命儲存撤銷
Access Token呼叫 APIclient / memory / cookie通常靠短效,必要時 denylist
Refresh Token換新 access token較長更嚴格保護必須可撤銷

Access Token 是給 resource server 看。

Refresh Token 是給 authorization server / auth API 看。

不要拿 refresh token 直接呼叫業務 API。

3. Refresh Token 是高價值憑證

Refresh Token 的危險性在於:

text
只要它還有效,就可以不斷換新的 access token。

所以它要比 access token 更嚴格保護:

  • 存 server-side hash。
  • 支援撤銷。
  • 支援 rotation。
  • 支援 reuse detection。
  • 綁定 client / device / session。
  • 有絕對過期時間。
  • 高風險時要求重新登入。

RFC 9700(OAuth 2.0 Security Best Current Practice)也建議 public clients 的 refresh token 應 sender-constrained 或使用 refresh token rotation。


二、 基本流程

1. 登入後簽發 token

回應可能是:

json
{
  "accessToken": "eyJ...",
  "expiresIn": 900,
  "refreshToken": "opaque-refresh-token"
}

2. Access token 過期後刷新

刷新不是延長原本 access token,而是簽發新的 access token。

3. Refresh token 不一定是 JWT

很多系統會讓 refresh token 是 opaque token:

text
rt_8a9f...random...

然後資料庫只存 hash:

typescript
type RefreshToken = {
  id: string
  userId: string
  sessionId: string
  tokenHash: string
  familyId: string
  expiresAt: string
  consumedAt?: string
  revokedAt?: string
  replacedByTokenId?: string
}

這樣 server 可以精準撤銷與追蹤。


三、 Access Token 設計

1. Access token 要短效

常見壽命:

text
5 分鐘
10 分鐘
15 分鐘
30 分鐘

高風險系統更短,低風險內部系統可能稍長。

重點:

  • 不要長到像 session。
  • 不要長到權限變更後很久仍有效。
  • 不要長到外洩後損害難以控制。

2. Access token 要限制權限範圍

Claims 可以包含:

json
{
  "sub": "user_123",
  "iss": "https://auth.example.com",
  "aud": "api.example.com",
  "scope": "profile:read orders:read",
  "session_id": "sess_123",
  "jti": "at_123",
  "exp": 1716533600
}

注意:

  • scope / permissions 不要過大。
  • audience 要對應 resource server。
  • 高風險 API 可即時查權限或 session 狀態。
  • 不要把敏感個資放進 token。

延伸讀:JWT 是什麼

3. Access token 被盜怎麼辦?

短效是第一防線。

其他策略:

  • jti denylist。
  • session revoke。
  • passwordChangedAt 檢查。
  • permission version 檢查。
  • 高風險 API 查 server-side session。
  • sender-constrained token,例如 DPoP 或 mTLS。

不是每個系統都需要 denylist,但高風險系統要知道它的成本與取捨。


四、 Refresh Token 設計

1. Refresh token 要可撤銷

登出時:

text
revoke refresh token

修改密碼時:

text
revoke all refresh tokens for user

可疑登入時:

text
revoke token family or session

這就是為什麼 refresh token 通常要有 server-side record。

2. Refresh token 要存 hash

和 password reset token 類似,client 拿 raw token,server 存 hash。

建立:

typescript
const rawRefreshToken = createRandomToken()
const tokenHash = await hashToken(rawRefreshToken)

await refreshTokenRepository.create({
  userId,
  sessionId,
  tokenHash,
  familyId,
  expiresAt,
})

驗證:

typescript
const tokenHash = await hashToken(input.refreshToken)
const token = await refreshTokenRepository.findByHash(tokenHash)

如果 refresh token table 外洩,攻擊者不能直接拿資料庫內容換 token。

3. Refresh token 也要過期

常見:

  • idle timeout。
  • absolute timeout。
  • remember me 較長。
  • 管理員較短。
  • 高風險帳號較短。

不要讓 refresh token 永久有效。


五、 Refresh Token Rotation

1. Rotation 是什麼?

每次使用 refresh token 換新 access token 時,同時簽發新的 refresh token,並讓舊的 refresh token 失效。

text
RT1 -> 使用 -> 產生 AT2 + RT2 -> RT1 失效
RT2 -> 使用 -> 產生 AT3 + RT3 -> RT2 失效

這樣被偷的 refresh token 比較容易被偵測。

RFC 9700 延續並更新 OAuth 2.0 安全最佳實務,對 public clients 建議使用 refresh token rotation 或 sender-constrained refresh tokens 來防止 replay。

2. Reuse detection

如果 RT1 已經被使用並換成 RT2,之後又有人拿 RT1 來刷新:

text
RT1 used again

這表示:

  • 使用者或攻擊者重放舊 token。
  • token 可能外洩。
  • client 可能並發刷新處理錯誤。

安全做法通常是撤銷整個 token family:

text
revoke RT1、RT2、RT3...
要求重新登入

3. Token family

Token family 是同一次登入生命週期中 refresh token 的鏈。

typescript
type RefreshToken = {
  id: string
  familyId: string
  userId: string
  sessionId: string
  tokenHash: string
  consumedAt?: string
  replacedByTokenId?: string
  revokedAt?: string
}

當發現 reuse:

typescript
await refreshTokenRepository.revokeFamily({
  familyId: token.familyId,
  reason: 'reuse_detected',
})

4. 並發刷新問題

真實前端可能同時發出多個 API,發現 access token 過期後同時刷新:

text
request A refresh RT1
request B refresh RT1

如果沒有處理,第二個請求可能被判定為 reuse attack。

解法:

  • 前端用 refresh mutex,只允許一個 refresh。
  • 後端設很短的 grace window。
  • 後端檢查是否為同一 client 在極短時間內重試。
  • 仍要避免寬鬆到攻擊者可利用。

資深回答要能說出 rotation 和並發刷新之間的取捨。


六、 Refresh API 設計

1. 基本流程

typescript
async function refresh(input: RefreshInput, request: Request) {
  const tokenHash = await hashToken(input.refreshToken)
  const token = await refreshTokenRepository.findByHash(tokenHash)

  if (!token || token.revokedAt || token.expiresAt < new Date()) {
    throw new InvalidRefreshTokenError()
  }

  if (token.consumedAt) {
    await refreshTokenRepository.revokeFamily({
      familyId: token.familyId,
      reason: 'reuse_detected',
    })

    throw new InvalidRefreshTokenError()
  }

  const newRefreshToken = createRandomToken()
  const newRefreshTokenHash = await hashToken(newRefreshToken)

  const accessToken = await accessTokenService.sign({
    sub: token.userId,
    sessionId: token.sessionId,
  })

  await transaction(async trx => {
    const replacement = await refreshTokenRepository.create(
      {
        userId: token.userId,
        sessionId: token.sessionId,
        familyId: token.familyId,
        tokenHash: newRefreshTokenHash,
        expiresAt: calculateRefreshExpiry(),
      },
      trx,
    )

    await refreshTokenRepository.consume(
      {
        tokenId: token.id,
        replacedByTokenId: replacement.id,
      },
      trx,
    )
  })

  return {
    accessToken,
    refreshToken: newRefreshToken,
  }
}

重點:

  • 驗證 token 是否存在、未過期、未撤銷。
  • consumed token 再次出現要視為高風險。
  • rotation 的 consume + create 要在 transaction。
  • 寫 audit log。

2. Refresh token 要綁定 session / client

可以記錄:

  • sessionId。
  • clientId。
  • device id。
  • userAgent hash。
  • IP history。
  • createdAt / lastUsedAt。

但不要過度依賴 IP 綁定,因為行動網路與公司網路可能變動。

3. Refresh 失敗時前端怎麼辦?

如果 refresh token invalid:

text
清除本地登入狀態
導回 login
保留 redirect
顯示登入已過期

不要陷入無限 refresh loop。


七、 儲存位置

1. Web app 常見選擇

選項一:Refresh token 放 HttpOnly Cookie。

優點:

  • JS 不能直接讀取。
  • 降低 XSS 直接偷 token。

缺點:

  • 要處理 CSRF。
  • Cookie 跨域設定複雜。

選項二:Refresh token 放 memory。

優點:

  • 重新整理後消失,降低長期偷取風險。

缺點:

  • 使用者刷新頁面可能要重新登入或靠 silent refresh。

選項三:Refresh token 放 localStorage。

優點:

  • 實作簡單。

缺點:

  • XSS 可直接讀取,風險高。

對一般 Web app,長效 refresh token 不建議裸放 localStorage。

2. Mobile app

Mobile app 應使用平台安全儲存:

  • iOS Keychain。
  • Android Keystore / EncryptedSharedPreferences。
  • Secure Enclave / hardware-backed key store,視需求。

不要把 refresh token 明文存在一般檔案或不安全 storage。

3. SPA 與 BFF

對高安全 Web app,可以考慮 Backend For Frontend:

text
Browser 只拿 session cookie
BFF 保存 token 或代表前端呼叫 API

這樣可以避免把 refresh token 暴露給瀏覽器 JavaScript。

代價是架構更複雜。


八、 登出與撤銷

1. 單裝置登出

text
revoke current refresh token family
or revoke current session

如果每個裝置有一個 session / token family,就可以精準登出單裝置。

2. 全裝置登出

text
revoke all sessions / refresh token families for user

常見觸發:

  • 使用者手動全部登出。
  • 修改密碼。
  • 忘記密碼成功。
  • 管理員停用帳號。
  • reuse detection。
  • 帳號疑似被盜。

3. Access token 怎麼處理?

如果 access token 很短效,登出時可以只撤銷 refresh token。

但高風險系統可能要:

  • 將 access token jti 加入 denylist。
  • resource server 查 session 狀態。
  • 權限變更時提高 token version。

取決於你能接受 access token 在剩餘有效期內仍可用多久。


九、 常見錯誤

1. Refresh token 不旋轉

如果同一個 refresh token 可以用一個月,每次都換 access token:

text
RT1 -> AT1
RT1 -> AT2
RT1 -> AT3

RT1 被偷後,攻擊者可以長期使用,而且難以偵測。

2. Refresh token 不可撤銷

如果 refresh token 是長效 JWT,且 server 不保存狀態,登出與可疑事件很難即時處理。

3. Refresh token 和 access token 用同一個 secret、同一套 claims

不同 token 用途不同,應該有:

  • 不同 lifetime。
  • 不同 audience。
  • 不同 storage。
  • 不同驗證邏輯。
  • 不同撤銷策略。

4. Refresh API 沒有 rate limit

Refresh endpoint 也可能被攻擊。

要限制:

  • 同一 token family。
  • 同一 user。
  • 同一 IP。
  • 同一 device。

5. 前端多請求同時 refresh

如果前端沒有 refresh mutex,可能造成:

  • rotation 衝突。
  • 使用者被踢出。
  • token family 被誤判 reuse。
  • 無限重試。

前端 token client 要集中管理 refresh 流程。


十、 追問題庫

Q1:Access token 和 refresh token 差在哪?

Access token 用來呼叫 API,壽命短、權限範圍有限;refresh token 用來取得新的 access token,壽命較長、風險更高,必須可撤銷並更嚴格保護。

Q2:為什麼 access token 要短效?

因為 access token 一旦被偷,在有效期內通常可以直接呼叫 API。短效可以降低外洩後的損害窗口。

Q3:Refresh token rotation 是什麼?

每次 refresh 都簽發新的 refresh token,並讓舊 refresh token 失效。如果舊 token 再次被使用,代表可能外洩,應撤銷 token family 或要求重新登入。

Q4:Refresh token 要不要存資料庫?

通常要。至少要存 hash、userId、sessionId、familyId、expiresAt、revokedAt、consumedAt 等資訊,才能撤銷、rotation、reuse detection 和多裝置管理。

Q5:登出時要撤銷 access token 嗎?

看風險。若 access token 很短效,可以只撤銷 refresh token;若是高風險系統,可以把 access token 的 jti 加入 denylist,或讓 resource server 查 session 狀態。

Q6:Refresh token 被偷怎麼辦?

如果有 rotation,舊 token 被再次使用時可觸發 reuse detection,撤銷整個 token family、通知使用者、要求重新登入,必要時撤銷所有 session。


十一、 實作題

題目:設計 Refresh Token Rotation

需求:

  • Access token 15 分鐘過期。
  • Refresh token 30 天過期。
  • 每次 refresh 都 rotation。
  • 偵測舊 refresh token 重放。
  • 支援登出單裝置。

資料表:

typescript
type RefreshTokenRecord = {
  id: string
  userId: string
  sessionId: string
  familyId: string
  tokenHash: string
  expiresAt: string
  consumedAt?: string
  replacedByTokenId?: string
  revokedAt?: string
  revokedReason?: string
  createdAt: string
}

流程:

text
POST /token/refresh
  -> hash raw refresh token
  -> 找 token record
  -> 不存在 / 過期 / revoked:拒絕
  -> consumedAt 已存在:reuse detected,revoke family
  -> 建立新 refresh token
  -> 舊 token consumed
  -> 簽發新 access token

題目延伸:前端避免並發 refresh

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

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

  return refreshPromise
}

多個 API 同時遇到 401 時,共用同一個 refresh promise,避免多次使用同一個 refresh token。


十二、 資深視角

1. Refresh token 是 session 的另一種表達

如果你的 refresh token:

  • 有 server-side record。
  • 有 sessionId。
  • 可撤銷。
  • 可列出裝置。
  • 可 rotation。

那它其實已經很接近 session 管理。

所以不要迷信「token-based 就一定 stateless」。

2. Rotation 提高安全,也提高複雜度

Rotation 帶來:

  • Reuse detection。
  • 被盜後可偵測。
  • 更細緻的撤銷。

但也帶來:

  • 並發刷新問題。
  • 前後端同步問題。
  • token family 管理。
  • transaction 與一致性要求。

高風險系統值得;低風險系統也至少要理解取捨。

3. Sender-constrained token 是更高階方向

Refresh token rotation 仍然是 bearer token:

text
誰拿到 token,誰就能用。

更高強度可以考慮 sender-constrained token,例如:

  • mTLS。
  • DPoP。
  • 裝置綁定。

這些會把 token 綁定到某個 client key,降低被偷後直接重放的風險。


總結

問題回答重點
Access token短效,用來呼叫 API
Refresh token較長效,用來換新 access token
為什麼分開降低 access token 被偷後的損害,同時維持體驗
Rotation每次 refresh 簽發新 refresh token,舊 token 失效
Reuse detection舊 token 再次使用代表可能外洩,撤銷 token family
儲存Refresh token 應 server-side 記錄並只存 hash
登出撤銷 refresh token / session,必要時 denylist access token
風險並發 refresh、token 被盜、長效 token 不可撤銷

一句完整的面試回答可以是:

Access token 和 refresh token 的分工是:access token 短效,用來呼叫 API,通常包含 sub、aud、scope、exp、jti 等 claims;refresh token 較長效,用來向 auth server 換新的 access token,安全等級更高,應該可撤銷並嚴格保護。Access token 短效可以降低被偷後的損害窗口;refresh token 則用來維持使用者體驗。Refresh token 通常要存在 server-side record,資料庫只存 hash,並記錄 userId、sessionId、familyId、expiresAt、consumedAt、revokedAt。比較安全的做法是 refresh token rotation:每次 refresh 都簽發新的 refresh token 並使舊 token 失效;如果舊 token 再次被使用,視為 reuse detection,撤銷整個 token family 並要求重新登入。登出時至少撤銷 refresh token,高風險系統也可 denylist access token 或讓 resource server 查 session 狀態。

延伸閱讀