跳至主要內容
Skip to content

Authorization Code Flow 與 PKCE

在第三方登入裡,你很常看到這句話:

text
前端應該使用 Authorization Code Flow with PKCE。

這句話看起來像背誦題,但面試官真正想知道的是:

  • 為什麼不是 Implicit Flow?
  • authorization code 被偷走會怎樣?
  • PKCE 到底保護了什麼?
  • code_verifiercode_challenge 是什麼關係?
  • SPA、mobile app、後端 web app 的做法有什麼不同?
  • 有 PKCE 之後,還需要 state 嗎?

這篇會把 Authorization Code Flow 和 PKCE 拆開,再重新組回完整登入流程。

一、 一句話回答

如果面試官問:

Authorization Code Flow 與 PKCE 是什麼?

可以先回答:

Authorization Code Flow 是 OAuth 2.0 中讓 Client 先透過瀏覽器取得 authorization code,再到 token endpoint 換 access token 的流程。PKCE 是這個流程的安全擴充,Client 在發起授權前產生一次性的 code_verifier,並把它的雜湊結果 code_challenge 放進 authorization request。等拿到 code 後,Client 必須帶 code_verifier 去換 token,Authorization Server 會重新計算並比對 challenge。這樣即使攻擊者攔截到 authorization code,因為沒有 code_verifier,也無法成功換 token。現在 SPA、mobile app 這類 public client 應使用 Authorization Code Flow with PKCE,OAuth 2.1 的方向也已把 PKCE 視為 authorization code flow 的必要安全機制。

短一點可以說:

text
Authorization Code Flow 避免 token 直接暴露在瀏覽器 URL;
PKCE 則把 code 綁定到發起登入的 client,
避免 code 被攔截後被別人拿去換 token。

二、 先理解 Authorization Code Flow

Authorization Code Flow 的核心想法是:

text
不要直接把 access token 經過瀏覽器導回前端。
先回傳一個短效、一次性的 code,
再由 Client 到 token endpoint 換 token。

流程如下:

它比 Implicit Flow 安全的地方在於:

流程token 回來的位置
Implicit Flowauthorization response,也就是瀏覽器 URL
Authorization Code Flowtoken endpoint 的後通道回應

Implicit Flow 常見是:

text
https://app.example.com/callback#access_token=...

Authorization Code Flow 則是:

text
https://app.example.com/callback?code=...

code 本身不是 access token,它只是用來換 token 的一次性憑證。

三、 為什麼 Implicit Flow 不再是主流

早期 SPA 常用 Implicit Flow,原因是瀏覽器端不能安全保存 client_secret,所以不方便做傳統 code flow。

但 Implicit Flow 有幾個問題:

  • access token 直接出現在瀏覽器導回流程。
  • token 可能出現在瀏覽器歷史、log、extension、錯誤監控或 referrer 相關風險中。
  • authorization server 很難對前端拿到的 token 做更細的 replay 防護。
  • refresh token 策略也比較受限。

現在主流做法是:

text
SPA / mobile app / browser-based app
使用 Authorization Code Flow + PKCE。

也就是:

text
不要讓 access token 從 authorization endpoint 直接回來。
讓前端拿 code,再用 PKCE 保護 code exchange。

如果有後端,也可以由後端完成 callback 和 token exchange,最後發自己的 session cookie。

四、 Authorization Code 被偷會怎樣

如果只有 Authorization Code Flow,流程像這樣:

text
1. Client 導向 Authorization Server。
2. 使用者登入。
3. Authorization Server 導回 callback?code=abc。
4. Client 用 code=abc 換 access token。

問題是:如果 code=abc 被攻擊者攔截,攻擊者也可能拿它去 token endpoint 換 token。

對 confidential client,也就是安全運行在後端的服務,token exchange 通常需要 client_secret

text
code + client_id + client_secret -> token

但 SPA、mobile app 這類 public client 沒辦法安全保存 secret:

  • JavaScript bundle 可以被使用者看見。
  • mobile app binary 可以被逆向。
  • 桌面 app 也不能保證 secret 不外洩。

所以 public client 不能依賴 client_secret 來證明「我是剛剛發起登入的那個 client」。

PKCE 就是為了補這個洞。

五、 PKCE 的核心想法

PKCE 全名是:

text
Proof Key for Code Exchange

它的核心不是加密 authorization code,而是:

