上一篇介紹 Access Token 被偷走後,如何透過短效期限、Scope、Audience,以及 mTLS/DPoP 限制冒用。其中,DPoP 不只要求 Client 出示 Token,還需要附上使用私鑰簽署的 Proof,證明自己持有與 Token 綁定的金鑰。
然而,這也延伸出另一個問題:若攻擊者取得的不只是 Access Token,而是連同某次有效的驗證資料或 Proof 一併取得,並將相同資料再次提交,伺服器應如何判斷與處理?
即使系統已經記錄「這份資料使用過了」,也不代表問題就結束。如果兩個請求幾乎同時抵達,都在狀態更新前通過檢查,原本只能用一次的驗證資料,仍然可能被接受兩次。另外,就算最後都回傳驗證失敗,處理時間的差異也可能透露系統不想公開的資訊。
因此,今天要從三個角度檢查驗證流程:資料是否被重複使用、狀態是否被正確更新,以及處理時間是否洩漏資訊。
今天內容涵蓋:
| 類型 | 核心問題 | 驗證流程中的例子 | 主要防護方向 |
|---|---|---|---|
| 重放攻擊(Replay Attack) | 舊的有效資料被再次提交,系統仍然接受 | 已使用的 Authorization Code 或同一份驗證 Proof 再次被接受 | 有效期限檢查、Nonce/jti 檢查、情境綁定、重複使用偵測 |
| 時序攻擊(Timing Attack) | 系統回應時間不同,可能讓攻擊者推測出不該知道的資訊 | 比對 MAC 時提早結束,或透過登入耗時推測帳號是否存在 | 安全比較函式、避免依祕密資訊產生可觀察的時間差 |
| 競態條件(Race Condition) | 多個操作交錯執行,讓共享狀態違反原本的規則 | 兩個請求都看到驗證碼「尚未使用」,接著各自建立 Session | 原子性操作、正確的鎖定與交易邊界 |
這裡也要先區分另一種情境:如果資料已經過期,卻因為伺服器時間錯誤、時鐘同步失效,或系統錯誤信任 Client 提供的時間而被接受,問題就不只是重放攻擊,而是過期驗證或時間判斷不正確。
重放攻擊的核心,是把原本有效的訊息或驗證資料,再次提交給伺服器。攻擊者不一定需要修改內容,也不一定需要破解簽章;只要系統沒有判斷這份資料是否已經使用過,就可能把它當成新的合法請求。

