<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Laravel on Tarragon</title><link>https://tarrragon.github.io/blog/tags/laravel/</link><description>Recent content in Laravel on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 18 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/laravel/index.xml" rel="self" type="application/rss+xml"/><item><title>Laravel Sanctum 的 Bearer Token 設計剖析：{PK}|{secret} 為什麼這樣設計</title><link>https://tarrragon.github.io/blog/work-log/laravel-sanctum-%E7%9A%84-bearer-token-%E8%A8%AD%E8%A8%88%E5%89%96%E6%9E%90pksecret-%E7%82%BA%E4%BB%80%E9%BA%BC%E9%80%99%E6%A8%A3%E8%A8%AD%E8%A8%88/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/laravel-sanctum-%E7%9A%84-bearer-token-%E8%A8%AD%E8%A8%88%E5%89%96%E6%9E%90pksecret-%E7%82%BA%E4%BB%80%E9%BA%BC%E9%80%99%E6%A8%A3%E8%A8%AD%E8%A8%88/</guid><description>&lt;h2 id="sanctum-pat-這篇要解決什麼">Sanctum PAT 這篇要解決什麼&lt;/h2>
&lt;p>Sanctum PAT 的核心設計是把「找 row」與「比對 secret」拆成兩個責任。Laravel Sanctum 的 Personal Access Token（簡稱 PAT）長這樣：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">1|abc123def456ghi789jkl012mno345pqr678stu
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">↑ ↑
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">DB 主鍵 真正的祕密&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>豎線前的數字是 &lt;code>personal_access_tokens&lt;/code> 資料表的 primary key、豎線後是高熵隨機字串。這個設計在 Laravel 生態裡很常見、但常被誤解為「業界標準 token 格式」 — 實際上是 Sanctum 特定的設計選擇、跟 GitHub PAT（&lt;code>ghp_...&lt;/code>）、Stripe API Key（&lt;code>sk_live_...&lt;/code>）的設計取捨完全不同。&lt;/p>
&lt;p>本文拆解 Sanctum PAT 三個關鍵設計決策：&lt;/p>
&lt;ol>
&lt;li>為什麼把 PK 公開放進 token&lt;/li>
&lt;li>DB 為什麼只存 hash 不存原文&lt;/li>
&lt;li>constant-time 比對為什麼放在應用層、不放在 DB&lt;/li>
&lt;/ol>
&lt;p>讀完後，你可以用 token 的傳播範圍、撤銷需求與洩漏偵測需求，判斷自己的 application 適合 Sanctum 風格還是其他 token format，並把 hash 儲存與 constant-time 比對原則套用到非 Laravel 環境。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>本文位置&lt;/strong>：本文是 &lt;a href="https://tarrragon.github.io/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/" data-link-title="API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning" data-link-desc="API 認證的信任邊界分層（Bearer Token / Shared Secret / Provisioning）：各層的洩漏後果與撤銷方式，以及混用造成的設計失效模式。">API 認證的三層信任邊界&lt;/a> Layer 1 的深入篇。主文聚焦「為什麼要分層」的心智模型、本文聚焦「Sanctum 這個特定實作怎麼設計、為什麼」。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="sanctum-在-laravel-認證生態的位置">Sanctum 在 Laravel 認證生態的位置&lt;/h2>
&lt;p>Laravel 官方提供三套認證套件、各自解的問題不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>套件&lt;/th>
 &lt;th>解的問題&lt;/th>
 &lt;th>Token 機制&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Laravel Breeze&lt;/strong>&lt;/td>
 &lt;td>server-rendered 應用的登入註冊 starter&lt;/td>
 &lt;td>session cookie&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Laravel Sanctum&lt;/strong>&lt;/td>
 &lt;td>SPA / mobile app / 簡單 API token 認證&lt;/td>
 &lt;td>session cookie + PAT（&lt;code>{PK}|{secret}&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Laravel Passport&lt;/strong>&lt;/td>
 &lt;td>完整 OAuth 2.0 server 實作&lt;/td>
 &lt;td>JWT-based access token&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Sanctum 的設計目標是「&lt;strong>比 Passport 簡單、比手刻 token 嚴謹&lt;/strong>」 — 不引入 OAuth 的完整 flow，但解決 token issue、storage、revoke 的常見坑。&lt;code>{PK}|{secret}&lt;/code> 是這個設計目標下的具體 trade-off。&lt;/p>