text
把 authorization code 綁定到一個只有原始 client 知道的一次性秘密。

這個一次性秘密叫:

text
code_verifier

由它算出來、先交給 Authorization Server 的值叫:

text
code_challenge

常用計算方式:

text
code_challenge = BASE64URL(SHA256(code_verifier))
code_challenge_method = S256

流程如下:

攻擊者就算偷到 code,也不知道 code_verifier,所以換不到 token。

六、 code_verifier 與 code_challenge

code_verifier 是高熵隨機字串。依 RFC 7636,它的長度範圍是 43 到 128 個字元,字元集通常使用 URL-safe 字元。

實作上常見做法:

ts
function base64UrlEncode(bytes: ArrayBuffer) {
  return btoa(String.fromCharCode(...new Uint8Array(bytes)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '')
}

function randomString(byteLength = 32) {
  const bytes = new Uint8Array(byteLength)
  crypto.getRandomValues(bytes)
  return base64UrlEncode(bytes.buffer)
}

async function sha256(input: string) {
  const data = new TextEncoder().encode(input)
  return crypto.subtle.digest('SHA-256', data)
}

async function createPkcePair() {
  const codeVerifier = randomString(32)
  const digest = await sha256(codeVerifier)
  const codeChallenge = base64UrlEncode(digest)

  return {
    codeVerifier,
    codeChallenge,
    codeChallengeMethod: 'S256',
  }
}

在發起登入時送出:

text
code_challenge=...
code_challenge_method=S256

在換 token 時送出:

text
code_verifier=...

注意:

  • code_verifier 不要送到 authorization endpoint。
  • code_challenge 可以被看到,因為它是雜湊後的結果。
  • 優先使用 S256,不要用 plain
  • 每次登入流程都要產生新的 verifier。
  • verifier 必須和該次登入流程綁定,不能重複使用。

七、 完整請求長什麼樣

1. Authorization Request

text
GET https://provider.example.com/oauth/authorize
  ?response_type=code
  &client_id=client_123
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
  &scope=openid%20email%20profile
  &state=random_state
  &nonce=random_nonce
  &code_challenge=abc123
  &code_challenge_method=S256

這一步會經過瀏覽器 redirect。

所以你要假設:

  • URL 可能被瀏覽器、extension、proxy、debug 工具或 log 看見。
  • 不要把 secret 放在這裡。
  • 可以放 code_challenge,不可以放 code_verifier

2. Callback

text
GET https://app.example.com/callback?code=auth_code&state=random_state

收到 callback 後要先驗:

  • state 是否存在。
  • state 是否和本次登入流程一致。
  • redirect_uri 是否和發起登入一致。
  • 若是 OIDC,後續也要驗 nonce

3. Token Request

text
POST https://provider.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&client_id=client_123
&code=auth_code
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&code_verifier=original_code_verifier

如果是 confidential client,還可能帶 client authentication,例如 client secret、private key JWT 或 mTLS。

如果是 public client,不能依賴 client secret,PKCE 就非常重要。

八、 SPA 該怎麼做

SPA 有兩種常見架構。

架構一:純前端 OAuth Client

前端自己:

  1. 產生 code_verifier
  2. 保存到 memory、sessionStorage 或安全的暫存位置。
  3. 計算 code_challenge
  4. redirect 到 Provider。
  5. callback 回 SPA route。
  6. code + code_verifier 呼叫 token endpoint。

概念如下:

這種做法需要 Provider 的 token endpoint 支援 CORS,且前端必須處理 token 保存風險。

如果 token 存在 localStorage,會提高 XSS 後被偷 token 的風險。若存在 memory,重新整理會消失,需要搭配 refresh 或重新登入策略。

架構二:BFF / 後端處理 OAuth

更常見也更容易控風險的做法是 Backend for Frontend:

  1. SPA 點擊登入。
  2. 後端產生 state、nonce、PKCE。
  3. 後端把登入 URL 回給前端。
  4. Provider callback 回後端。
  5. 後端換 token、驗 ID Token。
  6. 後端建立本地 session cookie。
  7. 前端只呼叫 /me 得知登入狀態。

概念如下:

這種做法的好處:

  • Provider token 不需要暴露給前端。
  • 可以使用 HttpOnly Cookie。
  • 後端可以集中做 ID Token 驗證。
  • 容易接 session 撤銷、MFA、風險控制。
  • 前端登入狀態比較簡單。

對一般產品來說,如果你有後端,這通常是更容易維護的路線。

九、 state、nonce、PKCE 的差別

這三個常常被混在一起。

名稱主要用途放在哪裡
state防 OAuth 流程 CSRF、流程混淆,也可關聯登入後返回頁authorization request / callback
nonceOIDC 中防 ID Token replay,綁定登入請求與 ID Tokenauthorization request / ID Token
PKCE防 authorization code 被攔截後被別人換 tokenauthorization request / token request

state

state 是 OAuth 流程裡的交易識別。

你可以把它理解成:

text
這個 callback 是不是我剛剛發起的那次登入?

常見設計:

  • 產生隨機 state。
  • 存在 server-side session、短效 cache 或 signed cookie。
  • callback 時比對。
  • state 對應 redirect target、provider、nonce、PKCE id 等資訊。

nonce

nonce 是 OIDC 的概念。

它用來確認:

text
這個 ID Token 是不是對應我剛剛發起的登入?

OIDC callback 後驗 ID Token 時,要確認 payload 中的 nonce 和本次流程一致。

PKCE

PKCE 處理的是:

text
就算 code 被偷走,偷 code 的人也不能換 token。

它不是用來表示登入後跳回哪一頁,也不是 ID Token replay 的唯一防線。

實務上建議:

text
state + nonce + PKCE 都要正確使用。

雖然 OAuth 安全最佳實踐中提到,在特定條件下 PKCE 可以承擔部分 CSRF 防護,但在一般產品實作裡,保留 state 仍然更清楚,也方便保存登入流程狀態。

十、 PKCE 與 client_secret 的關係

很多人會問:

有 client_secret 還需要 PKCE 嗎?

以前 PKCE 主要是為 public client 設計的,例如 native app、mobile app、SPA。

但現在安全建議已經偏向:

text
使用 Authorization Code Flow 的 client 都應該使用 PKCE。

原因是 PKCE 不只保護「沒有 secret 的 client」,也可以降低 code injection、流程混淆與某些中間路徑洩漏風險。

可以這樣理解:

機制解決問題
client_secret證明 token request 來自某個 confidential client
PKCE證明 token request 來自發起該次 authorization request 的實體

兩者不是完全替代關係。

對後端 web app:

text
client_secret + PKCE

對 SPA / mobile app:

text
PKCE,不能依賴 client_secret

十一、 後端實作範例

以下是一個簡化版 BFF 流程。

1. 發起登入

ts
app.get('/auth/google/login', async (req, res) => {
  const state = randomString(32)
  const nonce = randomString(32)
  const { codeVerifier, codeChallenge } = await createPkcePair()

  await oauthFlowStore.save(state, {
    provider: 'google',
    nonce,
    codeVerifier,
    redirectTo: normalizeRedirect(req.query.redirectTo),
    expiresAt: Date.now() + 10 * 60 * 1000,
  })

  const url = new URL('https://accounts.google.com/o/oauth2/v2/auth')
  url.searchParams.set('response_type', 'code')
  url.searchParams.set('client_id', GOOGLE_CLIENT_ID)
  url.searchParams.set('redirect_uri', GOOGLE_REDIRECT_URI)
  url.searchParams.set('scope', 'openid email profile')
  url.searchParams.set('state', state)
  url.searchParams.set('nonce', nonce)
  url.searchParams.set('code_challenge', codeChallenge)
  url.searchParams.set('code_challenge_method', 'S256')

  res.json({ url: url.toString() })
})

2. Callback

ts
app.get('/auth/google/callback', async (req, res) => {
  const code = String(req.query.code || '')
  const state = String(req.query.state || '')

  const flow = await oauthFlowStore.consume(state)
  if (!flow) {
    throw new Error('Invalid or expired OAuth state')
  }

  const tokens = await exchangeCodeForTokens({
    tokenEndpoint: 'https://oauth2.googleapis.com/token',
    clientId: GOOGLE_CLIENT_ID,
    clientSecret: GOOGLE_CLIENT_SECRET,
    redirectUri: GOOGLE_REDIRECT_URI,
    code,
    codeVerifier: flow.codeVerifier,
  })

  const idTokenPayload = await verifyGoogleIdToken(tokens.id_token, {
    audience: GOOGLE_CLIENT_ID,
    nonce: flow.nonce,
  })

  const user = await findOrCreateExternalUser({
    provider: 'google',
    issuer: idTokenPayload.iss,
    subject: idTokenPayload.sub,
    email: idTokenPayload.email,
    emailVerified: idTokenPayload.email_verified,
  })

  const session = await createSession(user.id, req)

  res.cookie('sid', session.id, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
  })

  res.redirect(flow.redirectTo || '/app')
})