可以依照圖中的流程理解:
這裡重送的是應用層的憑證或訊息,並不是指攻擊者只要複製已加密的 TLS 封包,就能直接繞過 HTTPS。這裡討論的是應用層憑證或訊息外洩後被再次提交的情境;相關資料可能在進入加密通道前外洩,也可能在抵達 Client、伺服器後,被寫入 Log、Trace 或錯誤報告而外洩。只要資料仍符合伺服器的接受條件,攻擊者就可能嘗試重新提交。
談重放攻擊時,要先區分「正常重複使用」與「不應再次接受」這兩種情境。正常的重複使用,不等於重放攻擊。
例如,Access Token 通常可以在有效期限與授權範圍內多次呼叫 API;只要使用者、Client、Scope 與 Audience 都符合預期,這就是正常使用。相對地,有些驗證資料本來就只能成功使用一次,使用後再次出現時,伺服器就應該拒絕。
| 資料 | 預期使用方式 | 需要注意的限制 |
|---|---|---|
| Access Token | 通常可在有效期限與授權範圍內多次使用 | 控制重點在於避免 Token 被非預期的持有人冒用,而非將每顆 Token 都設計為單次 API 呼叫後失效 |
| Authorization Code | 用來交換 Token,且只能成功兌換一次 | 使用成功後,後續兌換必須被拒絕 |
| OTP 驗證輸出 | 用於一次驗證 | 即使仍在有效期間,成功使用後也不能再次接受同一輸出 |
| DPoP Proof | 針對單次請求產生持有證明 | 應依防重放政策檢查 Proof 是否已被使用過 |
以 Authorization Code 為例,RFC 6749 §4.1.2 已規定它不能被使用超過一次;PKCE 則是在兌換 Code 時,額外要求 Client 提出正確的 Code Verifier。也就是說,PKCE 是保護 Code 兌換過程的補強,不是 Authorization Code 只能用一次的來源。
OTP 也有類似限制。NIST SP 800-63B-4 要求 OTP 在有效期間內只能被成功接受一次。因此,「驗證碼還沒過期」不代表「可以重複使用」;伺服器仍應保存成功使用紀錄,並在相同驗證資料再次提交時予以拒絕。
⚠️一次性限制只能避免同一份資料被接受兩次,不能保證第一次提交的人就是合法使用者。
如果攻擊者先取得並提交 OTP、Authorization Code 或其他一次性驗證資料,伺服器仍可能把它視為第一次有效使用。因此,防重放機制仍需要搭配傳輸保護、流程綁定與適當的身分驗證設計。
上面提到,重放攻擊的關鍵不只是「資料是否有效」,而是「同一份資料是否應該再次被接受」。因此,伺服器除了驗證簽章與格式,也需要檢查三類資訊:
對應到實作上,常見的欄位就是有效期限、jti 與 Nonce。
| 機制 | 主要用途 | 使用時要注意什麼 |
|---|---|---|
| 有效期限 | 限制資料可以被接受的時間,例如只能在幾分鐘內有效 | 只能限制時間;在過期以前,同一份資料仍可能被重送 |
jti |
替 JWT 或 Proof 加上一個唯一編號,讓伺服器辨識是否同一份資料又出現 | 伺服器必須保存已接受過的 jti,否則只有編號也無法阻止重送 |
| Nonce | 把資料對應到本次流程或伺服器要求,避免舊流程的資料被拿來混用 | 不同協定的 Nonce 用途不同;要看是 OIDC Nonce、DPoP Nonce,或其他 Challenge 流程 |
Nonce 在前一篇已經出現過:OIDC Nonce 用來確認 ID Token 是否屬於本次登入流程。本篇的重點則放在更廣的防重放情境:Nonce 如何協助判斷資料是否屬於這次流程,而 jti 與有效期限又各自補上哪些檢查。
有效期限用來限制資料可以被接受的時間範圍。例如 JWT 常見的 exp、nbf 與 iat,分別表示到期時間、開始可接受時間與核發時間。
但時間檢查只能判斷「現在是否仍可接受」,不能判斷「這份資料是否已經被用過」。如果某份資料在 12:05 到期,攻擊者在 12:01 與 12:02 重送同一份資料,兩次都可能通過時間檢查。
因此,短效期限只能縮小攻擊窗口,不能取代使用紀錄或防重放檢查。
如果 Token 原本 12:05 到期,但系統允許 5 秒誤差,那它可能到 12:05:05 之前都還會被接受。防重放紀錄也應該至少保留到 12:05:05;如果 12:05 就先刪掉,攻擊者在 12:05:03 重送時,系統可能因為紀錄已被刪除,而無法判斷這份資料先前已經用過。
jti 是 JWT ID,可用來替每一份 JWT 或 Proof 加上一個唯一識別碼。伺服器可以依照這個識別碼,判斷同一份資料是否曾經被提交過。
不過,jti 本身只是編號,不是防護機制。如果伺服器只解析 jti,卻沒有保存並查詢它是否曾經被接受,當攻擊者重新提交同一份 JWT 時,系統仍可能將它視為有效資料。
對於這類需要限制單次使用的資料,伺服器通常會透過 Replay Cache(防重放紀錄) 保存已接受過的 jti:
jti 是否已經出現在防重放紀錄中。防重放紀錄要記錄「哪一份資料」已經被接受過,不能只看 jti 這個值本身。
例如,A 系統發出的 Token 和 B 系統發出的 Token,可能剛好使用相同的 jti。這不一定代表有人重送同一份資料;只有來源、Token 類型與使用情境都相同時,才應該判斷為同一份資料再次提交。
另外,紀錄要保留到資料不可能再被接受為止。只要系統仍可能接受這份資料,就應該還查得到它是否已經使用過。
Nonce 通常是隨機或不重複的值,用來把資料和特定流程或特定請求連在一起。Day 27 提過的 OIDC Nonce,就是這個概念。Client 在登入流程開始時產生 Nonce,Authorization Server 簽發 ID Token 時把它寫進 Token。Client 收到 ID Token 後,再檢查裡面的 Nonce 是否一致,用來確認這顆 ID Token 是否屬於本次登入。
DPoP Nonce 的方向則不同。它通常由 Authorization Server 或 Resource Server 提供,要求 Client 放進下一次簽署的 Proof。伺服器收到 Proof 後,會檢查 Nonce 是否符合要求,用來降低同一份 Proof 被重複提交的風險。
所以,看到 Nonce 時,要先看它用在哪個流程:
兩者都叫 Nonce,但產生者、放置位置和驗證時機不同,不能直接套用同一種規則。
這三項檢查各自處理不同問題:有效期限看時間,jti 看是否重複出現,Nonce 看是否屬於本次流程。防重放設計通常需要把它們合併在同一個驗證流程中,而不是只選其中一項。
防重放機制需要記錄哪些資料已經使用過。但如果兩個請求幾乎同時進來,問題可能發生在「查詢紀錄」和「寫入紀錄」之間。
問題可以先用一般流程理解:
單看一個請求,這個流程看起來合理;但在兩個請求同時進來時,問題就會出現:
| 步驟 | 請求 A | 請求 B | 共享狀態 |
|---|---|---|---|
| 1 | 讀取 Challenge,看到尚未使用 | 尚未讀取 | 尚未使用 |
| 2 | 驗證通過,準備標記已使用 | 讀取同一筆 Challenge,也看到尚未使用 | 尚未使用 |
| 3 | 標記已使用,並建立 Session | 仍依剛才讀到的狀態繼續處理 | 已使用 |
| 4 | 處理完成 | 如果沒有再次確認狀態,仍可能建立第二個 Session | 已使用 |
最後資料庫裡可能只看得到「已使用」,但實際上同一份 Challenge 已經被接受兩次。
這就是競態條件在驗證流程中的風險,問題不在於資料有沒有過期,而在於「檢查尚未使用」和「標記已使用」之間,留下了其他請求可以插入的空隙。
TOCTOU 是競態條件的一種常見模式。它的全名是 Time-of-Check to Time-of-Use,指的是「檢查」與「使用」之間的時間差帶來的錯誤:檢查時看到的狀態,到真正要使用時可能已經被別的請求改掉了,MITRE 將它收錄為 CWE-367 的弱點類型。
也就是說,先前查到的結果只代表那一瞬間的情況,不代表現在仍然成立;如果程式還是拿舊的檢查結果繼續往下做,就會依照已經過時的狀態放行。
以上方表格中的 Challenge 併發請求為例:請求 B 一開始讀取 Challenge 時,狀態仍是「尚未使用」;但在請求 B 真的要建立 Session 前,請求 A 已經先把同一筆 Challenge 標記為已使用。也就是說,請求 B 先前取得的檢查結果,已經不是目前狀態。
如果請求 B 仍然依照舊的檢查結果繼續處理,就可能讓同一份 Challenge 被接受第二次。這就是 TOCTOU 在驗證流程中的問題:檢查時通過,不代表使用時狀態仍然有效。
如果系統只有單機測試,這類問題有時不容易被發現。但實際部署時,同一份 Proof 的兩次請求可能被分派到不同的 API Server。這時,如果每台伺服器只用自己的記憶體記錄已使用的 jti,Server A 記得這份 Proof 用過,不代表 Server B 也知道。結果是,相同 Proof 被送到另一台伺服器時,仍可能被當成第一次出現。
所以,防重放紀錄和一次性使用狀態,不能只存在單一伺服器的本機記憶體中;它們需要放在所有 API 執行個體都能一致查詢與更新的位置。
要避免這類競態,不能依賴程式執行速度足夠快,而是必須讓「確認仍可使用」與「取得本次使用資格」在同一個不可被其他請求插入的操作中完成。
這就是 Atomic Operation(原子性操作) 要處理的問題。原子性指的是:一組動作對外只有「全部完成」與「完全未發生」兩種結果,不會暴露中間狀態,也不會在執行過程中允許其他請求介入。
因此,問題不在於「已使用」這個欄位有沒有被寫入,而在於寫入前的檢查是不是和寫入分開執行:
| 實作方式 | 執行步驟 | 並發下的結果 |
|---|---|---|
| 檢查與更新分開執行 | 先查詢狀態為「尚未使用」,再更新為「已使用」 | 兩個步驟之間存在空隙,併發請求可能都讀到「尚未使用」 |
| 檢查與更新合為一個操作 | 在同一個操作中確認「尚未使用」並標記為「已使用」 | 併發競爭時僅一個請求能成功,其餘因條件不符而失敗 |
以資料庫保存登入 Challenge 為例,可將使用狀態納入更新條件。下列語句採 PostgreSQL 風格示意,其中參數應由資料庫驅動綁定,id 為唯一識別碼:
UPDATE auth_challenges
SET used_at = clock_timestamp()
WHERE id = :challenge_id
AND account_id = :account_id
AND purpose = 'login'
AND used_at IS NULL
AND expires_at > clock_timestamp()
RETURNING id;
該語句的作用不僅是將資料標記為已使用,而是將使用資格的判定條件一併納入更新操作:唯有帳號與用途相符、狀態尚未使用且尚未逾期時,本次更新才會成功。
執行該語句之前,伺服器仍應先完成驗證碼或簽章檢查,並確認所驗證的對象為同一份 Challenge 資料。請求僅攜帶 Challenge ID,不足以作為將該筆 Challenge 標記為已使用並放行的依據。
這條 UPDATE 會告訴程式它更新了幾筆資料;程式必須依這個筆數決定是否放行:
能達到這個效果,是因為常見的關聯式資料庫(Relational Database)在更新同一筆資料時會互相排除:後到的更新必須等前一個交易結束,才能以最新的資料重新比對 WHERE 條件。前一個請求提交成功後,「尚未使用」這個條件就不再成立,後到的請求自然更新 0 筆。實際行為仍與所用資料庫的隔離層級有關,導入前應確認官方文件的定義。
這裡真正保護一次性規則的,是更新時再次檢查目前的狀態,並讓資料庫處理競爭。應用程式也必須依更新結果決定是否放行,而不是只有新增一個「已使用」欄位。
這裡的交易指的是資料庫交易(Database Transaction),不是金流或訂單那種交易;它是用 BEGIN … COMMIT 把幾條 SQL 框成一組,讓它們一起生效或一起取消。以本節的例子來說,一次登入請求在伺服器內部會對資料庫發出多條 SQL(查詢 Challenge、標記已使用、建立 Session),這些 SQL 就可以被包在同一個交易裡。
把這些 SQL 包進交易,不等於競態就消失了。交易保證的是「要麼全部寫入,要麼全部取消」,並不保證別的請求不會在這段期間讀到同一筆資料。如果交易裡面仍然是「先 SELECT 確認尚未使用,再無條件 UPDATE 成已使用」,兩個請求仍可能都讀到「尚未使用」,然後各自放行。
真正決定安全的是下面這些寫法:
UPDATE 的條件裡。jti)設為唯一欄位;兩個請求同時新增時,只有一個能成功,另一個會因重複而失敗。SELECT … FOR UPDATE): 先鎖住要檢查的資料,其他請求必須等待;適用於驗證需要跨多個步驟完成的情況。還有兩點要注意:
UPDATE 只能保護資料庫裡的那一筆資料。若流程還要寫 Redis、送通知信件或簡訊、呼叫外部 API、寫審計紀錄,這些動作不在資料庫交易的保護範圍內,遇到重試時可能被執行多次,需要各自設計冪等或去重。上面的做法同樣適用於 jti:直接寫入並限定同一個 jti 只能存一筆,由儲存系統決定誰是第一個。
但這裡多了一個狀況:查不到紀錄,不代表這份資料真的沒用過。 紀錄可能因為過期被提早刪除、Redis 重啟遺失,或查詢失敗而查不到。如果這時候直接放行,防重放機制等於在故障期間完全失效;高風險的流程應該寧可拒絕請求,或要求使用者重新驗證。
前面的重放與競態問題,都在處理資料能否再次使用。時序攻擊則換了一個方向:攻擊者不只看驗證結果,而是觀察伺服器花多久回應。
MITRE CWE-208 將這類問題稱為 Observable Timing Discrepancy。即使差異很小,只要能觀察到與祕密資訊有關的規律,就可能形成側通道(Side Channel)。
MAC(Message Authentication Code,訊息鑑別碼)是使用共享祕密金鑰計算的驗證值,用來檢查訊息完整性與來源是否符合預期,與使用公私鑰的數位簽章不同。
假設伺服器要比較自己計算出的 MAC,與 Client 傳來的 MAC,卻採用逐位元組比對,從第一個位元組開始一個一個比,一遇到不同就立刻回傳失敗。
如果第一個位元組就不同,函式很快結束;如果前面一段都相同,程式就會做更多次比較。
這讓工作量與「已經猜對多少前綴」產生關聯。攻擊者可能透過反覆觀察與統計,推測原本不應公開的資訊。
實際能否利用,仍與執行環境、時間差大小、網路雜訊及量測條件有關;不是一次請求比較慢,就能直接還原祕密。但網路有雜訊,也不是保證時間差無法被利用的理由。
另一個常見例子,是登入流程採用提早返回:帳號不存在時直接回傳「帳號或密碼錯誤」;帳號存在時,則先執行密碼雜湊驗證,再回傳同一句「帳號或密碼錯誤」。
兩種情況的文字完全相同,但第二種多做了一次成本較高的密碼驗證。攻擊者可能從時間差推測哪些帳號存在,再把後續猜測集中到有效帳號。
OWASP Authentication Cheat Sheet 因此提醒,避免帳號列舉不能只統一錯誤文字,還要留意處理路徑、HTTP 狀態與其他可觀察差異。
Constant-Time Comparison(恆定時間比較) 的重點,是不要因為「前面對了多少位元組」而改變比對所花的時間。做法是,不管中途有沒有發現不同,都把所有位元組比完,最後才回傳成功或失敗。這樣每次比對的耗時都差不多,攻擊者就無法從快慢判斷自己提供的值有多接近正確答案。
實作時不要自己寫迴圈比對,而是直接使用語言或密碼學函式庫提供的安全比較函式,例如 Python 官方文件建議驗證 HMAC 輸出時使用 hmac.compare_digest()。
就算比較 MAC 這一行已經安全,整條處理路徑還是可能留下時間差。實務上有四點要注意:
另外,加上固定或隨機延遲只是把差異藏進雜訊,並沒有真的消除它;HTTPS 也不會隱藏整體回應時間。
驗證流程裡的時間與狀態問題,可以分成三種來看:
jti 與 Nonce 也需要搭配實際的綁定、驗證與使用紀錄。綜上所述,驗證機制除了確認資料的真偽,尚須一併檢核:資料是否屬於本次流程、目前是否仍在可接受的時間範圍內、是否已經使用過,以及處理過程是否洩漏不應公開的資訊。
當這些防護逐漸補齊,下一個問題就是它們帶來的成本。最後一篇將回到安全與效能的取捨,整理雜湊成本、Token 驗證、快取與限流,思考哪些地方可以優化,以及哪些必要檢查不能為了速度而省略。