本文同步發表於個人部落格:為什麼需要 PKCE
火線超人的專案裡有兩份 PKCE 程式碼。
一份在 Fhir::OAuth2Service,看起來很正統:
def generate_pkce_pair
code_verifier = SecureRandom.hex(32)
code_challenge = Base64.urlsafe_encode64(
Digest::SHA256.digest(code_verifier)
).delete('=')
{ verifier: code_verifier, challenge: code_challenge }
end
另一份在 FhirOauthState 這個 model 上:
def self.generate_code_verifier
SecureRandom.urlsafe_base64(32)
end
def self.generate_code_challenge(verifier)
Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
end
兩份算出來的東西都合規,寫法上的差別只是 hex 對 urlsafe_base64、手動 delete('=') 對 padding: false。但實際在跑的只有下面那份,因為 initiate_authorization 拿的是 oauth_state.code_challenge,service 上那個方法沒有人呼叫。
為什麼是 model 那份活下來?看它旁邊那一行就懂了:
create!(
line_user_id: line_user_id,
state: generate_state,
code_verifier: code_verifier,
expires_at: EXPIRY_MINUTES.minutes.from_now
)
code_verifier 被存進資料庫了。
這就是 PKCE 真正的重點。算 hash 是最簡單的部分,難的是那個 verifier 得活過整趟跳轉,所以它必須跟一個「能存東西的地方」綁在一起。service 那份沒有存放的去處,它注定跑不起來。
今天先講這組亂數在擋什麼,再來處理它要放哪裡。
昨天結尾那串 code 不認人:它躺在網址列上,而換 token 那一趟不需要出示任何 secret。誰拿到它,誰就換得到 token。
所以問題變成,那串網址會被誰看到?
PKCE 這套機制最早被提出來,針對的其實是手機 app:redirect_uri 用的是 myapp://callback 這種自訂協定,而作業系統不保證只有你註冊得到那個協定,惡意 app 註冊同一組就能把 code 攔走。純前端的網頁 app 處境類似,同樣沒有 secret 可以證明自己是誰。
PKCE 的想法很簡單:既然沒有預先講好的 secret,那就當場生一個。
流程只多兩個參數:
code_verifier(答案),留在手上code_challenge(題目)。第一趟只送題目出門code_verifier(答案)送出去名字很容易記反,這裡先釘住:challenge 這個字本來就是「題目」的意思,而 verifier 是你最後交出去驗明正身的那個,所以它是答案。順序也幫得上忙,先生出來的一定是答案,題目是從答案算出來的,反過來算不回去。
關鍵在於題目和答案走的是不同通道。題目跟著 authorize URL 走前台,會被看到;答案只走後台的 POST,不經過網址列。
於是攻擊者就算撿到 code,也換不到 token,因為他手上沒有答案。他有的是那個 code_challenge(題目),而 SHA-256 不可反解。