這段真正重要的是順序:

text
驗 state -> consume flow -> 用 code_verifier 換 token -> 驗 ID Token -> 建本地 session

consume(state) 最好是一次性讀取,用完就刪。避免同一個 state 被重放。

十二、 常見錯誤

1. code_verifier 重複使用

錯誤:

text
整個 app 共用同一組 code_verifier。

正確:

text
每一次 authorization request 都產生新的 code_verifier。

2. 使用 plain 而不是 S256

錯誤:

text
code_challenge_method=plain

正確:

text
code_challenge_method=S256

plain 只應視為相容性選項,不應是一般產品的預設。

3. 把 code_verifier 放進 authorization URL

錯誤:

text
/authorize?...&code_verifier=secret

正確:

text
/authorize?...&code_challenge=hash
/token code_verifier=secret

4. 有 PKCE 就不驗 state

錯誤:

text
callback 有 code,就直接換 token。

正確:

text
callback 先驗 state,再用 code_verifier 換 token。

尤其當你的 state 還保存 provider、redirect target、tenant、nonce 等流程資訊時,它仍然不可省略。

5. redirect_uri 不一致

錯誤:

text
發起登入用 A redirect_uri,換 token 用 B redirect_uri。

正確:

text
authorization request 和 token request 使用完全一致的 redirect_uri。

