最近在忙一套客製化的預約系統串接第三方金流,遇到一個偶爾才會發作的臭蟲:我在測試站按下結帳,畫面跳出「無效的安全驗證碼」。我的反射動作是進後台清快取,重整,再結一次,結果還是同一個錯。
如果你也遇過清了快取、wp_verify_nonce 卻照樣失敗,這篇就是寫給你的。先講結論:問題通常不在快取有沒有清乾淨,而在 WordPress nonce 是綁使用者 ID 算出來的,一旦頁面被全頁快取存成「訪客版」,你用登入身分送出表單時,兩邊的 uid 對不起來,驗證就過不了。
一個會被快取的結帳頁
這個案例是把前台的預約表單,送出後轉址到第三方金流付款,表單放在活動的頁面,為了 SEO 和載入速度,它本來就需要被快取,在本機可以正常運作,一推上測試站就壞,錯誤訊息回拋 Nonce 驗證錯誤。
第一個線索:同一頁 curl 兩次,nonce 一模一樣
nonce 驗證失敗最常見的原因,是頁面被全頁快取存了下來,連表單裡的 nonce 也一起被凍住,直接 curl 同一個頁面兩次,比對兩次拿到的 nonce 值,如果 nonce 機制正常,每次請求應該都會算出新的值;如果兩次完全一樣,那它就是從快取吐出來的同一份 HTML。
回應的 header 顯示如下:
cache-control: max-age=0, s-maxage=2592000
age: 37
x-cache: HIT
s-maxage=2592000 是 30 天,x-cache: HIT 代表這次回應是從 Varnish 快取命中的,age: 37 是這份快取已經存了 37 秒。測試站的 Varnish 把整個活動前台頁面快取了 30 天,連表單裡的 nonce 都被快取,WordPress nonce 預設只有 12 到 24 小時的有效期,撐不過 30 天,過期之後每個訪客拿到的都是同一個失效的 nonce。
清了快取還是錯,問題比想像中深
到這裡看起來很單純,以為清掉快取讓 HTML 重新產生不就好了。我清完 Breeze 加 Varnish 快取再重新結帳,還是「無效的安全驗證碼」,這代表事情不只是「快取存了過期的 nonce」這麼簡單。
我改用 curl 直接模擬訪客的結帳流程,把「快取」和「登入狀態」這兩個變因拆開來測。結果訪客用頁面上的 nonce 提交竟然通過了 nonce 驗證,所以 nonce 機制本身在測試站是正常的,真正的問題在這裡:
頁面被快取成「訪客版」(nonce 用 uid=0 計算)
↓
我用「登入後台」的管理員身份在前台提交
↓
server 拿我登入的 uid 去驗一個 uid=0 算出來的 nonce → 對不上 → 無效驗證碼
WordPress 的 nonce 不是全站通用的亂數,它把當下使用者的 ID 一起算進去,頁面第一次被沒登入的訪客打開,Varnish 就把這份「uid=0 版」的 HTML 快取起來,之後所有人——包含登入後台的你——拿到的都是這份訪客版 nonce。
我一提交,server 用我真正的 uid 去驗,結果就是對不上,清快取沒用,是因為清完之後只要又有一個訪客先打開頁面,Varnish 又會把訪客版快取回去,登入後提交還是一樣踩雷。
最後一個測試:用無痕視窗(純訪客、沒有快取干擾)結帳,一次就過,平常的瀏覽器因為登入著後台,才會跟快取的訪客版 nonce 衝突,這是「nonce 綁 uid」加上「全頁快取」的衝突案例。
nonce 改成從前端拿
網路上多數文章給的解法是「把快取壽命設短一點」或「把這頁排除在快取之外」。前者治標,而且在 Kinsta、WP Engine 這類代管主機根本改不動快取壽命;後者等於放棄這頁的快取,SEO 和速度都受影響。
我選的方向是讓頁面繼續被快取,但把 nonce 從「寫死在 HTML 裡」改成「送出前即時跟 REST 拿一份新的」。因為 /wp-json/ 不走 Varnish,它回的 nonce 永遠是新鮮的、而且綁定當下這個訪客的 uid。
第一步,在 CheckoutController 加一個只回 nonce 的 endpoint:
register_rest_route(
self::NAMESPACE_ROOT,
'/checkout-nonce',
array(
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => array( $this, 'handle_nonce' ),
)
);
public function handle_nonce( \WP_REST_Request $request ): \WP_REST_Response {
unset( $request );
nocache_headers();
return new \WP_REST_Response(
array( 'nonce' => wp_create_nonce( 'cdx_checkout' ) ),
200
);
}
nocache_headers() 確保這個回應不會被 Varnish 或瀏覽器留下來,每次都是現算的。
第二步,把這個 endpoint 的網址透過 wp_localize_script 丟給前端:
'nonceUrl' => rest_url( 'cdx/v1/checkout-nonce' ),
第三步,表單送出前先 fetch 一份新的 nonce,拿到之後才組表單資料送出:
function fetchFreshNonce( nonceUrl, fallback ) {
if ( ! nonceUrl ) {
return Promise.resolve( fallback );
}
return fetch( nonceUrl, {
method: 'GET',
credentials: 'same-origin',
cache: 'no-store'
} ).then( function ( response ) {
return response.json();
} ).then( function ( data ) {
return ( data && data.nonce ) ? data.nonce : fallback;
} ).catch( function () {
return fallback;
} );
}
credentials: 'same-origin' 很關鍵,它讓這個請求帶上登入 cookie,server 才能用「當下這個 caller 的 uid」去產 nonce,產出來的值跟稍後提交時的 uid 永遠一致。萬一 fetch 失敗,就退回頁面上那份可能過期的 nonce,至少還能嘗試送出。送出的 handler 改成先拿 nonce、再送資料:
fetchFreshNonce( config.nonceUrl, getNonce( form ) ).then( function ( nonce ) {
var formData = buildFormData( detail, nonce, config.spaceId );
return postCheckout( config.restUrl, formData );
} );
這樣空間頁照舊被快取,效能和 SEO 不打折,只有那一小段 nonce 是動態取得。
這邊需要註記的是,官方事實上有提供 admin-ajax.php?action=rest-nonce API 可以直接動態取得 nonce,但之所以我們要自己設計一個 API 來做這件事情,是因為這個場景牽涉到金流的結帳過程,需要跟官方通用的做法區分開來。
nonce 是這樣算出來的:nonce = 時間 + action + 登入的會員 ID + 登入的會員 session
官方動態取得 nonce 的方法是把 action 取名為 wp_rest,這個名稱大家都知道,然後 WP 核心在驗證這個 nonce 的時候,就會用 wp_rest 這個名稱來作為驗證條件之一,所以在做任何的 WP API 的請求時,都會用 wp_rest 這個 aciton 來檢查 nonce,如果我用了這個 nonce,某天萬一這個 nonce 洩漏了,它也能來打我們的結帳 API。
因此後來決定的做法是設計自己的 API 與 action 來取得 nonce,萬一通用的 nonce 洩漏了,能確保不會被攻擊者進行大規模結帳,進而避免最重要的金流暴露在風險之下。
小結
如果你之後也遇到「清了快取還是 invalid nonce」,這裡有個心法:用無痕視窗試一次,如果無痕能過、平常登入的瀏覽器不行,幾乎可以確定就是 nonce 綁 uid 撞上全頁快取。
nonce 為什麼要綁 uid、跟權限檢查怎麼分工,可以看〈WordPress 接案者一定要懂的 CSRF 與權限檢查〉;更全面的站台防護面向,整理在〈WordPress 安全防護實戰〉。而這次靠 SSH 加 WP-CLI 直連測試站、用 curl 拆變因的除錯方式,我在〈AI agent 管 WordPress 該用 MCP、REST API 還是 SSH + WP-CLI?〉裡分享過。
這次遇到的狀況是因為快取和 nonce 兩個機制的前提本來就互相矛盾,快取要把同一份回應存起來給所有人重複用,nonce 卻要每次都不一樣、還要綁住特定使用者,打通這個邏輯後後,哪一頁該快取、哪一頁該排除、哪一頁的 nonce 要改成動態取得,就能照頁面性質直接判斷,不用再一個一個試。