這也解釋了為什麼不能把 code_verifier(答案)直接當 code_challenge(題目)送。那叫 plain 模式,等於把答案寫在題目上,攔到 authorize URL 的人一併就拿到答案了。SMART App Launch 2.0 只認 S256,昨天抓的 discovery 裡 code_challenge_methods_supported 也只列了這一項。我試著送 plain 過去,直接被退回來:
error=invalid_request
error_description=Invalid+code_challenge_method.+Must+be+S256.
瀏覽器內建的 Web Crypto API 就夠用了,不需要任何函式庫。新增 pkce.js:
const VERIFIER_KEY = 'smart_code_verifier'
// 產生一段 URL 安全的高熵亂數字串
export function randomBase64Url(byteLength = 32) {
const bytes = crypto.getRandomValues(new Uint8Array(byteLength))
return base64UrlEncode(bytes)
}
export async function createPkcePair() {
const verifier = randomBase64Url(32)
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(verifier)
)
const challenge = base64UrlEncode(new Uint8Array(digest))
return { verifier, challenge }
}
function base64UrlEncode(bytes) {
const binary = String.fromCharCode(...bytes)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
有幾個地方值得說清楚。
crypto.getRandomValues 不能換成 Math.random。 後者是給你決定圖片輪播順序用的,產出可以被預測。這裡要的是猜不到,不是看起來很亂。

那三行 replace 做的就是這件事。
crypto.subtle 只在安全環境下存在。 HTTPS 頁面有,http://localhost 也有,因為瀏覽器把 localhost 當成安全環境。但如果你為了用手機測試而改開 http://192.168.x.x:5173,crypto.subtle 會變成 undefined,錯誤訊息是「Cannot read properties of undefined」,看起來完全不像跟協定有關。
digest() 回的是 ArrayBuffer,不是可以直接跑迴圈的東西,所以要先包一層 new Uint8Array(digest) 才餵得進 String.fromCharCode。
現在回到開場那個問題。
createPkcePair() 產生的那對值,在你把使用者送去授權伺服器的那一刻就會消失,因為頁面整個被換掉了。等使用者帶著 code 回來,你的 JavaScript 是重新執行的,記憶體裡什麼都沒有。
所以 code_verifier(答案)一定要存到某個地方去。瀏覽器裡的選項大致三個:
| 放哪裡 | 活多久 | 適不適合 |
|---|---|---|
| 一般變數 | 換頁就沒了 | 不行,跳轉回來就掉了 |
localStorage |
一直留著,跨分頁共用 | 過頭了,用完的答案還躺在那 |
sessionStorage |
同一個分頁,關掉就沒 | 剛好,授權本來就是一個分頁內的事 |
所以 pkce.js 再加兩個函式:
export function savePkceVerifier(verifier) {
sessionStorage.setItem(VERIFIER_KEY, verifier)
}
// 取出後立刻清掉:verifier 只該被用一次
export function takePkceVerifier() {
const verifier = sessionStorage.getItem(VERIFIER_KEY)
sessionStorage.removeItem(VERIFIER_KEY)
return verifier
}
takePkceVerifier 故意不叫 get。取出來就順手刪掉,因為它的任務只有一次,留著只是多一個會外洩的東西。火線超人也是同一個原則,換完 token 之後那筆狀態記錄就 consume! 掉,而且建立時就寫死 10 分鐘到期。
至於它為什麼存資料庫而不是 sessionStorage:火線超人的授權是從 LINE 裡點開的,使用者被送回來時進的是伺服器的 callback,根本沒有哪個瀏覽器分頁一路跟著。存放的位置要選在「發起授權的人」和「接住 callback 的人」都摸得到的地方,這件事 day09 還會再碰一次。
我把參數逐個拿掉在這台 sandbox 上試了一輪:
| 情境 | 結果 |
|---|---|
authorize 完全不帶 code_challenge(題目) |
照樣拿到 code,換 token 也不用 verifier |
code_challenge_method=plain |
直接退回,Must be S256 |
送了題目,換 token 少 code_verifier(答案) |
HTTP 400,Missing code_verifier parameter |
帶錯的 code_verifier(答案) |
HTTP 401,Invalid grant or Invalid PKCE Verifier |
第一列要特別講。這台 sandbox 不強制 PKCE,不帶就是不驗。所以 PKCE 不是伺服器逼你用的,是你自己該用的,一旦你送了 code_challenge(題目),它就會認真比對。
還有一件事必須據實說:這台沒有做 code 的單次使用限制,同一串 code 我換了兩次 token,兩次都成功。標準要求 code 用過即失效,這台的 code 是一個自己簽的 JWT,5 分鐘內都認。所以前面講的「攻擊者搶先換走 token」在這台上其實兩邊都換得到,你在真實伺服器上不會看到這種行為。
起點:day06 之後的 smart-app,day07 沒有動到任何檔案。手邊還要有 day07 第三步組好的那串 authorize URL。
產出:多一個 pkce.js,而且你會親眼看到答案對不上就換不到 token。這個檔案明天會被 auth.js 直接拿去用。
把上面兩段程式碼合成一個 pkce.js,放在 app.js 旁邊。app.js 今天不用動。
啟動 python3 -m http.server 5173,開 http://localhost:5173,按 F12。因為專案是 ES module,console 可以直接載入剛寫好的檔案:
const { createPkcePair, savePkceVerifier } = await import('./pkce.js')
const { verifier, challenge } = await createPkcePair()
// 自己再算一次 SHA-256 對照
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
const again = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
console.log(verifier.length, challenge === again) // 43 true
savePkceVerifier(verifier)
console.log(challenge) // 等一下要用,先複製起來
把昨天那串 authorize URL 拿出來,尾巴接上兩個參數,{challenge} 換成剛剛印出來的值:
&code_challenge={challenge}&code_challenge_method=S256
貼進同一個分頁的網址列,走完 Login 與 Approve。這一步不能開新分頁,sessionStorage 是分頁綁定的,換分頁就找不到答案了。
回到 http://localhost:5173/?code=… 之後,在 console 貼上:
const { takePkceVerifier } = await import('./pkce.js')
const code = new URLSearchParams(location.search).get('code')
const verifier = takePkceVerifier() // 跨過跳轉還在
const TOKEN = '你的 token 端點'
const post = (extra) => fetch(TOKEN, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'http://localhost:5173/',
client_id: 'my-smart-app',
...extra,
}),
}).then(async (r) => console.log(r.status, await r.text()))
await post({}) // 不帶
await post({ code_verifier: 'wrong-verifier-1234567890-1234567890-abcdef' }) // 帶錯的
await post({ code_verifier: verifier }) // 帶對的
預期結果:verifier 在跳轉之後還取得回來,長度 43。三次呼叫依序是 400(Missing code_verifier parameter)、401(Invalid grant or Invalid PKCE Verifier,訊息裡還會把它算出來的值和當初收到的擺在一起給你看)、200 並回傳完整的 token response。

401 那行值得多看一眼。前面那串 bq3fsseb… 是伺服器拿你送的答案算出來的,而上面那個錯誤答案是我寫死的固定字串,所以你跑出來會是同一個值,可以直接對照。後面那串是你當初送出去的題目,由亂數算出,每個人都不一樣。
同一串 code 換得到兩次 token,是這台 sandbox 沒做單次使用限制,不是標準行為。
PKCE 補的是 public client 的先天不足:沒有 client_secret 可以證明身分,那就臨時生一組,把 code_verifier(答案)留在自己手上,只把 code_challenge(題目)送出門。
程式碼其實只有幾行,getRandomValues 加 crypto.subtle.digest 加 base64url 轉換。真正要想清楚的是那個答案放哪裡,因為它必須橫跨一次完整的頁面跳轉。我們選 sessionStorage 並且用完就刪,火線超人選資料庫加 10 分鐘到期,選擇不同是因為兩邊接住 callback 的人不一樣。
到這裡,authorize URL 上的參數其實都湊齊了。discovery 給了端點、PKCE 給了題目,剩下的都是固定填法。
明天把這些接起來寫成程式,讓 app 自己走完整趟授權,並且把 state 那個一直被我隨便填成 abc123 的參數認真處理掉。