Provider 後台也應設定精確 redirect URI,不要用過寬鬆的萬用規則。

6. 把 token exchange 放在不受控的地方

如果使用 BFF 架構,token exchange 應由後端完成。不要一邊說後端管登入,一邊又讓前端拿 code 到處轉傳。

比較好的邊界是:

text
前端負責導向登入。
後端負責 callback、token exchange、ID Token 驗證、本地 session。

十三、 面試追問題庫

Q1:PKCE 解決什麼問題?

PKCE 解決 authorization code interception attack。它讓 Client 在發起登入前產生一次性的 code_verifier,並把雜湊後的 code_challenge 送給 Authorization Server。之後換 token 時必須提交原始 code_verifier。攻擊者即使攔截 authorization code,也因為不知道 verifier,無法換 token。

Q2:為什麼 SPA 不應使用 Implicit Flow?

因為 Implicit Flow 會讓 access token 直接出現在 authorization response,也就是瀏覽器導回路徑。這提高 token 洩漏風險。現在更建議使用 Authorization Code Flow with PKCE,讓 authorization endpoint 只回 code,token 從 token endpoint 取得。

Q3:PKCE 可以取代 client_secret 嗎?

不能完全這樣說。

對 public client 來說,client_secret 本來就不能被視為秘密,所以主要依賴 PKCE。

對 confidential client 來說,client_secret 是 client authentication,PKCE 是把 code 綁定到某一次 authorization request。兩者解決的問題不同,可以同時使用。

Q4:PKCE 可以取代 state 嗎?

實務上不建議這樣講。

PKCE 主要保護 code exchange。state 主要用來防 OAuth 流程 CSRF、流程混淆,並關聯登入流程狀態。即使某些安全建議允許在特定條件下依賴 PKCE 做 CSRF 防護,產品實作上通常仍會保留 state。

Q5:code_challenge 為什麼用雜湊?

因為 authorization request 會經過瀏覽器,可能被觀察到。若直接傳 code_verifier,攻擊者看到 authorization URL 就能知道秘密。使用 S256 後,authorization request 只暴露 challenge,token request 才提交 verifier。

Q6:為什麼 code_verifier 要高熵且一次性?

因為它是換 token 時的證明。若 verifier 可猜、可重複或長期不變,攻擊者就可能重放或暴力猜測,使 PKCE 失去保護效果。

Q7:Authorization Code Flow with PKCE 後,前端還需要保存 access token 嗎?

看架構。

如果是純 SPA OAuth Client,前端可能需要保存 access token,常見放 memory,並搭配 refresh 策略。

如果是 BFF 架構,前端不需要碰 provider token,後端驗證完外部身份後建立 HttpOnly session cookie,前端只查 /me