&lt;hr>
&lt;h2 id="設計決策一為什麼把-pk-公開放進-token">設計決策一：為什麼把 PK 公開放進 token&lt;/h2>
&lt;h3 id="驗證-token-的兩個責任">驗證 token 的兩個責任&lt;/h3>
&lt;p>Server 收到 client 傳來的 token、要做兩件事：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>找到&lt;/strong> DB 裡對應的 row（這個 token 是哪個 user 的）&lt;/li>
&lt;li>&lt;strong>比對&lt;/strong> 確認 token 沒被偽造&lt;/li>
&lt;/ol>
&lt;p>如果 token 只是純隨機字串（沒有 PK 前綴），validation 的 SQL 常會被設計成：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">personal_access_tokens&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">?&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這要求 &lt;code>token&lt;/code> 欄位有 index，且 server 要讓 DB 同時負責 lookup 與 secret 比對。效能通常不是瓶頸，真正的設計問題是 secret 比對落在應用層控制範圍之外。&lt;/p>
&lt;h3 id="db-比對的-timing-不可控">DB 比對的 timing 不可控&lt;/h3>
&lt;p>DB 查詢適合處理索引搜尋，不適合承擔機密字串的 timing-safe 比對。當 &lt;code>WHERE token = ?&lt;/code> 在 DB 執行時，執行時間可能洩漏：&lt;/p></description><content:encoded><![CDATA[<h2 id="sanctum-pat-這篇要解決什麼">Sanctum PAT 這篇要解決什麼</h2>
<p>Sanctum PAT 的核心設計是把「找 row」與「比對 secret」拆成兩個責任。Laravel Sanctum 的 Personal Access Token（簡稱 PAT）長這樣：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1|abc123def456ghi789jkl012mno345pqr678stu
</span></span><span class="line"><span class="ln">2</span><span class="cl">↑           ↑
</span></span><span class="line"><span class="ln">3</span><span class="cl">DB 主鍵     真正的祕密</span></span></code></pre></div><p>豎線前的數字是 <code>personal_access_tokens</code> 資料表的 primary key、豎線後是高熵隨機字串。這個設計在 Laravel 生態裡很常見、但常被誤解為「業界標準 token 格式」 — 實際上是 Sanctum 特定的設計選擇、跟 GitHub PAT（<code>ghp_...</code>）、Stripe API Key（<code>sk_live_...</code>）的設計取捨完全不同。</p>
<p>本文拆解 Sanctum PAT 三個關鍵設計決策：</p>
<ol>
<li>為什麼把 PK 公開放進 token</li>
<li>DB 為什麼只存 hash 不存原文</li>
<li>constant-time 比對為什麼放在應用層、不放在 DB</li>
</ol>
<p>讀完後，你可以用 token 的傳播範圍、撤銷需求與洩漏偵測需求，判斷自己的 application 適合 Sanctum 風格還是其他 token format，並把 hash 儲存與 constant-time 比對原則套用到非 Laravel 環境。</p>
<blockquote>
<p><strong>本文位置</strong>：本文是 <a href="/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/" data-link-title="API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning" data-link-desc="API 認證的信任邊界分層（Bearer Token / Shared Secret / Provisioning）：各層的洩漏後果與撤銷方式，以及混用造成的設計失效模式。">API 認證的三層信任邊界</a> Layer 1 的深入篇。主文聚焦「為什麼要分層」的心智模型、本文聚焦「Sanctum 這個特定實作怎麼設計、為什麼」。</p></blockquote>
<hr>
<h2 id="sanctum-在-laravel-認證生態的位置">Sanctum 在 Laravel 認證生態的位置</h2>
<p>Laravel 官方提供三套認證套件、各自解的問題不同：</p>
<table>
  <thead>
      <tr>
          <th>套件</th>
          <th>解的問題</th>
          <th>Token 機制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Laravel Breeze</strong></td>
          <td>server-rendered 應用的登入註冊 starter</td>
          <td>session cookie</td>
      </tr>
      <tr>
          <td><strong>Laravel Sanctum</strong></td>
          <td>SPA / mobile app / 簡單 API token 認證</td>
          <td>session cookie + PAT（<code>{PK}|{secret}</code>）</td>
      </tr>
      <tr>
          <td><strong>Laravel Passport</strong></td>
          <td>完整 OAuth 2.0 server 實作</td>
          <td>JWT-based access token</td>
      </tr>
  </tbody>
</table>
<p>Sanctum 的設計目標是「<strong>比 Passport 簡單、比手刻 token 嚴謹</strong>」 — 不引入 OAuth 的完整 flow，但解決 token issue、storage、revoke 的常見坑。<code>{PK}|{secret}</code> 是這個設計目標下的具體 trade-off。</p>
<hr>
<h2 id="設計決策一為什麼把-pk-公開放進-token">設計決策一：為什麼把 PK 公開放進 token</h2>
<h3 id="驗證-token-的兩個責任">驗證 token 的兩個責任</h3>
<p>Server 收到 client 傳來的 token、要做兩件事：</p>
<ol>
<li><strong>找到</strong> DB 裡對應的 row（這個 token 是哪個 user 的）</li>
<li><strong>比對</strong> 確認 token 沒被偽造</li>
</ol>
<p>如果 token 只是純隨機字串（沒有 PK 前綴），validation 的 SQL 常會被設計成：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">personal_access_tokens</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">token</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">?</span></span></span></code></pre></div><p>這要求 <code>token</code> 欄位有 index，且 server 要讓 DB 同時負責 lookup 與 secret 比對。效能通常不是瓶頸，真正的設計問題是 secret 比對落在應用層控制範圍之外。</p>
<h3 id="db-比對的-timing-不可控">DB 比對的 timing 不可控</h3>
<p>DB 查詢適合處理索引搜尋，不適合承擔機密字串的 timing-safe 比對。當 <code>WHERE token = ?</code> 在 DB 執行時，執行時間可能洩漏：</p>
<ul>
<li>B-tree index 的查找路徑長度（同 prefix 的 row 多時、走的 page 不同）</li>
<li>字串比對的短路行為（多數 DB 引擎不保證 constant-time 比對）</li>
<li>Buffer pool hit / miss 造成的時間差</li>
</ul>
<p>攻擊者透過大量探測，可能推斷出有效 token 的部分結構。雖然實務上利用這個 leak 攻擊成本很高，但更穩健的設計原則是：安全機制應放在 application 能明確控制的比對函式，而不是依賴 DB 引擎的實作細節。</p>
<h3 id="sanctum-的解法用-pk-收斂搜尋把比對搬到應用層">Sanctum 的解法：用 PK 收斂搜尋、把比對搬到應用層</h3>
<p><code>{PK}|{secret}</code> 的設計把驗證拆成兩步：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">client 傳來: &#34;1|abc123...&#34;
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">       ↓
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">   server 拆解
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">       ↓
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">   ┌──────────────┐
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">   │ PK = 1       │ ──→ SELECT * FROM tokens WHERE id = 1
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">   │ secret = abc │      （O(log N)、行為穩定）
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">   └──────────────┘
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">       ↓
</span></span><span class="line"><span class="ln">10</span><span class="cl">   拿到該 row 的 hash
</span></span><span class="line"><span class="ln">11</span><span class="cl">       ↓
</span></span><span class="line"><span class="ln">12</span><span class="cl">   hash_equals(stored_hash, sha256(secret))
</span></span><span class="line"><span class="ln">13</span><span class="cl">       ↓
</span></span><span class="line"><span class="ln">14</span><span class="cl">   constant-time 比對、不洩漏 timing</span></span></code></pre></div><p>關鍵在於 <strong>DB 只負責「找到單一 row」、不負責「比對機密」</strong>：</p>
<table>
  <thead>
      <tr>
          <th>動作</th>
          <th>由誰處理</th>
          <th>為什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>用 PK 找到 row</td>
          <td>DB（O(log N)）</td>
          <td>PK 是公開資訊、即使 timing 洩漏也沒安全意義</td>
      </tr>
      <tr>
          <td>比對 secret hash 是否相等</td>
          <td>應用層 constant-time</td>
          <td>在控制範圍內、可保證不依輸入內容變化執行時間</td>
      </tr>
  </tbody>
</table>
<h3 id="常見誤解pk-讓查詢變-o1">常見誤解：「PK 讓查詢變 O(1)」</h3>
<p>PK 前綴的主要價值是安全責任切分，不是把查詢從慢變快。很多 Sanctum 教學文章寫「PK 把查詢變 O(1)、避免 full scan」，這個說法忽略了 hash 欄位也能被索引：</p>
<ul>
<li><strong>hash 欄位也能 index</strong> — <code>WHERE token_hash = ?</code> 用 B-tree index 是 O(log N)、不是 full scan</li>
<li><strong>兩條路都是 B-tree index lookup</strong> — token 規模下都不會是效能瓶頸；clustered（PK）跟 secondary（hash）的 IO cost 微差在多數場景可忽略</li>
</ul>
<p>PK 設計的<strong>主要價值在安全可預測性</strong>、效能差距在多數場景可忽略：把比對機密的責任明確劃在「應用層 constant-time 函式」、不依賴 DB 引擎不保證的 timing 行為。</p>
<p>效能差異反而出現在「<strong>hash 欄位是否要 index</strong>」 — 如果用 hash lookup、<code>token_hash</code> 欄位需要 unique index、寫入成本變高；用 PK lookup、<code>token_hash</code> 不需要 index、寫入更輕量。但這在 token 規模通常不是 bottleneck。</p>
<hr>
<h2 id="設計決策二db-只存-hash-的威脅模型">設計決策二：DB 只存 hash 的威脅模型</h2>
<h3 id="威脅模型db-被攻陷">威脅模型：DB 被攻陷</h3>
<p>Token 是 capability credential — 持有即授權。如果 DB 直接存 plaintext token、任何能讀取 DB 的人（SQL injection、備份外流、運維 dump 不小心 push 到 GitHub）都能直接拿 token 假冒使用者發 request。</p>
<p>Sanctum 的做法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 發放 token
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nv">$plaintext</span> <span class="o">=</span> <span class="nx">Str</span><span class="o">::</span><span class="na">random</span><span class="p">(</span><span class="mi">40</span><span class="p">);</span>  <span class="c1">// Sanctum 預設 40 char、base62 字元集
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="nv">$hash</span> <span class="o">=</span> <span class="nx">hash</span><span class="p">(</span><span class="s1">&#39;sha256&#39;</span><span class="p">,</span> <span class="nv">$plaintext</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">DB</span><span class="o">::</span><span class="na">table</span><span class="p">(</span><span class="s1">&#39;personal_access_tokens&#39;</span><span class="p">)</span><span class="o">-&gt;</span><span class="na">insert</span><span class="p">([</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="s1">&#39;token&#39;</span> <span class="o">=&gt;</span> <span class="nv">$hash</span><span class="p">,</span>           <span class="c1">// DB 只存 hash
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>    <span class="s1">&#39;tokenable_id&#39;</span> <span class="o">=&gt;</span> <span class="nv">$userId</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">]);</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="k">return</span> <span class="nv">$tokenId</span> <span class="o">.</span> <span class="s1">&#39;|&#39;</span> <span class="o">.</span> <span class="nv">$plaintext</span><span class="p">;</span>  <span class="c1">// 只此一次回給 client、之後再也拿不到
</span></span></span></code></pre></div><p>意義：<strong>DB 被 dump 時，攻擊者拿到的是不可直接使用的 hash</strong>。攻擊者要還原 <code>plaintext</code> 需要對 SHA-256 做 preimage attack；對 40 字元高熵隨機字串而言，計算成本實務上不可行。</p>
<h3 id="sha-256-與-bcrypt-的適用差異">SHA-256 與 bcrypt 的適用差異</h3>
<p>密碼儲存用 bcrypt / Argon2 是因為<strong>密碼通常熵低</strong>（人類記得住的東西、entropy 通常 &lt; 40 bit）、要刻意慢、抵抗 offline brute-force。</p>
<p>Token 是<strong>高熵隨機字串</strong>（40 char base62 ≈ 238 bit entropy、比一般人類記得住的 password 高約 6 個數量級的熵）— 攻擊者就算拿到 hash、暴力枚舉 plaintext 的搜尋空間是 <code>62^40 ≈ 10^71</code>、宇宙年齡內試不完。在這個前提下：</p>
<table>
  <thead>
      <tr>
          <th>演算法</th>
          <th>處理時間（每次驗證）</th>
          <th>對 token 是否合理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SHA-256</td>
          <td>~微秒</td>
          <td>完全足夠</td>
      </tr>
      <tr>
          <td>bcrypt（cost=12）</td>
          <td>~250ms</td>
          <td>浪費 CPU、無增益</td>
      </tr>
  </tbody>
</table>
<p>在高熵 token 的前提下，SHA-256 的速度是優點，因為每次 API request 都需要驗證 token。bcrypt 的慢速設計主要服務低熵 password，套到高熵 token 會增加延遲而沒有對應的安全收益。</p>
<h3 id="salt-的適用邊界">Salt 的適用邊界</h3>
<p>bcrypt 用 salt 是為了防 <strong>rainbow table 攻擊</strong>（預算好常見密碼的 hash、查表）。Rainbow table 對「人類選的密碼」有效、對「40 char 高熵 token」無效（搜尋空間太大、預算表的成本超過直接 brute-force）。</p>
<p>所以 Sanctum 對 token 用 unsalted SHA-256，是符合「高熵隨機 token」威脅模型的選擇。若 credential 來源改成人類可記憶密碼，威脅模型就會改變，儲存策略也要回到 password hashing。</p>
<hr>
<h2 id="設計決策三constant-time-比對放在應用層">設計決策三：constant-time 比對放在應用層</h2>
<h3 id="constant-time-比對在解什麼">Constant-time 比對在解什麼</h3>
<p><code>==</code> 或 <code>strcmp</code> 比對字串時、會「<strong>短路</strong>」 — 一發現不同就回傳 false：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 偽程式碼：strcmp 的典型實作
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">for</span> <span class="p">(</span><span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">len</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">a</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">!=</span> <span class="n">b</span><span class="p">[</span><span class="n">i</span><span class="p">])</span> <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>  <span class="c1">// ← 在這裡 return、不跑完
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">return</span> <span class="nb">true</span><span class="p">;</span></span></span></code></pre></div><p>攻擊者可量測「server 從收到 request 到回 401」的時間、推斷「前幾個 byte 是對的」：</p>
<table>
  <thead>
      <tr>
          <th>嘗試的 token</th>
          <th>跑了幾個 byte 才 return</th>
          <th>server 回應時間</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>aaaaaaaa...</code></td>
          <td>1（第 1 byte 就錯）</td>
          <td>~1 μs</td>
      </tr>
      <tr>
          <td><code>1aaaaaaa...</code></td>
          <td>2（第 2 byte 才錯）</td>
          <td>~2 μs</td>
      </tr>
      <tr>
          <td><code>1a aaaaa...</code></td>
          <td>3</td>
          <td>~3 μs</td>
      </tr>
  </tbody>
</table>
<p>實務上單次 request 的網路抖動遠大於這幾 μs、但攻擊者可重複幾百萬次取平均、把雜訊濾掉、最終推出整個 hash。這就是 <strong>timing attack</strong>。</p>
<h3 id="constant-time-函式的實作策略">Constant-time 函式的實作策略</h3>
<p>Constant-time 比對的核心是「<strong>不論輸入長什麼樣、都跑完整個比對長度</strong>」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 偽程式碼：constant-time 比對
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">result</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">for</span> <span class="p">(</span><span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">len</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">result</span> <span class="o">|=</span> <span class="n">a</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">^</span> <span class="n">b</span><span class="p">[</span><span class="n">i</span><span class="p">];</span>  <span class="c1">// 用 XOR 累積差異、不 return
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">return</span> <span class="n">result</span> <span class="o">==</span> <span class="mi">0</span><span class="p">;</span></span></span></code></pre></div><p>每次呼叫都跑完整個 loop、結果用 bitwise OR 累積、最後一次性比對。執行時間不依輸入內容變化。</p>
<h3 id="各語言的-constant-time-比對函式">各語言的 constant-time 比對函式</h3>
<table>
  <thead>
      <tr>
          <th>語言</th>
          <th>函式</th>
          <th>注意事項</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>PHP</strong></td>
          <td><code>hash_equals($known, $user_input)</code></td>
          <td>第一個參數要是 known、第二個是 user input</td>
      </tr>
      <tr>
          <td><strong>Python</strong></td>
          <td><code>hmac.compare_digest(a, b)</code></td>
          <td>也可用 <code>secrets.compare_digest</code></td>
      </tr>
      <tr>
          <td><strong>Go</strong></td>
          <td><code>subtle.ConstantTimeCompare(a, b)</code></td>
          <td>回傳 int (0 / 1)、不是 bool</td>
      </tr>
      <tr>
          <td><strong>Ruby</strong></td>
          <td><code>ActiveSupport::SecurityUtils.secure_compare(a, b)</code></td>
          <td>Rails；純 Ruby 用 <code>OpenSSL.fixed_length_secure_compare</code></td>
      </tr>
      <tr>
          <td><strong>Java</strong></td>
          <td><code>MessageDigest.isEqual(a, b)</code></td>
          <td>Java 6+ 保證 constant-time</td>
      </tr>
      <tr>
          <td><strong>Node.js</strong></td>
          <td><code>crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))</code></td>
          <td>兩個 Buffer 長度必須相同、否則 throw</td>
      </tr>
  </tbody>
</table>
<p><strong>失效模式</strong>：用 <code>==</code>、<code>===</code>、<code>strcmp</code>、<code>String.equals</code> 比對 hash，會讓執行時間受到第一個不同 byte 的位置影響。判讀訊號是驗證邏輯直接使用語言的一般字串相等運算；下一步路由是改用標準庫或框架提供的 constant-time 函式。</p>
<h3 id="為什麼不放在-db-層">為什麼不放在 DB 層</h3>
<p>DB 引擎大多不保證 constant-time 比對。MySQL、PostgreSQL 的字串比對為了效能，底層仍可能走短路邏輯；因此「<code>WHERE hash = ?</code>」即使加 index，也不適合被當成 timing-safe 的安全邊界。</p>
<p>Sanctum 的設計把 secret 比對完全搬到應用層用 <code>hash_equals</code> — DB 只負責「用 PK 找到單一 row」、應用層負責「比對 hash」。職責清楚、安全可預測。</p>
<hr>
<h2 id="sanctum-vs-github-pat-vs-stripe-api-key">Sanctum vs GitHub PAT vs Stripe API Key</h2>
<p>三者都是 opaque token（隨機字串、server lookup）、但 format 設計取捨完全不同：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Sanctum <code>{PK}|{secret}</code></th>
          <th>GitHub <code>ghp_xxx</code></th>
          <th>Stripe <code>sk_live_xxx</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>找到 row 的方式</strong></td>
          <td>用 PK lookup</td>
          <td>用 hash lookup</td>
          <td>用 hash lookup</td>
      </tr>
      <tr>
          <td><strong>格式可辨識性</strong></td>
          <td>低（看起來像一般字串）</td>
          <td>高（<code>ghp_</code> 前綴）</td>
          <td>高（<code>sk_live_</code> / <code>sk_test_</code> 前綴）</td>
      </tr>
      <tr>
          <td><strong>洩漏掃描</strong></td>
          <td>困難</td>
          <td>容易（GitHub 自己 scan 公開 repo）</td>
          <td>容易（Stripe webhook scan）</td>
      </tr>
      <tr>
          <td><strong>Token type 辨識</strong></td>
          <td>需查 DB</td>
          <td>從前綴直接知道（user / app / OAuth）</td>
          <td>從前綴直接知道（live / test、public / secret）</td>
      </tr>
      <tr>
          <td><strong>適合場景</strong></td>
          <td>單一 Laravel app 內部使用</td>
          <td>對外開放、需要洩漏偵測</td>
          <td>對外開放、多環境（live / test）</td>
      </tr>
  </tbody>
</table>
<h3 id="各自的設計動機">各自的設計動機</h3>
<p><strong>Sanctum</strong>：使用情境是「單一 Laravel application 自己發、自己驗」。Token 不會散落在公開 repo（除非開發者犯錯）、洩漏偵測不是首要需求。把 PK 直接放進 token、換 timing 安全與設計簡潔。</p>
<p><strong>GitHub PAT</strong>：使用情境是「使用者把 token 寫進 CI config、push 到 public repo」。GitHub 把 <code>ghp_</code> 前綴標準化、自家服務（Push Protection、Secret Scanning）會主動 scan 公開 repo、發現 <code>ghp_...</code> pattern 就通知 user 並 revoke。Token 的可辨識性是<strong>洩漏偵測 infrastructure 的一環</strong>、不是浪費字元。</p>
<p><strong>Stripe API Key</strong>：使用情境跨 live 跟 test 環境、且有 public / secret 兩種 key。前綴設計：</p>
<ul>
<li><code>sk_live_</code> — secret key、live 環境（會收真錢）</li>
<li><code>sk_test_</code> — secret key、test 環境</li>
<li><code>pk_live_</code> — publishable key、live 環境（可放 client）</li>
<li><code>pk_test_</code> — publishable key、test 環境</li>
</ul>
<p>工程師看一眼就知道「這把 key 能幹嘛」、避免把 live key 寫進 test config。</p>
<h3 id="怎麼選">怎麼選</h3>
<table>
  <thead>
      <tr>
          <th>你的場景</th>
          <th>建議設計</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單一 Laravel app、token 只內部用</td>
          <td>Sanctum 預設即可</td>
      </tr>
      <tr>
          <td>對外開放 API、token 會散落第三方環境</td>
          <td>學 GitHub / Stripe 加 prefix</td>
      </tr>
      <tr>
          <td>多環境（dev / staging / prod）容易誤用</td>
          <td>加環境 prefix（如 <code>_live_</code>）</td>
      </tr>
      <tr>
          <td>多 token type（user / bot / OAuth）</td>
          <td>加 type prefix</td>
      </tr>
  </tbody>
</table>
<p>表格的判準是 token 會不會離開受控環境。單一 Laravel app 內部使用時，Sanctum 的 PK 前綴足以支撐 lookup 與撤銷；對外 API、第三方整合或多環境部署時，prefix 可提供洩漏掃描與人工辨識訊號。也可以混用成 <code>{prefix}|{PK}|{secret}</code>，同時保留 lookup 收斂與語意辨識。</p>
<hr>
<h2 id="在非-laravel-環境怎麼套用">在非 Laravel 環境怎麼套用</h2>
<p>Sanctum 的三個原則跨語言通用：</p>
<ol>
<li><strong>DB 只存 hash</strong> — 用任何語言的 SHA-256 / SHA-512 即可。Python: <code>hashlib.sha256</code>、Go: <code>crypto/sha256</code>、Node: <code>crypto.createHash('sha256')</code></li>
<li><strong>Lookup 用穩定字段</strong> — 把「找到 row」跟「比對機密」分開、<code>WHERE id = ?</code> 是穩定的、<code>WHERE hash = ?</code> 在 timing 上不可控</li>
<li><strong>應用層 constant-time 比對</strong> — 用本文上面表格列的函式、絕不用 <code>==</code></li>
</ol>
<p>非 Laravel 框架的等效實作：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Python + SQLAlchemy 範例</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">import</span> <span class="nn">secrets</span><span class="o">,</span> <span class="nn">hashlib</span><span class="o">,</span> <span class="nn">hmac</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">def</span> <span class="nf">issue_token</span><span class="p">(</span><span class="n">user_id</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">plaintext</span> <span class="o">=</span> <span class="n">secrets</span><span class="o">.</span><span class="n">token_urlsafe</span><span class="p">(</span><span class="mi">32</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">hash_value</span> <span class="o">=</span> <span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">plaintext</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="n">token</span> <span class="o">=</span> <span class="n">PersonalAccessToken</span><span class="p">(</span><span class="n">user_id</span><span class="o">=</span><span class="n">user_id</span><span class="p">,</span> <span class="nb">hash</span><span class="o">=</span><span class="n">hash_value</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">db</span><span class="o">.</span><span class="n">session</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">token</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">db</span><span class="o">.</span><span class="n">session</span><span class="o">.</span><span class="n">commit</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">token</span><span class="o">.</span><span class="n">id</span><span class="si">}</span><span class="s2">|</span><span class="si">{</span><span class="n">plaintext</span><span class="si">}</span><span class="s2">&#34;</span>  <span class="c1"># 只此一次回給 client</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">def</span> <span class="nf">verify_token</span><span class="p">(</span><span class="n">raw_token</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="c1"># production 範例需多一層 try-except 涵蓋 int() 轉型與 DB 例外</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="n">token_id</span><span class="p">,</span> <span class="n">plaintext</span> <span class="o">=</span> <span class="n">raw_token</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s1">&#39;|&#39;</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="n">token</span> <span class="o">=</span> <span class="n">PersonalAccessToken</span><span class="o">.</span><span class="n">query</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="nb">int</span><span class="p">(</span><span class="n">token_id</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">except</span> <span class="p">(</span><span class="ne">ValueError</span><span class="p">,</span> <span class="ne">TypeError</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">token</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="n">expected_hash</span> <span class="o">=</span> <span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">plaintext</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">token</span><span class="o">.</span><span class="n">hash</span><span class="p">,</span> <span class="n">expected_hash</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">return</span> <span class="n">token</span><span class="o">.</span><span class="n">user</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// Go + sqlx 範例</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">IssueToken</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">userID</span> <span class="kt">int64</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">plaintext</span> <span class="o">:=</span> <span class="nf">generateRandomString</span><span class="p">(</span><span class="mi">40</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">hash</span> <span class="o">:=</span> <span class="nx">sha256</span><span class="p">.</span><span class="nf">Sum256</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="nx">plaintext</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="kd">var</span> <span class="nx">tokenID</span> <span class="kt">int64</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">QueryRowContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="s">&#34;INSERT INTO personal_access_tokens (user_id, hash) VALUES ($1, $2) RETURNING id&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">userID</span><span class="p">,</span> <span class="nx">hex</span><span class="p">.</span><span class="nf">EncodeToString</span><span class="p">(</span><span class="nx">hash</span><span class="p">[:]),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">).</span><span class="nf">Scan</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">tokenID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;&#34;</span><span class="p">,</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span><span class="s">&#34;%d|%s&#34;</span><span class="p">,</span> <span class="nx">tokenID</span><span class="p">,</span> <span class="nx">plaintext</span><span class="p">),</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="kd">func</span> <span class="nf">VerifyToken</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">raw</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="nx">Token</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">parts</span> <span class="o">:=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">SplitN</span><span class="p">(</span><span class="nx">raw</span><span class="p">,</span> <span class="s">&#34;|&#34;</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">parts</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">2</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">ErrInvalidFormat</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">tokenID</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">strconv</span><span class="p">.</span><span class="nf">ParseInt</span><span class="p">(</span><span class="nx">parts</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">64</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">ErrInvalidFormat</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="kd">var</span> <span class="nx">token</span> <span class="nx">Token</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="nx">err</span> <span class="p">=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">GetContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">token</span><span class="p">,</span> <span class="s">&#34;SELECT * FROM personal_access_tokens WHERE id = $1&#34;</span><span class="p">,</span> <span class="nx">tokenID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="nx">expectedHash</span> <span class="o">:=</span> <span class="nx">sha256</span><span class="p">.</span><span class="nf">Sum256</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="nx">parts</span><span class="p">[</span><span class="mi">1</span><span class="p">]))</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="nx">storedHash</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">hex</span><span class="p">.</span><span class="nf">DecodeString</span><span class="p">(</span><span class="nx">token</span><span class="p">.</span><span class="nx">Hash</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="k">if</span> <span class="nx">subtle</span><span class="p">.</span><span class="nf">ConstantTimeCompare</span><span class="p">(</span><span class="nx">storedHash</span><span class="p">,</span> <span class="nx">expectedHash</span><span class="p">[:])</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">ErrInvalidToken</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">token</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>兩者的關鍵都是：<code>SELECT WHERE id = ?</code> + 應用層 <code>compare_digest</code> / <code>ConstantTimeCompare</code>、不依賴 DB 比對 hash。</p>
<hr>
<h2 id="收尾">收尾</h2>
<p>Sanctum 的 <code>{PK}|{secret}</code> 是一個<strong>特定情境下的設計取捨</strong>，不是業界通用標準：</p>
<ul>
<li>它假設 token 不會散落到公開環境、所以不需要 prefix-based 洩漏偵測</li>
<li>它把比對機密的責任明確劃在應用層、不依賴 DB 引擎的 timing 行為</li>
<li>它用 SHA-256 + 不加 salt、因為 token 高熵時這個選擇符合威脅模型</li>
</ul>
<p>如果你的場景符合這些假設，Sanctum 的設計可以直接使用。若場景是對外 API、需要洩漏偵測、多環境或多 token type，prefix-based format 會提供更好的操作訊號；儲存原則（hash + constant-time）則跨設計通用。</p>
<p>延伸閱讀：</p>
<ul>
<li><a href="/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/" data-link-title="API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning" data-link-desc="API 認證的信任邊界分層（Bearer Token / Shared Secret / Provisioning）：各層的洩漏後果與撤銷方式，以及混用造成的設計失效模式。">API 認證的三層信任邊界</a> — 本文的主篇、Sanctum 在「Layer 1 使用者層」的位置</li>
<li><a href="/blog/work-log/shared-secret-%E5%AE%89%E5%85%A8%E8%BC%AA%E6%9B%BF%E8%A8%AD%E8%A8%88%E9%9B%99%E5%AF%86%E9%81%8E%E6%B8%A1%E6%9C%9F%E8%87%AA%E5%8B%95%E5%8C%96%E8%88%87%E7%B7%8A%E6%80%A5%E6%B5%81%E7%A8%8B/" data-link-title="Shared Secret 安全輪替設計：雙密過渡期、自動化與緊急流程" data-link-desc="系統間 Shared Secret 輪替的核心機制：dual-secret rollover、自動化工具比較（AWS Secrets Manager / Vault / GCP）、緊急 rotation 流程與多 client 環境的失敗模式。">Shared Secret 安全輪替設計</a> — Layer 2 系統間 secret 的輪替議題</li>
<li><a href="/blog/work-log/mtls-%E5%AF%A6%E9%9A%9B%E6%80%8E%E9%BA%BC%E8%A8%AD%E5%AE%9A%E8%88%87%E9%81%8B%E7%B6%ADca-%E9%9A%8E%E5%B1%A4%E6%86%91%E8%AD%89%E7%94%9F%E5%91%BD%E9%80%B1%E6%9C%9F%E6%92%A4%E9%8A%B7%E6%A9%9F%E5%88%B6/" data-link-title="mTLS 實際怎麼設定與運維：CA 階層、憑證生命週期、撤銷機制" data-link-desc="mTLS 落地的運維決策（CA 階層、憑證儲存、撤銷機制）與基礎設施整合（nginx / envoy / service mesh），以及跟 API Key / OAuth 的成本與安全取捨。">mTLS 實際怎麼設定與運維</a> — Layer 2 進階方案的部署細節</li>
</ul>
]]></content:encoded></item></channel></rss>