Authorization Code Flow 與 PKCE
在第三方登入裡,你很常看到這句話:
前端應該使用 Authorization Code Flow with PKCE。這句話看起來像背誦題,但面試官真正想知道的是:
- 為什麼不是 Implicit Flow?
- authorization code 被偷走會怎樣?
- PKCE 到底保護了什麼?
code_verifier和code_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 的必要安全機制。
短一點可以說:
Authorization Code Flow 避免 token 直接暴露在瀏覽器 URL;
PKCE 則把 code 綁定到發起登入的 client,
避免 code 被攔截後被別人拿去換 token。二、 先理解 Authorization Code Flow
Authorization Code Flow 的核心想法是:
不要直接把 access token 經過瀏覽器導回前端。
先回傳一個短效、一次性的 code,
再由 Client 到 token endpoint 換 token。流程如下:
它比 Implicit Flow 安全的地方在於:
| 流程 | token 回來的位置 |
|---|---|
| Implicit Flow | authorization response,也就是瀏覽器 URL |
| Authorization Code Flow | token endpoint 的後通道回應 |
Implicit Flow 常見是:
https://app.example.com/callback#access_token=...Authorization Code Flow 則是:
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 策略也比較受限。
現在主流做法是:
SPA / mobile app / browser-based app
使用 Authorization Code Flow + PKCE。也就是:
不要讓 access token 從 authorization endpoint 直接回來。
讓前端拿 code,再用 PKCE 保護 code exchange。如果有後端,也可以由後端完成 callback 和 token exchange,最後發自己的 session cookie。
四、 Authorization Code 被偷會怎樣
如果只有 Authorization Code Flow,流程像這樣:
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:
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 全名是:
Proof Key for Code Exchange它的核心不是加密 authorization code,而是:
把 authorization code 綁定到一個只有原始 client 知道的一次性秘密。這個一次性秘密叫:
code_verifier由它算出來、先交給 Authorization Server 的值叫:
code_challenge常用計算方式:
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 字元。
實作上常見做法:
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',
}
}在發起登入時送出:
code_challenge=...
code_challenge_method=S256在換 token 時送出:
code_verifier=...注意:
code_verifier不要送到 authorization endpoint。code_challenge可以被看到,因為它是雜湊後的結果。- 優先使用
S256,不要用plain。 - 每次登入流程都要產生新的 verifier。
- verifier 必須和該次登入流程綁定,不能重複使用。
七、 完整請求長什麼樣
1. Authorization Request
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
GET https://app.example.com/callback?code=auth_code&state=random_state收到 callback 後要先驗:
state是否存在。state是否和本次登入流程一致。redirect_uri是否和發起登入一致。- 若是 OIDC,後續也要驗
nonce。
3. Token Request
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
前端自己:
- 產生
code_verifier。 - 保存到 memory、sessionStorage 或安全的暫存位置。
- 計算
code_challenge。 - redirect 到 Provider。
- callback 回 SPA route。
- 用
code + code_verifier呼叫 token endpoint。
概念如下:
這種做法需要 Provider 的 token endpoint 支援 CORS,且前端必須處理 token 保存風險。
如果 token 存在 localStorage,會提高 XSS 後被偷 token 的風險。若存在 memory,重新整理會消失,需要搭配 refresh 或重新登入策略。
架構二:BFF / 後端處理 OAuth
更常見也更容易控風險的做法是 Backend for Frontend:
- SPA 點擊登入。
- 後端產生 state、nonce、PKCE。
- 後端把登入 URL 回給前端。
- Provider callback 回後端。
- 後端換 token、驗 ID Token。
- 後端建立本地 session cookie。
- 前端只呼叫
/me得知登入狀態。
概念如下:
這種做法的好處:
- Provider token 不需要暴露給前端。
- 可以使用 HttpOnly Cookie。
- 後端可以集中做 ID Token 驗證。
- 容易接 session 撤銷、MFA、風險控制。
- 前端登入狀態比較簡單。
對一般產品來說,如果你有後端,這通常是更容易維護的路線。
九、 state、nonce、PKCE 的差別
這三個常常被混在一起。
| 名稱 | 主要用途 | 放在哪裡 |
|---|---|---|
| state | 防 OAuth 流程 CSRF、流程混淆,也可關聯登入後返回頁 | authorization request / callback |
| nonce | OIDC 中防 ID Token replay,綁定登入請求與 ID Token | authorization request / ID Token |
| PKCE | 防 authorization code 被攔截後被別人換 token | authorization request / token request |
state
state 是 OAuth 流程裡的交易識別。
你可以把它理解成:
這個 callback 是不是我剛剛發起的那次登入?常見設計:
- 產生隨機 state。
- 存在 server-side session、短效 cache 或 signed cookie。
- callback 時比對。
- state 對應 redirect target、provider、nonce、PKCE id 等資訊。
nonce
nonce 是 OIDC 的概念。
它用來確認:
這個 ID Token 是不是對應我剛剛發起的登入?OIDC callback 後驗 ID Token 時,要確認 payload 中的 nonce 和本次流程一致。
PKCE
PKCE 處理的是:
就算 code 被偷走,偷 code 的人也不能換 token。它不是用來表示登入後跳回哪一頁,也不是 ID Token replay 的唯一防線。
實務上建議:
state + nonce + PKCE 都要正確使用。雖然 OAuth 安全最佳實踐中提到,在特定條件下 PKCE 可以承擔部分 CSRF 防護,但在一般產品實作裡,保留 state 仍然更清楚,也方便保存登入流程狀態。
十、 PKCE 與 client_secret 的關係
很多人會問:
有 client_secret 還需要 PKCE 嗎?
以前 PKCE 主要是為 public client 設計的,例如 native app、mobile app、SPA。
但現在安全建議已經偏向:
使用 Authorization Code Flow 的 client 都應該使用 PKCE。原因是 PKCE 不只保護「沒有 secret 的 client」,也可以降低 code injection、流程混淆與某些中間路徑洩漏風險。
可以這樣理解:
| 機制 | 解決問題 |
|---|---|
| client_secret | 證明 token request 來自某個 confidential client |
| PKCE | 證明 token request 來自發起該次 authorization request 的實體 |
兩者不是完全替代關係。
對後端 web app:
client_secret + PKCE對 SPA / mobile app:
PKCE,不能依賴 client_secret十一、 後端實作範例
以下是一個簡化版 BFF 流程。
1. 發起登入
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
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')
})這段真正重要的是順序:
驗 state -> consume flow -> 用 code_verifier 換 token -> 驗 ID Token -> 建本地 sessionconsume(state) 最好是一次性讀取,用完就刪。避免同一個 state 被重放。
十二、 常見錯誤
1. code_verifier 重複使用
錯誤:
整個 app 共用同一組 code_verifier。正確:
每一次 authorization request 都產生新的 code_verifier。2. 使用 plain 而不是 S256
錯誤:
code_challenge_method=plain正確:
code_challenge_method=S256plain 只應視為相容性選項,不應是一般產品的預設。
3. 把 code_verifier 放進 authorization URL
錯誤:
/authorize?...&code_verifier=secret正確:
/authorize?...&code_challenge=hash
/token code_verifier=secret4. 有 PKCE 就不驗 state
錯誤:
callback 有 code,就直接換 token。正確:
callback 先驗 state,再用 code_verifier 換 token。尤其當你的 state 還保存 provider、redirect target、tenant、nonce 等流程資訊時,它仍然不可省略。
5. redirect_uri 不一致
錯誤:
發起登入用 A redirect_uri,換 token 用 B redirect_uri。正確:
authorization request 和 token request 使用完全一致的 redirect_uri。Provider 後台也應設定精確 redirect URI,不要用過寬鬆的萬用規則。
6. 把 token exchange 放在不受控的地方
如果使用 BFF 架構,token exchange 應由後端完成。不要一邊說後端管登入,一邊又讓前端拿 code 到處轉傳。
比較好的邊界是:
前端負責導向登入。
後端負責 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_verifier與code_challenge。
要注意:
- 不要使用
Math.random()。 - 不要產生太短的 verifier。
- base64url 不應包含
=padding。 code_challenge_method使用S256。
題目二:設計 OAuth flow store
請設計一個暫存結構保存:
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 exchange | token 保存 | 優缺點 |
|---|---|---|---|
| 後端 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. 不要只背流程,要理解威脅模型
面試中只說:
PKCE 是 code_verifier 和 code_challenge。還不夠。
更好的回答會補:
它防的是 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 為何需要 PKCE | public 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 建立。