十四、 實作題

題目一:手寫 PKCE pair

請實作:

  • 產生 32 bytes random。
  • base64url encode。
  • SHA-256。
  • 產生 code_verifiercode_challenge

要注意:

  • 不要使用 Math.random()
  • 不要產生太短的 verifier。
  • base64url 不應包含 = padding。
  • code_challenge_method 使用 S256

題目二:設計 OAuth flow store

請設計一個暫存結構保存:

ts
type OAuthFlow = {
  state: string
  provider: string
  nonce?: string
  codeVerifier: string
  redirectTo: string
  createdAt: Date
  expiresAt: Date
}

要求:

  • state 必須不可預測。
  • flow 必須短效,例如 5 到 10 分鐘。
  • callback 後 consume,一次性使用。
  • redirectTo 必須防 open redirect。
  • provider 必須和 callback route 對得上。

題目三:比較三種架構

請比較:

架構token exchangetoken 保存優缺點
後端 web app後端後端 session最容易控風險
純 SPA + PKCE前端前端 memory 或其他策略架構簡單但 XSS 風險高
BFF + PKCE後端HttpOnly Cookie前端簡單,後端集中控管

好的回答要能講出:

  • public client 不能保存 client secret。
  • BFF 可以避免 provider token 暴露給前端。
  • SPA 仍然要處理 XSS、token 保存與 refresh token 策略。

十五、 資深視角

1. PKCE 是便宜但很有效的防線

PKCE 的成本很低:

  • 產生一個隨機字串。
  • 算一個 SHA-256。
  • 多傳兩個參數。

但它補上的安全價值很大:

  • code 被攔截後不能直接換 token。
  • authorization request 和 token request 被綁起來。
  • 降低 public client 無 secret 的風險。
  • 也能提升 confidential client 的 code flow 安全性。

2. 不要只背流程,要理解威脅模型

面試中只說:

text
PKCE 是 code_verifier 和 code_challenge。

還不夠。

更好的回答會補:

text
它防的是 authorization code interception。
因為 public client 無法安全保存 client_secret,
所以用一次性的 verifier 證明 token request 來自原本發起登入的 client。

3. OAuth 流程應該有交易狀態

成熟系統不會只把 callback 當成一個 route。

它會把每次登入當作一次交易:

  • flow id。
  • provider。
  • state。
  • nonce。
  • code verifier。
  • redirect target。
  • tenant。
  • risk context。
  • expires at。
  • consumed at。

這樣才能處理多分頁、重放、錯誤回調、provider mix-up、登入後導向、稽核與除錯。

4. BFF 常是產品工程的穩健選擇

純 SPA + PKCE 在規格上是可以成立的,但產品工程常常還要面對:

  • token 放哪裡。
  • XSS 後怎麼降低損害。
  • refresh token 如何旋轉。
  • 登出如何撤銷。
  • 多裝置如何管理。
  • SSR 如何知道登入狀態。

BFF 把 OAuth 複雜度留在後端,前端只面對自己的 session,很多時候更清楚。


總結

問題回答重點
Authorization Code Flow先拿 code,再用 code 換 token
PKCE防 code 被攔截後被別人換 token
code_verifier一次性高熵秘密
code_challenge由 verifier 算出的 challenge
建議方法使用 S256
SPA 為何需要 PKCEpublic client 不能安全保存 client secret
Implicit Flow 問題token 直接暴露在瀏覽器導回流程
PKCE 是否取代 state實務上仍保留 state
BFF 優點provider token 不暴露給前端,後端建立本地 session

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

Authorization Code Flow 是 OAuth 2.0 中讓 Client 先透過 authorization endpoint 取得短效 code,再到 token endpoint 換 access token 的流程。PKCE 是這個流程的安全擴充,Client 在發起登入時產生一次性的高熵 code_verifier,用 S256 算出 code_challenge 並放進 authorization request;拿到 authorization code 後,token request 必須帶上原始 code_verifier,Authorization Server 會重新計算並比對。這樣即使 authorization code 被攔截,攻擊者因為沒有 verifier 也無法換 token。SPA、mobile app 這類 public client 不能安全保存 client secret,因此應使用 Authorization Code Flow with PKCE;即使是後端 web app,也建議搭配 PKCE。實作時仍要處理 state、nonce、redirect_uri、一次性 flow store、ID Token 驗證與本地 session 建立。

延伸閱讀