<?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>Authentication on Tarragon</title><link>https://tarrragon.github.io/blog/tags/authentication/</link><description>Recent content in Authentication on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 24 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/authentication/index.xml" rel="self" type="application/rss+xml"/><item><title>Gate 分類與三問設計法</title><link>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/gate-three-questions/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/gate-three-questions/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">Gate&lt;/a> 是使用者操作流程中的「必須通過才能繼續」的關卡。生物辨識認證、網路連線檢查、權限請求、版本檢查 — 這些都是 gate。Gate 設計的核心責任是確保使用者在每種結果下都有路可走，而非只設計「通過」的情境。&lt;/p>
&lt;h2 id="三問設計法">三問設計法&lt;/h2>
&lt;p>每個 gate 設計時回答三個問題：&lt;/p>
&lt;h3 id="成功時做什麼">成功時做什麼&lt;/h3>
&lt;p>Gate 通過後使用者進入下一步。這是最直覺的設計 — 認證成功進入主畫面、網路連線成功開始載入資料、權限授予後啟用功能。&lt;/p>
&lt;p>成功路徑通常是設計時最先考慮的，也是最不容易遺漏的。&lt;/p>
&lt;h3 id="失敗時做什麼">失敗時做什麼&lt;/h3>
&lt;p>Gate 未通過時使用者的&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">替代路徑&lt;/a>。替代路徑可以是：降級功能（部分功能可用）、替代驗證方式（密碼代替 Face ID）、手動重試（重試按鈕）、放棄操作（返回上一頁）。&lt;/p>
&lt;p>失敗路徑是最容易遺漏的。app_tunnel 的 biometric gate 設定 &lt;code>biometricOnly: true&lt;/code>，Face ID 不可用時使用者直接被擋住，沒有密碼 fallback、沒有跳過選項、沒有返回路徑（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2&lt;/a>）。修復只改一個 boolean — &lt;code>biometricOnly: false&lt;/code> — 讓系統自動提示輸入裝置密碼。但這個決策應該在企劃階段做，而非實機測試時才發現。&lt;/p>
&lt;h3 id="使用者不知道發生什麼時做什麼">使用者不知道發生什麼時做什麼&lt;/h3>
&lt;p>Gate 處理中（loading）或結果不確定（timeout）時使用者看到什麼、能做什麼。&lt;/p>
&lt;p>使用者不知道發生什麼的情境包括：認證彈窗尚未出現（系統延遲）、網路請求已發但未回應（loading）、權限對話框被系統遮擋（多個 dialog 堆疊）。&lt;/p>
&lt;p>在這個狀態下使用者需要的是：知道系統在做什麼（loading 指示）、可以取消等待（取消按鈕）、超過合理時間後有提示（timeout 訊息 + 重試選項）。&lt;/p>
&lt;h2 id="gate-的四種常見類型">Gate 的四種常見類型&lt;/h2>
&lt;h3 id="認證-gate">認證 Gate&lt;/h3>
&lt;p>使用者必須驗證身份才能使用功能。生物辨識、密碼、PIN 碼、OAuth 登入。&lt;/p>
&lt;p>認證 gate 的 fallback 設計取決於安全需求和使用場景。銀行 app 可能要求生物辨識 + PIN 碼雙重驗證，沒有更低層級的 fallback。自用工具可以接受密碼 fallback，因為使用者本身就是 owner — 可用性優先於認證強度（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2&lt;/a>）。&lt;/p>
&lt;h3 id="網路-gate">網路 Gate&lt;/h3>
&lt;p>功能需要網路連線才能運作。連線存在但不穩定的場景比完全離線更難處理 — 請求可能成功、可能逾時、可能部分成功。&lt;/p>
&lt;h3 id="權限-gate">權限 Gate&lt;/h3>
&lt;p>App 需要系統權限（相機、位置、通知）才能使用特定功能。&lt;/p>
&lt;p>權限 gate 的特殊性在於使用者可以永久拒絕。拒絕後再次請求不會彈出系統對話框 — 必須引導使用者到系統設定手動開啟。&lt;/p>
&lt;h3 id="環境-gate">環境 Gate&lt;/h3>
&lt;p>特定的硬體或軟體條件必須滿足。最低 OS 版本、特定感測器（NFC、深度相機）、特定連接（藍牙已開啟）。&lt;/p>
&lt;p>環境 gate 的 fallback 通常有限 — 硬體不存在時無法用軟體模擬。但至少應該告知使用者為什麼功能不可用，而非靜默禁用。&lt;/p>
&lt;h3 id="其他常見-gate">其他常見 Gate&lt;/h3>
&lt;p>商業 app 還有兩種 gate 在本系列涵蓋範圍之外但實務常見：&lt;/p>
&lt;p>&lt;strong>付費 Gate&lt;/strong>（paywall）：功能需要付費才能使用。付費 gate 的 fallback 設計和上述四種不同 — 「失敗」路徑的目標是引導使用者付費而非提供替代功能。試用期、降級功能、付費引導 vs 付費強制的取捨依賴商業模式決策。&lt;/p>
&lt;p>&lt;strong>版本相容性 Gate&lt;/strong>：API 版本過舊需要升級 app。Fallback 是提示使用者更新，但強制更新會阻擋無法更新的使用者（舊 OS 版本不支援新版 app）。&lt;/p>
&lt;h2 id="gate-設計表">Gate 設計表&lt;/h2>
&lt;p>把三問設計法應用到每個 gate，產出一張設計表：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Gate&lt;/th>
 &lt;th>成功&lt;/th>
 &lt;th>失敗&lt;/th>
 &lt;th>不確定&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>生物辨識&lt;/td>
 &lt;td>進入主畫面&lt;/td>
 &lt;td>提示輸入裝置密碼&lt;/td>
 &lt;td>顯示「驗證中」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>網路連線&lt;/td>
 &lt;td>開始載入資料&lt;/td>
 &lt;td>顯示離線提示 + 重試&lt;/td>
 &lt;td>顯示 loading + 取消&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>相機權限&lt;/td>
 &lt;td>開啟掃描功能&lt;/td>
 &lt;td>說明原因 + 設定連結&lt;/td>
 &lt;td>等待系統對話框&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>藍牙&lt;/td>
 &lt;td>開始裝置搜尋&lt;/td>
 &lt;td>提示開啟藍牙 + 連結&lt;/td>
 &lt;td>顯示搜尋中 + 取消&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>失敗欄和不確定欄為空的 gate 就是 UX 死胡同的候選 — 和&lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣&lt;/a>的退出路徑檢查同樣的邏輯。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">Gate</a> 是使用者操作流程中的「必須通過才能繼續」的關卡。生物辨識認證、網路連線檢查、權限請求、版本檢查 — 這些都是 gate。Gate 設計的核心責任是確保使用者在每種結果下都有路可走，而非只設計「通過」的情境。</p>
<h2 id="三問設計法">三問設計法</h2>
<p>每個 gate 設計時回答三個問題：</p>
<h3 id="成功時做什麼">成功時做什麼</h3>
<p>Gate 通過後使用者進入下一步。這是最直覺的設計 — 認證成功進入主畫面、網路連線成功開始載入資料、權限授予後啟用功能。</p>
<p>成功路徑通常是設計時最先考慮的，也是最不容易遺漏的。</p>
<h3 id="失敗時做什麼">失敗時做什麼</h3>
<p>Gate 未通過時使用者的<a href="/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">替代路徑</a>。替代路徑可以是：降級功能（部分功能可用）、替代驗證方式（密碼代替 Face ID）、手動重試（重試按鈕）、放棄操作（返回上一頁）。</p>
<p>失敗路徑是最容易遺漏的。app_tunnel 的 biometric gate 設定 <code>biometricOnly: true</code>，Face ID 不可用時使用者直接被擋住，沒有密碼 fallback、沒有跳過選項、沒有返回路徑（<a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2</a>）。修復只改一個 boolean — <code>biometricOnly: false</code> — 讓系統自動提示輸入裝置密碼。但這個決策應該在企劃階段做，而非實機測試時才發現。</p>
<h3 id="使用者不知道發生什麼時做什麼">使用者不知道發生什麼時做什麼</h3>
<p>Gate 處理中（loading）或結果不確定（timeout）時使用者看到什麼、能做什麼。</p>
<p>使用者不知道發生什麼的情境包括：認證彈窗尚未出現（系統延遲）、網路請求已發但未回應（loading）、權限對話框被系統遮擋（多個 dialog 堆疊）。</p>
<p>在這個狀態下使用者需要的是：知道系統在做什麼（loading 指示）、可以取消等待（取消按鈕）、超過合理時間後有提示（timeout 訊息 + 重試選項）。</p>
<h2 id="gate-的四種常見類型">Gate 的四種常見類型</h2>
<h3 id="認證-gate">認證 Gate</h3>
<p>使用者必須驗證身份才能使用功能。生物辨識、密碼、PIN 碼、OAuth 登入。</p>
<p>認證 gate 的 fallback 設計取決於安全需求和使用場景。銀行 app 可能要求生物辨識 + PIN 碼雙重驗證，沒有更低層級的 fallback。自用工具可以接受密碼 fallback，因為使用者本身就是 owner — 可用性優先於認證強度（<a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2</a>）。</p>
<h3 id="網路-gate">網路 Gate</h3>
<p>功能需要網路連線才能運作。連線存在但不穩定的場景比完全離線更難處理 — 請求可能成功、可能逾時、可能部分成功。</p>
<h3 id="權限-gate">權限 Gate</h3>
<p>App 需要系統權限（相機、位置、通知）才能使用特定功能。</p>
<p>權限 gate 的特殊性在於使用者可以永久拒絕。拒絕後再次請求不會彈出系統對話框 — 必須引導使用者到系統設定手動開啟。</p>
<h3 id="環境-gate">環境 Gate</h3>
<p>特定的硬體或軟體條件必須滿足。最低 OS 版本、特定感測器（NFC、深度相機）、特定連接（藍牙已開啟）。</p>
<p>環境 gate 的 fallback 通常有限 — 硬體不存在時無法用軟體模擬。但至少應該告知使用者為什麼功能不可用，而非靜默禁用。</p>
<h3 id="其他常見-gate">其他常見 Gate</h3>
<p>商業 app 還有兩種 gate 在本系列涵蓋範圍之外但實務常見：</p>
<p><strong>付費 Gate</strong>（paywall）：功能需要付費才能使用。付費 gate 的 fallback 設計和上述四種不同 — 「失敗」路徑的目標是引導使用者付費而非提供替代功能。試用期、降級功能、付費引導 vs 付費強制的取捨依賴商業模式決策。</p>
<p><strong>版本相容性 Gate</strong>：API 版本過舊需要升級 app。Fallback 是提示使用者更新，但強制更新會阻擋無法更新的使用者（舊 OS 版本不支援新版 app）。</p>
<h2 id="gate-設計表">Gate 設計表</h2>
<p>把三問設計法應用到每個 gate，產出一張設計表：</p>
<table>
  <thead>
      <tr>
          <th>Gate</th>
          <th>成功</th>
          <th>失敗</th>
          <th>不確定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>生物辨識</td>
          <td>進入主畫面</td>
          <td>提示輸入裝置密碼</td>
          <td>顯示「驗證中」</td>
      </tr>
      <tr>
          <td>網路連線</td>
          <td>開始載入資料</td>
          <td>顯示離線提示 + 重試</td>
          <td>顯示 loading + 取消</td>
      </tr>
      <tr>
          <td>相機權限</td>
          <td>開啟掃描功能</td>
          <td>說明原因 + 設定連結</td>
          <td>等待系統對話框</td>
      </tr>
      <tr>
          <td>藍牙</td>
          <td>開始裝置搜尋</td>
          <td>提示開啟藍牙 + 連結</td>
          <td>顯示搜尋中 + 取消</td>
      </tr>
  </tbody>
</table>
<p>失敗欄和不確定欄為空的 gate 就是 UX 死胡同的候選 — 和<a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣</a>的退出路徑檢查同樣的邏輯。</p>
<p>三問設計法的具體應用在 <a href="/blog/ux-design/02-gate-fallback/biometric-fallback-design/" data-link-title="Biometric fallback 完整設計" data-link-desc="iOS Face ID / Touch ID 和 Android BiometricPrompt 的行為差異、fallback 策略、安全 vs 可用性取捨的顯式記錄方法">Biometric fallback 完整設計</a>中以生物辨識 gate 為例展開。Gate 在開發環境的行為可能和真機不同，<a href="/blog/ux-design/02-gate-fallback/dev-vs-real-gate-behavior/" data-link-title="開發環境 vs 真機的 gate 行為差異表" data-link-desc="模擬器、debug build、test 環境中的 gate 行為和真機 release build 不同 — 差異表讓開發者在上機前知道哪些 gate 還沒被真實驗證">開發環境 vs 真機的 gate 行為差異表</a>列出每個 gate 在模擬器和真機上的差異。Gate 設計表的「失敗」欄和<a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">畫面狀態矩陣</a>的「退出路徑」欄是同一個問題在不同層級的表達。</p>
]]></content:encoded></item><item><title>Biometric fallback 完整設計</title><link>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/biometric-fallback-design/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/biometric-fallback-design/</guid><description>&lt;p>Biometric &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">gate&lt;/a> 的 &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">fallback&lt;/a> 設計需要理解兩件事：平台的認證 API 在不同情境下的行為差異，以及安全收益和可用性代價之間的顯式取捨。&lt;/p>
&lt;h2 id="生物辨識失敗的情境">生物辨識失敗的情境&lt;/h2>
&lt;p>生物辨識失敗有多種原因，每種原因對使用者的影響和合理的 fallback 不同。&lt;/p>
&lt;h3 id="暫時性失敗">暫時性失敗&lt;/h3>
&lt;p>Face ID 因光線不足辨識失敗、指紋因手指潮濕讀取失敗。使用者的生物特徵正常，只是當次辨識條件不佳。重試可能成功。&lt;/p>
&lt;h3 id="持續性失敗">持續性失敗&lt;/h3>
&lt;p>使用者戴口罩讓 Face ID 無法辨識（較舊的 iOS 版本）、手指受傷影響指紋辨識。生物特徵暫時改變，短期內重試都不會成功。需要替代認證方式。&lt;/p>
&lt;h3 id="硬體不可用">硬體不可用&lt;/h3>
&lt;p>裝置沒有 Face ID / Touch ID 模組（較舊機型）、模擬器不支援生物辨識、生物辨識功能被裝置管理策略（MDM）禁用。需要替代認證方式。&lt;/p>
&lt;h3 id="使用者未設定">使用者未設定&lt;/h3>
&lt;p>裝置有硬體但使用者沒有設定 Face ID 或指紋。系統的 &lt;code>canCheckBiometrics&lt;/code> 回傳 &lt;code>true&lt;/code>（硬體存在）但實際認證會失敗。需要引導使用者設定或提供替代認證。&lt;/p>
&lt;h2 id="ios-和-android-的行為差異">iOS 和 Android 的行為差異&lt;/h2>
&lt;h3 id="ioslocalauthentication">iOS（LocalAuthentication）&lt;/h3>
&lt;p>iOS 的 &lt;code>LAContext.evaluatePolicy&lt;/code> 有兩個 policy：&lt;/p>
&lt;ul>
&lt;li>&lt;code>deviceOwnerAuthenticationWithBiometrics&lt;/code>：只接受生物辨識，失敗後不自動提示密碼&lt;/li>
&lt;li>&lt;code>deviceOwnerAuthentication&lt;/code>：先嘗試生物辨識，失敗後系統自動彈出裝置密碼輸入&lt;/li>
&lt;/ul>
&lt;p>Flutter 的 &lt;code>local_auth&lt;/code> 套件的 &lt;code>biometricOnly&lt;/code> 參數對應這兩個 policy。&lt;code>biometricOnly: true&lt;/code> 用前者，&lt;code>biometricOnly: false&lt;/code> 用後者。&lt;/p>
&lt;p>iOS 的行為特點：系統控制認證 UI（不是 app 自行繪製），認證失敗次數過多會自動鎖定（需要輸入密碼解鎖），Face ID 多次失敗後系統會自動提供密碼選項（即使 app 要求 biometricOnly）。&lt;/p>
&lt;h3 id="androidbiometricprompt">Android（BiometricPrompt）&lt;/h3>
&lt;p>Android 的 BiometricPrompt 分成三個 class：&lt;/p>
&lt;ul>
&lt;li>&lt;code>BIOMETRIC_STRONG&lt;/code>：只接受 Class 3 生物辨識（經過硬體安全模組驗證的指紋/面部）&lt;/li>
&lt;li>&lt;code>BIOMETRIC_WEAK&lt;/code>：接受 Class 2 和 Class 3 生物辨識&lt;/li>
&lt;li>&lt;code>DEVICE_CREDENTIAL&lt;/code>：接受裝置 PIN/圖形/密碼&lt;/li>
&lt;/ul>
&lt;p>三個 class 可以用 &lt;code>|&lt;/code> 組合。&lt;code>BIOMETRIC_STRONG | DEVICE_CREDENTIAL&lt;/code> 表示先嘗試強生物辨識，失敗後 fallback 到裝置密碼。&lt;/p>
&lt;p>Android 的行為特點：不同廠商的生物辨識品質差異大（Samsung 的面部辨識和 Pixel 的面部辨識安全等級不同）、部分裝置的指紋感測器在螢幕下方（使用者可能不知道在哪裡觸碰）。&lt;/p>
&lt;h2 id="安全-vs-可用性的顯式取捨">安全 vs 可用性的顯式取捨&lt;/h2>
&lt;p>&lt;code>biometricOnly&lt;/code> 的決策涉及安全和可用性的取捨。這個取捨應該在功能規格中顯式記錄，讓後續的 code review 和維護者能理解決策的背景。&lt;/p>
&lt;p>記錄格式建議：&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">Gate: biometric authentication
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">Decision: biometricOnly = false (allow device credential fallback)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">Security trade-off: device credential (PIN/password) is weaker than biometric
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">Rationale: self-hosted tool, user = owner, availability &amp;gt; auth strength
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">Risk accepted: someone with device PIN can access the app&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>app_tunnel 選擇 &lt;code>biometricOnly: true&lt;/code> 的原始意圖是「安全性更高」，但沒有顯式記錄取捨，也沒有評估「Face ID 不可用時使用者完全無法使用 app」的代價。自用工具的使用者就是 owner，密碼 fallback 的安全風險遠低於完全無法使用的可用性風險（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2&lt;/a>）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>Gate 設計的通用方法論 → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法&lt;/a>&lt;/li>
&lt;li>開發環境遮蔽 gate 問題 → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/dev-vs-real-gate-behavior/" data-link-title="開發環境 vs 真機的 gate 行為差異表" data-link-desc="模擬器、debug build、test 環境中的 gate 行為和真機 release build 不同 — 差異表讓開發者在上機前知道哪些 gate 還沒被真實驗證">開發環境 vs 真機的 gate 行為差異表&lt;/a>&lt;/li>
&lt;li>安全 vs 可用性在 monitoring 中的對應 → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Biometric <a href="/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">gate</a> 的 <a href="/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">fallback</a> 設計需要理解兩件事：平台的認證 API 在不同情境下的行為差異，以及安全收益和可用性代價之間的顯式取捨。</p>
<h2 id="生物辨識失敗的情境">生物辨識失敗的情境</h2>
<p>生物辨識失敗有多種原因，每種原因對使用者的影響和合理的 fallback 不同。</p>
<h3 id="暫時性失敗">暫時性失敗</h3>
<p>Face ID 因光線不足辨識失敗、指紋因手指潮濕讀取失敗。使用者的生物特徵正常，只是當次辨識條件不佳。重試可能成功。</p>
<h3 id="持續性失敗">持續性失敗</h3>
<p>使用者戴口罩讓 Face ID 無法辨識（較舊的 iOS 版本）、手指受傷影響指紋辨識。生物特徵暫時改變，短期內重試都不會成功。需要替代認證方式。</p>
<h3 id="硬體不可用">硬體不可用</h3>
<p>裝置沒有 Face ID / Touch ID 模組（較舊機型）、模擬器不支援生物辨識、生物辨識功能被裝置管理策略（MDM）禁用。需要替代認證方式。</p>
<h3 id="使用者未設定">使用者未設定</h3>
<p>裝置有硬體但使用者沒有設定 Face ID 或指紋。系統的 <code>canCheckBiometrics</code> 回傳 <code>true</code>（硬體存在）但實際認證會失敗。需要引導使用者設定或提供替代認證。</p>
<h2 id="ios-和-android-的行為差異">iOS 和 Android 的行為差異</h2>
<h3 id="ioslocalauthentication">iOS（LocalAuthentication）</h3>
<p>iOS 的 <code>LAContext.evaluatePolicy</code> 有兩個 policy：</p>
<ul>
<li><code>deviceOwnerAuthenticationWithBiometrics</code>：只接受生物辨識，失敗後不自動提示密碼</li>
<li><code>deviceOwnerAuthentication</code>：先嘗試生物辨識，失敗後系統自動彈出裝置密碼輸入</li>
</ul>
<p>Flutter 的 <code>local_auth</code> 套件的 <code>biometricOnly</code> 參數對應這兩個 policy。<code>biometricOnly: true</code> 用前者，<code>biometricOnly: false</code> 用後者。</p>
<p>iOS 的行為特點：系統控制認證 UI（不是 app 自行繪製），認證失敗次數過多會自動鎖定（需要輸入密碼解鎖），Face ID 多次失敗後系統會自動提供密碼選項（即使 app 要求 biometricOnly）。</p>
<h3 id="androidbiometricprompt">Android（BiometricPrompt）</h3>
<p>Android 的 BiometricPrompt 分成三個 class：</p>
<ul>
<li><code>BIOMETRIC_STRONG</code>：只接受 Class 3 生物辨識（經過硬體安全模組驗證的指紋/面部）</li>
<li><code>BIOMETRIC_WEAK</code>：接受 Class 2 和 Class 3 生物辨識</li>
<li><code>DEVICE_CREDENTIAL</code>：接受裝置 PIN/圖形/密碼</li>
</ul>
<p>三個 class 可以用 <code>|</code> 組合。<code>BIOMETRIC_STRONG | DEVICE_CREDENTIAL</code> 表示先嘗試強生物辨識，失敗後 fallback 到裝置密碼。</p>
<p>Android 的行為特點：不同廠商的生物辨識品質差異大（Samsung 的面部辨識和 Pixel 的面部辨識安全等級不同）、部分裝置的指紋感測器在螢幕下方（使用者可能不知道在哪裡觸碰）。</p>
<h2 id="安全-vs-可用性的顯式取捨">安全 vs 可用性的顯式取捨</h2>
<p><code>biometricOnly</code> 的決策涉及安全和可用性的取捨。這個取捨應該在功能規格中顯式記錄，讓後續的 code review 和維護者能理解決策的背景。</p>
<p>記錄格式建議：</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">Gate: biometric authentication
</span></span><span class="line"><span class="ln">2</span><span class="cl">Decision: biometricOnly = false (allow device credential fallback)
</span></span><span class="line"><span class="ln">3</span><span class="cl">Security trade-off: device credential (PIN/password) is weaker than biometric
</span></span><span class="line"><span class="ln">4</span><span class="cl">Rationale: self-hosted tool, user = owner, availability &gt; auth strength
</span></span><span class="line"><span class="ln">5</span><span class="cl">Risk accepted: someone with device PIN can access the app</span></span></code></pre></div><p>app_tunnel 選擇 <code>biometricOnly: true</code> 的原始意圖是「安全性更高」，但沒有顯式記錄取捨，也沒有評估「Face ID 不可用時使用者完全無法使用 app」的代價。自用工具的使用者就是 owner，密碼 fallback 的安全風險遠低於完全無法使用的可用性風險（<a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2</a>）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Gate 設計的通用方法論 → <a href="/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法</a></li>
<li>開發環境遮蔽 gate 問題 → <a href="/blog/ux-design/02-gate-fallback/dev-vs-real-gate-behavior/" data-link-title="開發環境 vs 真機的 gate 行為差異表" data-link-desc="模擬器、debug build、test 環境中的 gate 行為和真機 release build 不同 — 差異表讓開發者在上機前知道哪些 gate 還沒被真實驗證">開發環境 vs 真機的 gate 行為差異表</a></li>
<li>安全 vs 可用性在 monitoring 中的對應 → <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a></li>
</ul>
]]></content:encoded></item><item><title>Gate（UX）</title><link>https://tarrragon.github.io/blog/ux-design/knowledge-cards/gate/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/knowledge-cards/gate/</guid><description>&lt;p>Gate 的核心概念是「使用者操作流程中必須通過才能繼續的關卡」。認證、網路連線、權限請求、環境檢查、付費牆都是 gate。每個 gate 需要設計三條路徑：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼。可先對照 &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">Fallback（UX）&lt;/a> 和 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">Fallback（Backend）&lt;/a>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>UX 語境的 gate 聚焦在使用者體驗層 — 關注的是「使用者被擋住時看到什麼、能做什麼」。和 backend 語境的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/gate-decision/" data-link-title="Gate Decision" data-link-desc="說明 release gate 如何把證據轉成放行、暫停、回退或補證據的決策">gate decision&lt;/a> 不同，後者關注的是部署流程中的品質關卡。Gate 的失敗路徑和不確定路徑應該反映在&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣&lt;/a>的退出路徑欄中。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>需要 gate 設計的訊號是使用者在某個功能前被阻擋且沒有替代路徑。常見情境：biometric 認證失敗後使用者無法進入 app、網路斷線後使用者被困在 loading 畫面、權限被拒後功能靜默消失但使用者不知道為什麼。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Gate 的設計責任是確保每條路徑都有明確的使用者體驗。成功路徑通常最先被設計；失敗路徑需要提供 &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">UX fallback&lt;/a>（替代驗證、降級功能、返回上一頁）；不確定路徑需要 loading 指示和取消操作。開發環境可能遮蔽 gate 問題 — 模擬器跳過認證、debug build 自動授權 — 差異表讓開發者在上機前知道哪些 gate 還沒被真實驗證。&lt;/p></description><content:encoded><![CDATA[<p>Gate 的核心概念是「使用者操作流程中必須通過才能繼續的關卡」。認證、網路連線、權限請求、環境檢查、付費牆都是 gate。每個 gate 需要設計三條路徑：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼。可先對照 <a href="/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">Fallback（UX）</a> 和 <a href="/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">Fallback（Backend）</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>UX 語境的 gate 聚焦在使用者體驗層 — 關注的是「使用者被擋住時看到什麼、能做什麼」。和 backend 語境的 <a href="/blog/backend/knowledge-cards/gate-decision/" data-link-title="Gate Decision" data-link-desc="說明 release gate 如何把證據轉成放行、暫停、回退或補證據的決策">gate decision</a> 不同，後者關注的是部署流程中的品質關卡。Gate 的失敗路徑和不確定路徑應該反映在<a href="/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣</a>的退出路徑欄中。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>需要 gate 設計的訊號是使用者在某個功能前被阻擋且沒有替代路徑。常見情境：biometric 認證失敗後使用者無法進入 app、網路斷線後使用者被困在 loading 畫面、權限被拒後功能靜默消失但使用者不知道為什麼。</p>
<h2 id="設計責任">設計責任</h2>
<p>Gate 的設計責任是確保每條路徑都有明確的使用者體驗。成功路徑通常最先被設計；失敗路徑需要提供 <a href="/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">UX fallback</a>（替代驗證、降級功能、返回上一頁）；不確定路徑需要 loading 指示和取消操作。開發環境可能遮蔽 gate 問題 — 模擬器跳過認證、debug build 自動授權 — 差異表讓開發者在上機前知道哪些 gate 還沒被真實驗證。</p>
]]></content:encoded></item><item><title>T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽</title><link>https://tarrragon.github.io/blog/testing/cases/auth-handshake-missing-mock-blindspot/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/auth-handshake-missing-mock-blindspot/</guid><description>&lt;p>這個案例的核心責任是說明 mock 如何讓「功能缺失」變得不可見。不同於 T.C1（功能存在但行為錯誤），這個案例是功能根本沒實作 — 因為 mock 不需要這個功能就能通過所有 test。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>ttyd WebSocket 協議要求連線建立後發送一個 JSON frame 包含 base64 編碼的帳密（&lt;code>{&amp;quot;AuthToken&amp;quot;:&amp;quot;base64(user:pass)&amp;quot;}&lt;/code>），ttyd 驗證通過後才開始推送 terminal output。app_tunnel 的 &lt;code>ConnectionManager&lt;/code> 建立 WS 連線後直接開始監聽 stream，沒有發送 auth token。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>指標&lt;/th>
 &lt;th>值&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>影響範圍&lt;/td>
 &lt;td>連線建立後 ttyd 不推送資料（等 auth token），app 顯示空白終端機&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Unit test 結果&lt;/td>
 &lt;td>10 個 ConnectionManager test 全過（&lt;code>FakeWebSocketChannel.ready&lt;/code> 立即完成）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Integration test 結果&lt;/td>
 &lt;td>11 個 connection_flow_test 全過（同樣用 &lt;code>FakeWebSocketChannel&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>實機表現&lt;/td>
 &lt;td>連線成功，終端機空白無輸出&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>修復&lt;/td>
 &lt;td>新增 &lt;code>_sendAuthTokenIfNeeded()&lt;/code> 在 &lt;code>_establishWebSocket()&lt;/code> 內呼叫&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Mock 的 happy path 比真實服務寬鬆&lt;/strong>。&lt;code>FakeWebSocketChannel&lt;/code> 的 &lt;code>ready&lt;/code> 是 &lt;code>Future.value()&lt;/code>（立即完成），&lt;code>stream&lt;/code> 是開發者手動控制的 &lt;code>StreamController&lt;/code>。真實 ttyd 的行為是：&lt;code>ready&lt;/code> 完成代表 TCP+WS 握手成功，但 stream 要等 auth token 驗證後才有資料。Mock 把兩步合成一步。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Integration test 名為整合實為 fake&lt;/strong>。&lt;code>connection_flow_test.dart&lt;/code> 標題是「端對端整合測試」，但內部使用 &lt;code>FakeWebSocketChannel&lt;/code> + &lt;code>FakeBiometricService&lt;/code> + &lt;code>InMemoryCredentialRepository&lt;/code> — 三個核心依賴全是 fake。這個 test 驗證的是「假設所有外部服務都正常，內部狀態機是否正確」，不是「真實服務互動是否正確」。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>功能缺失比功能錯誤更難被 test 抓到&lt;/strong>。功能錯誤（T.C1 text vs binary）至少有一個實作可以斷言；功能缺失意味著沒有程式碼可以 test。只有 protocol integration test（對真實服務跑）才能暴露「應該有但沒有」的行為。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Protocol integration test 必須涵蓋 auth handshake&lt;/strong>：連線 → 發送正確 auth token → 斷言收到 output；連線 → 不發送 auth token → 斷言 timeout 或斷線。&lt;/li>
&lt;li>&lt;strong>在企劃階段列出協議握手步驟&lt;/strong>：ttyd WS 協議的 auth handshake 應該在 spec 文件中明確列出，不依賴開發者記得實作。&lt;/li>
&lt;li>&lt;strong>區分「名義 integration」和「真實 integration」&lt;/strong>：test 名稱含 integration 但全用 fake，應標明 &lt;code>fake-integration&lt;/code> 或改名 &lt;code>connection-state-machine-test&lt;/code>。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想區分 mock 層級 → &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">模組一：測試策略分層&lt;/a>&lt;/li>
&lt;li>想建 protocol integration test → &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試&lt;/a>&lt;/li>
&lt;li>想設計 auth 機制的 UX fallback → &lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2 biometricOnly 無 fallback&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 mock 如何讓「功能缺失」變得不可見。不同於 T.C1（功能存在但行為錯誤），這個案例是功能根本沒實作 — 因為 mock 不需要這個功能就能通過所有 test。</p>
<h2 id="觀察">觀察</h2>
<p>ttyd WebSocket 協議要求連線建立後發送一個 JSON frame 包含 base64 編碼的帳密（<code>{&quot;AuthToken&quot;:&quot;base64(user:pass)&quot;}</code>），ttyd 驗證通過後才開始推送 terminal output。app_tunnel 的 <code>ConnectionManager</code> 建立 WS 連線後直接開始監聽 stream，沒有發送 auth token。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>影響範圍</td>
          <td>連線建立後 ttyd 不推送資料（等 auth token），app 顯示空白終端機</td>
      </tr>
      <tr>
          <td>Unit test 結果</td>
          <td>10 個 ConnectionManager test 全過（<code>FakeWebSocketChannel.ready</code> 立即完成）</td>
      </tr>
      <tr>
          <td>Integration test 結果</td>
          <td>11 個 connection_flow_test 全過（同樣用 <code>FakeWebSocketChannel</code>）</td>
      </tr>
      <tr>
          <td>實機表現</td>
          <td>連線成功，終端機空白無輸出</td>
      </tr>
      <tr>
          <td>修復</td>
          <td>新增 <code>_sendAuthTokenIfNeeded()</code> 在 <code>_establishWebSocket()</code> 內呼叫</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>Mock 的 happy path 比真實服務寬鬆</strong>。<code>FakeWebSocketChannel</code> 的 <code>ready</code> 是 <code>Future.value()</code>（立即完成），<code>stream</code> 是開發者手動控制的 <code>StreamController</code>。真實 ttyd 的行為是：<code>ready</code> 完成代表 TCP+WS 握手成功，但 stream 要等 auth token 驗證後才有資料。Mock 把兩步合成一步。</p>
</li>
<li>
<p><strong>Integration test 名為整合實為 fake</strong>。<code>connection_flow_test.dart</code> 標題是「端對端整合測試」，但內部使用 <code>FakeWebSocketChannel</code> + <code>FakeBiometricService</code> + <code>InMemoryCredentialRepository</code> — 三個核心依賴全是 fake。這個 test 驗證的是「假設所有外部服務都正常，內部狀態機是否正確」，不是「真實服務互動是否正確」。</p>
</li>
<li>
<p><strong>功能缺失比功能錯誤更難被 test 抓到</strong>。功能錯誤（T.C1 text vs binary）至少有一個實作可以斷言；功能缺失意味著沒有程式碼可以 test。只有 protocol integration test（對真實服務跑）才能暴露「應該有但沒有」的行為。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>Protocol integration test 必須涵蓋 auth handshake</strong>：連線 → 發送正確 auth token → 斷言收到 output；連線 → 不發送 auth token → 斷言 timeout 或斷線。</li>
<li><strong>在企劃階段列出協議握手步驟</strong>：ttyd WS 協議的 auth handshake 應該在 spec 文件中明確列出，不依賴開發者記得實作。</li>
<li><strong>區分「名義 integration」和「真實 integration」</strong>：test 名稱含 integration 但全用 fake，應標明 <code>fake-integration</code> 或改名 <code>connection-state-machine-test</code>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想區分 mock 層級 → <a href="/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">模組一：測試策略分層</a></li>
<li>想建 protocol integration test → <a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試</a></li>
<li>想設計 auth 機制的 UX fallback → <a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2 biometricOnly 無 fallback</a></li>
</ul>
]]></content:encoded></item><item><title>U.C2 biometricOnly=true 無密碼 fallback</title><link>https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/</guid><description>&lt;p>這個案例的核心責任是說明 Gate（使用者必須通過的關卡）的設計不只是「成功時怎麼做」，還必須包含「失敗時的替代路徑」。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel 使用 &lt;code>local_auth&lt;/code> 套件進行生物辨識認證。&lt;code>AuthenticationOptions&lt;/code> 設定 &lt;code>biometricOnly: true&lt;/code>，表示只接受生物辨識（Face ID / 指紋），不接受裝置密碼作為 fallback。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 修復前
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nl">options:&lt;/span> &lt;span class="kd">const&lt;/span> &lt;span class="n">AuthenticationOptions&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nl">stickyAuth:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nl">biometricOnly:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// Face ID 不可用 → 認證直接失敗
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">// 修復後
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nl">options:&lt;/span> &lt;span class="kd">const&lt;/span> &lt;span class="n">AuthenticationOptions&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nl">stickyAuth:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nl">biometricOnly:&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// Face ID 不可用 → 系統自動提示輸入裝置密碼
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">),&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>指標&lt;/th>
 &lt;th>值&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>影響範圍&lt;/td>
 &lt;td>Face ID 不可用時（戴口罩、光線差、指紋模糊、模擬器）完全無法使用 app&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>修復成本&lt;/td>
 &lt;td>改一個 boolean&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>根因&lt;/td>
 &lt;td>企劃階段未設計 biometric gate 的 fallback&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Gate fallback 是設計問題，不是實作問題&lt;/strong>。&lt;code>biometricOnly&lt;/code> 的預設值是 &lt;code>false&lt;/code>（允許密碼 fallback），開發時特意改成 &lt;code>true&lt;/code> 是因為認為「安全性更高」。但這個判斷沒有考慮 fallback 缺失時的 UX 代價 — 使用者完全無法進入 app。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>開發環境遮蔽了問題&lt;/strong>。iOS 模擬器預設不支援 Face ID，但 &lt;code>isAvailable()&lt;/code> 的實作會檢查 &lt;code>isDeviceSupported()&lt;/code> + &lt;code>getAvailableBiometrics().isNotEmpty&lt;/code>。模擬器回傳 &lt;code>isDeviceSupported() = true&lt;/code> 但 &lt;code>getAvailableBiometrics() = []&lt;/code>，所以在模擬器上 &lt;code>isAvailable()&lt;/code> 回傳 false，直接跳過認證走預設路徑。真實裝置上 &lt;code>isAvailable() = true&lt;/code> 但 Face ID 可能失敗，這時沒有 fallback。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>安全性 vs 可用性的取捨需要顯式記錄&lt;/strong>。&lt;code>biometricOnly: true&lt;/code> 的安全收益是「確保只有生物特徵擁有者能操作」；代價是「任何生物辨識失敗場景都阻擋使用」。自用工具的使用者就是 owner，密碼 fallback 的安全風險遠低於「完全無法使用」的可用性風險。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>每個 gate 設計時列三問&lt;/strong>：成功時做什麼？失敗時做什麼？使用者不知道發生什麼時做什麼？&lt;/li>
&lt;li>&lt;strong>在狀態矩陣標注 gate fallback&lt;/strong>：biometric / network / auth 每個 gate 旁邊標注替代路徑，空白 = 使用者被擋住。&lt;/li>
&lt;li>&lt;strong>安全 vs 可用性取捨顯式記錄&lt;/strong>：在 spec 文件記錄「&lt;code>biometricOnly: false&lt;/code> — 接受密碼 fallback，因為自用工具可用性優先於生物辨識強制」。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想設計 Gate fallback 體系 → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法&lt;/a>&lt;/li>
&lt;li>想了解 biometric 在不同平台的行為差異 → 待補：iOS/Android biometric API 行為對照&lt;/li>
&lt;li>類似案例（導航死胡同）→ &lt;a href="https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1 五個狀態零個退出&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 Gate（使用者必須通過的關卡）的設計不只是「成功時怎麼做」，還必須包含「失敗時的替代路徑」。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel 使用 <code>local_auth</code> 套件進行生物辨識認證。<code>AuthenticationOptions</code> 設定 <code>biometricOnly: true</code>，表示只接受生物辨識（Face ID / 指紋），不接受裝置密碼作為 fallback。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 修復前
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="nl">options:</span> <span class="kd">const</span> <span class="n">AuthenticationOptions</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nl">stickyAuth:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nl">biometricOnly:</span> <span class="kc">true</span><span class="p">,</span>  <span class="c1">// Face ID 不可用 → 認證直接失敗
</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></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">// 修復後
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="nl">options:</span> <span class="kd">const</span> <span class="n">AuthenticationOptions</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="nl">stickyAuth:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="nl">biometricOnly:</span> <span class="kc">false</span><span class="p">,</span> <span class="c1">// Face ID 不可用 → 系統自動提示輸入裝置密碼
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="p">),</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>影響範圍</td>
          <td>Face ID 不可用時（戴口罩、光線差、指紋模糊、模擬器）完全無法使用 app</td>
      </tr>
      <tr>
          <td>修復成本</td>
          <td>改一個 boolean</td>
      </tr>
      <tr>
          <td>根因</td>
          <td>企劃階段未設計 biometric gate 的 fallback</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>Gate fallback 是設計問題，不是實作問題</strong>。<code>biometricOnly</code> 的預設值是 <code>false</code>（允許密碼 fallback），開發時特意改成 <code>true</code> 是因為認為「安全性更高」。但這個判斷沒有考慮 fallback 缺失時的 UX 代價 — 使用者完全無法進入 app。</p>
</li>
<li>
<p><strong>開發環境遮蔽了問題</strong>。iOS 模擬器預設不支援 Face ID，但 <code>isAvailable()</code> 的實作會檢查 <code>isDeviceSupported()</code> + <code>getAvailableBiometrics().isNotEmpty</code>。模擬器回傳 <code>isDeviceSupported() = true</code> 但 <code>getAvailableBiometrics() = []</code>，所以在模擬器上 <code>isAvailable()</code> 回傳 false，直接跳過認證走預設路徑。真實裝置上 <code>isAvailable() = true</code> 但 Face ID 可能失敗，這時沒有 fallback。</p>
</li>
<li>
<p><strong>安全性 vs 可用性的取捨需要顯式記錄</strong>。<code>biometricOnly: true</code> 的安全收益是「確保只有生物特徵擁有者能操作」；代價是「任何生物辨識失敗場景都阻擋使用」。自用工具的使用者就是 owner，密碼 fallback 的安全風險遠低於「完全無法使用」的可用性風險。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>每個 gate 設計時列三問</strong>：成功時做什麼？失敗時做什麼？使用者不知道發生什麼時做什麼？</li>
<li><strong>在狀態矩陣標注 gate fallback</strong>：biometric / network / auth 每個 gate 旁邊標注替代路徑，空白 = 使用者被擋住。</li>
<li><strong>安全 vs 可用性取捨顯式記錄</strong>：在 spec 文件記錄「<code>biometricOnly: false</code> — 接受密碼 fallback，因為自用工具可用性優先於生物辨識強制」。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想設計 Gate fallback 體系 → <a href="/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法</a></li>
<li>想了解 biometric 在不同平台的行為差異 → 待補：iOS/Android biometric API 行為對照</li>
<li>類似案例（導航死胡同）→ <a href="/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1 五個狀態零個退出</a></li>
</ul>
]]></content:encoded></item><item><title>模組二：Gate 與 Fallback 設計</title><link>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/</guid><description>&lt;p>回答「使用者過不了關卡時怎麼辦」。&lt;/p>
&lt;h2 id="對應-findings">對應 findings&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Finding&lt;/th>
 &lt;th>來源&lt;/th>
 &lt;th>內容&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>UF-4&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2&lt;/a>&lt;/td>
 &lt;td>biometricOnly 安全收益 vs 可用性代價 — &lt;strong>本模組主寫&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>UF-5&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2&lt;/a>&lt;/td>
 &lt;td>開發環境遮蔽 gate 問題（模擬器行為 vs 真機）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Gate 分類與三問設計法（成功 / 失敗 / 使用者不知道發生什麼）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Biometric fallback 完整設計（iOS/Android 差異）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 網路斷線 UX 模式（offline-first / retry / degraded mode）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Permission 請求時機與措辭&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 開發環境 vs 真機的 gate 行為差異表&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一 測試策略&lt;/a>：gate fallback 的 mock vs 真機行為差異需要 protocol test&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安&lt;/a>：biometric fallback 的安全 vs 可用性取捨&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「使用者過不了關卡時怎麼辦」。</p>
<h2 id="對應-findings">對應 findings</h2>
<table>
  <thead>
      <tr>
          <th>Finding</th>
          <th>來源</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>UF-4</td>
          <td><a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2</a></td>
          <td>biometricOnly 安全收益 vs 可用性代價 — <strong>本模組主寫</strong></td>
      </tr>
      <tr>
          <td>UF-5</td>
          <td><a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2</a></td>
          <td>開發環境遮蔽 gate 問題（模擬器行為 vs 真機）</td>
      </tr>
  </tbody>
</table>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> Gate 分類與三問設計法（成功 / 失敗 / 使用者不知道發生什麼）</li>
<li><input checked="" disabled="" type="checkbox"> Biometric fallback 完整設計（iOS/Android 差異）</li>
<li><input checked="" disabled="" type="checkbox"> 網路斷線 UX 模式（offline-first / retry / degraded mode）</li>
<li><input checked="" disabled="" type="checkbox"> Permission 請求時機與措辭</li>
<li><input checked="" disabled="" type="checkbox"> 開發環境 vs 真機的 gate 行為差異表</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一 測試策略</a>：gate fallback 的 mock vs 真機行為差異需要 protocol test</li>
<li>→ <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a>：biometric fallback 的安全 vs 可用性取捨</li>
</ul>
]]></content:encoded></item><item><title>Collector Access Control 實作</title><link>https://tarrragon.github.io/blog/monitoring/07-security-privacy/collector-access-control/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/07-security-privacy/collector-access-control/</guid><description>&lt;p>Collector access control 管理「誰可以對 collector 做什麼操作」。三層控制各自回答不同的問題：認證回答「來源是誰」，授權回答「這個來源被允許做什麼」，access log 回答「誰在什麼時候實際做了什麼」。&lt;/p>
&lt;h2 id="認證來源是誰">認證：來源是誰&lt;/h2>
&lt;p>認證驗證送出資料的 client 是否合法。未認證的 request 應該被拒絕，避免任意來源向 collector 寫入資料。&lt;/p>
&lt;h3 id="api-key-認證">API Key 認證&lt;/h3>
&lt;p>每個合法的 SDK client 有一個 API key。Collector 檢查 request header 中的 API key 是否在合法清單中。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">authMiddleware&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">next&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Handler&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Handler&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">HandlerFunc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">key&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Header&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;X-API-Key&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nf">isValidKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;unauthorized&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusUnauthorized&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ServeHTTP&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>自用工具場景下，一個 API key 對應一個 client 通常就足夠。多個 client（例如同一個 app 的 iOS 和 Android 版本）可以用同一個 key，或每個平台一個 key 以便在 access log 中區分來源。&lt;/p>
&lt;h3 id="mtlsmutual-tls">mTLS（Mutual TLS）&lt;/h3>
&lt;p>Client 和 server 互相驗證對方的憑證。安全性比 API key 高 — 攻擊者即使取得 API key，沒有 client 憑證也無法連線。&lt;/p>
&lt;p>mTLS 的設定成本較高（每個 client 需要產生和管理憑證），適合對安全性要求較高的環境。自用工具通常不需要 mTLS。&lt;/p>
&lt;h2 id="授權允許做什麼">授權：允許做什麼&lt;/h2>
&lt;p>授權控制已認證的 client 可以執行哪些操作。Collector 的操作通常分為兩類：寫入事件和查詢事件。&lt;/p>
&lt;h3 id="角色分離">角色分離&lt;/h3>
&lt;p>最簡單的授權模型是兩個角色：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Writer&lt;/strong>：只能寫入事件（POST /events）。SDK client 使用這個角色。&lt;/li>
&lt;li>&lt;strong>Reader&lt;/strong>：只能查詢事件（GET /events、GET /query）。開發者的 CLI 工具使用這個角色。&lt;/li>
&lt;/ul>
&lt;p>角色分離的價值在於限制洩漏的影響範圍。如果 SDK 的 API key 被洩漏，攻擊者只能寫入（產生垃圾事件），不能讀取（看到歷史事件中的敏感資訊）。&lt;/p>
&lt;h3 id="寫入限制">寫入限制&lt;/h3>
&lt;p>即使認證通過、角色正確，collector 也可以對寫入加上限制：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Rate limit&lt;/strong>：每個 API key 每分鐘最多 N 個 request。防止 client 端 bug 導致事件風暴。&lt;/li>
&lt;li>&lt;strong>Payload size limit&lt;/strong>：每個事件最大 M KB。防止異常大的 event data 消耗儲存。&lt;/li>
&lt;li>&lt;strong>Schema validation&lt;/strong>：事件必須符合定義的 JSON schema。格式不正確的事件拒絕存入。&lt;/li>
&lt;/ul>
&lt;h2 id="access-log誰做了什麼">Access Log：誰做了什麼&lt;/h2>
&lt;p>Access log 記錄每個到達 collector 的 request — 來源 IP、API key（或 key 的 hash）、操作類型、時間戳、response status。&lt;/p>
&lt;p>Access log 的用途：&lt;/p>
&lt;p>&lt;strong>安全審計&lt;/strong>：發現異常行為 — 未知 IP 的大量寫入、非工作時間的讀取、連續的認證失敗。&lt;/p>
&lt;p>&lt;strong>問題排查&lt;/strong>：SDK 說事件送出成功但 collector 沒有收到 — access log 可以確認 request 是否到達、response 是什麼。&lt;/p>
&lt;p>&lt;strong>用量統計&lt;/strong>：每個 client 送了多少事件、佔多少儲存。&lt;/p>
&lt;p>Access log 本身也是監控資料，但和業務事件分開儲存。Access log 存在 collector 本機的 log 檔中，用系統的 logrotate 管理輪替。&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">2026-06-19T10:30:00Z POST /events key=sk_mon_ab...cd ip=192.168.1.50 status=200 size=1234
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">2026-06-19T10:30:01Z POST /events key=INVALID ip=10.0.0.99 status=401 size=0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">2026-06-19T10:31:00Z GET /query key=sk_read_ef...gh ip=192.168.1.1 status=200 size=8901&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>SDK 端的 redaction → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/sdk-redaction-api/" data-link-title="SDK Redaction API 設計" data-link-desc="預設 redaction rule 過濾已知敏感欄位、自訂 pattern 擴展應用特有的 secret 格式 — redaction 在 SDK 端執行，敏感資料不離開 client">SDK Redaction API 設計&lt;/a>&lt;/li>
&lt;li>Transport 層的加密 → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全&lt;/a>&lt;/li>
&lt;li>資料儲存後的去識別化 → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/anonymization-strategy/" data-link-title="去識別化策略" data-link-desc="IP 截斷 / user agent 簡化 / stack trace 路徑清理 / session UUID — 四種去識別化技術的適用場景和實作方式">去識別化策略&lt;/a>&lt;/li>
&lt;li>Client-side credential 暴露的根本限制 → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/client-sdk-authentication/" data-link-title="Client-side SDK 認證的根本限制" data-link-desc="嵌在 client 端的 credential 必然可被提取 — 認清 architecture 天花板後的多層緩解策略，從 origin 驗證到 device attestation">Client-side SDK 認證&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Collector access control 管理「誰可以對 collector 做什麼操作」。三層控制各自回答不同的問題：認證回答「來源是誰」，授權回答「這個來源被允許做什麼」，access log 回答「誰在什麼時候實際做了什麼」。</p>
<h2 id="認證來源是誰">認證：來源是誰</h2>
<p>認證驗證送出資料的 client 是否合法。未認證的 request 應該被拒絕，避免任意來源向 collector 寫入資料。</p>
<h3 id="api-key-認證">API Key 認證</h3>
<p>每個合法的 SDK client 有一個 API key。Collector 檢查 request header 中的 API key 是否在合法清單中。</p>





<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="kd">func</span> <span class="nf">authMiddleware</span><span class="p">(</span><span class="nx">next</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span><span class="p">)</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="nx">http</span><span class="p">.</span><span class="nf">HandlerFunc</span><span class="p">(</span><span class="kd">func</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</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">key</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;X-API-Key&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nf">isValidKey</span><span class="p">(</span><span class="nx">key</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;unauthorized&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusUnauthorized</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="k">return</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="nx">next</span><span class="p">.</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">r</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>自用工具場景下，一個 API key 對應一個 client 通常就足夠。多個 client（例如同一個 app 的 iOS 和 Android 版本）可以用同一個 key，或每個平台一個 key 以便在 access log 中區分來源。</p>
<h3 id="mtlsmutual-tls">mTLS（Mutual TLS）</h3>
<p>Client 和 server 互相驗證對方的憑證。安全性比 API key 高 — 攻擊者即使取得 API key，沒有 client 憑證也無法連線。</p>
<p>mTLS 的設定成本較高（每個 client 需要產生和管理憑證），適合對安全性要求較高的環境。自用工具通常不需要 mTLS。</p>
<h2 id="授權允許做什麼">授權：允許做什麼</h2>
<p>授權控制已認證的 client 可以執行哪些操作。Collector 的操作通常分為兩類：寫入事件和查詢事件。</p>
<h3 id="角色分離">角色分離</h3>
<p>最簡單的授權模型是兩個角色：</p>
<ul>
<li><strong>Writer</strong>：只能寫入事件（POST /events）。SDK client 使用這個角色。</li>
<li><strong>Reader</strong>：只能查詢事件（GET /events、GET /query）。開發者的 CLI 工具使用這個角色。</li>
</ul>
<p>角色分離的價值在於限制洩漏的影響範圍。如果 SDK 的 API key 被洩漏，攻擊者只能寫入（產生垃圾事件），不能讀取（看到歷史事件中的敏感資訊）。</p>
<h3 id="寫入限制">寫入限制</h3>
<p>即使認證通過、角色正確，collector 也可以對寫入加上限制：</p>
<ul>
<li><strong>Rate limit</strong>：每個 API key 每分鐘最多 N 個 request。防止 client 端 bug 導致事件風暴。</li>
<li><strong>Payload size limit</strong>：每個事件最大 M KB。防止異常大的 event data 消耗儲存。</li>
<li><strong>Schema validation</strong>：事件必須符合定義的 JSON schema。格式不正確的事件拒絕存入。</li>
</ul>
<h2 id="access-log誰做了什麼">Access Log：誰做了什麼</h2>
<p>Access log 記錄每個到達 collector 的 request — 來源 IP、API key（或 key 的 hash）、操作類型、時間戳、response status。</p>
<p>Access log 的用途：</p>
<p><strong>安全審計</strong>：發現異常行為 — 未知 IP 的大量寫入、非工作時間的讀取、連續的認證失敗。</p>
<p><strong>問題排查</strong>：SDK 說事件送出成功但 collector 沒有收到 — access log 可以確認 request 是否到達、response 是什麼。</p>
<p><strong>用量統計</strong>：每個 client 送了多少事件、佔多少儲存。</p>
<p>Access log 本身也是監控資料，但和業務事件分開儲存。Access log 存在 collector 本機的 log 檔中，用系統的 logrotate 管理輪替。</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">2026-06-19T10:30:00Z POST /events key=sk_mon_ab...cd ip=192.168.1.50 status=200 size=1234
</span></span><span class="line"><span class="ln">2</span><span class="cl">2026-06-19T10:30:01Z POST /events key=INVALID ip=10.0.0.99 status=401 size=0
</span></span><span class="line"><span class="ln">3</span><span class="cl">2026-06-19T10:31:00Z GET /query key=sk_read_ef...gh ip=192.168.1.1 status=200 size=8901</span></span></code></pre></div><h2 id="下一步路由">下一步路由</h2>
<ul>
<li>SDK 端的 redaction → <a href="/blog/monitoring/07-security-privacy/sdk-redaction-api/" data-link-title="SDK Redaction API 設計" data-link-desc="預設 redaction rule 過濾已知敏感欄位、自訂 pattern 擴展應用特有的 secret 格式 — redaction 在 SDK 端執行，敏感資料不離開 client">SDK Redaction API 設計</a></li>
<li>Transport 層的加密 → <a href="/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全</a></li>
<li>資料儲存後的去識別化 → <a href="/blog/monitoring/07-security-privacy/anonymization-strategy/" data-link-title="去識別化策略" data-link-desc="IP 截斷 / user agent 簡化 / stack trace 路徑清理 / session UUID — 四種去識別化技術的適用場景和實作方式">去識別化策略</a></li>
<li>Client-side credential 暴露的根本限制 → <a href="/blog/monitoring/07-security-privacy/client-sdk-authentication/" data-link-title="Client-side SDK 認證的根本限制" data-link-desc="嵌在 client 端的 credential 必然可被提取 — 認清 architecture 天花板後的多層緩解策略，從 origin 驗證到 device attestation">Client-side SDK 認證</a></li>
</ul>
]]></content:encoded></item><item><title>Client-side SDK 認證的根本限制</title><link>https://tarrragon.github.io/blog/monitoring/07-security-privacy/client-sdk-authentication/</link><pubDate>Wed, 24 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/07-security-privacy/client-sdk-authentication/</guid><description>&lt;p>當監控 SDK 部署在使用者裝置上（瀏覽器、手機 app、本機腳本），collector 的 ingestion endpoint 就暴露在外部網路 — 認證機制需要面對 credential 必然可被提取的前提。Client-side SDK 的認證和 server-side API 的認證面對的是結構性不同的問題。Server-side 的 API key 存在環境變數或 secret store 裡，只有 server process 能讀取。Client-side SDK 的 credential 必須嵌入到使用者手上的程式碼中 — JS bundle、APK、Python script — 使用者（或攻擊者）可以直接讀取。&lt;/p>
&lt;p>這個限制來自 architecture，和 implementation 無關。混淆 JS、ProGuard 混淆 APK、編譯 Python 成 &lt;code>.pyc&lt;/code>，都只增加提取成本，不改變「credential 在 client 端」的事實。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control&lt;/a> 討論了 API key 和 mTLS 的認證機制，&lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全&lt;/a> 討論了傳輸層加密。兩者的前提是 credential 被妥善保管。本章處理的是那個前提不成立時 — credential 已被提取或必然可被提取 — 的緩解策略。&lt;/p>
&lt;h2 id="商業方案的處理方式">商業方案的處理方式&lt;/h2>
&lt;p>所有主流的 client-side telemetry 方案都面對同樣的限制。它們的共同策略是：承認 client credential 會暴露，把防線從「保護 credential」轉移到「限制 credential 被濫用的影響」。&lt;/p>
&lt;p>&lt;strong>Google Analytics 4&lt;/strong>：Measurement ID（G-XXXXXXXXXX）直接寫在網頁的 JS snippet 中，任何人檢視網頁原始碼都能取得。GA4 的防護在 server-side — Google 用 domain 白名單過濾來源，加上自動的 bot traffic 偵測剔除機器流量。Measurement Protocol（server-to-server）需要額外的 API secret，但 client-side 的 gtag.js 不需要。&lt;/p>
&lt;p>&lt;strong>Sentry&lt;/strong>：DSN（Data Source Name）包含 project ID 和 public key，直接嵌在 SDK init 的程式碼中。Sentry 官方文件明確標示 DSN 是 public 的 — 攻擊者取得 DSN 只能送事件，不能讀取已收集的資料。防護靠 rate limit（每個 project 的 events/sec 上限）、allowed domains（只接受來自白名單 domain 的事件）、和 server-side 的 event 去重。&lt;/p>
&lt;p>&lt;strong>Firebase&lt;/strong>：整個 &lt;code>google-services.json&lt;/code> / &lt;code>GoogleService-Info.plist&lt;/code> 的內容 — 包含 apiKey、projectId、appId — 都視為公開資訊。Firebase 的安全模型不依賴這些 key 的保密性；它們的功能是識別（identify）而非授權（authorize）。需要保護的資源靠 Firebase Security Rules 和 App Check（device attestation）處理。&lt;/p>
&lt;p>&lt;strong>Datadog RUM&lt;/strong>：Client token 是獨立於 API key 的 credential。API key 可以讀寫所有 Datadog 資料，必須保護在 server-side；client token 只能寫入 RUM 事件，設計上可以暴露在 client 端。Datadog 建議搭配 intake proxy（collector 前面加一層自己的 server），讓 client token 不直接出現在瀏覽器中。&lt;/p></description><content:encoded><![CDATA[<p>當監控 SDK 部署在使用者裝置上（瀏覽器、手機 app、本機腳本），collector 的 ingestion endpoint 就暴露在外部網路 — 認證機制需要面對 credential 必然可被提取的前提。Client-side SDK 的認證和 server-side API 的認證面對的是結構性不同的問題。Server-side 的 API key 存在環境變數或 secret store 裡，只有 server process 能讀取。Client-side SDK 的 credential 必須嵌入到使用者手上的程式碼中 — JS bundle、APK、Python script — 使用者（或攻擊者）可以直接讀取。</p>
<p>這個限制來自 architecture，和 implementation 無關。混淆 JS、ProGuard 混淆 APK、編譯 Python 成 <code>.pyc</code>，都只增加提取成本，不改變「credential 在 client 端」的事實。</p>
<p><a href="/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control</a> 討論了 API key 和 mTLS 的認證機制，<a href="/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全</a> 討論了傳輸層加密。兩者的前提是 credential 被妥善保管。本章處理的是那個前提不成立時 — credential 已被提取或必然可被提取 — 的緩解策略。</p>
<h2 id="商業方案的處理方式">商業方案的處理方式</h2>
<p>所有主流的 client-side telemetry 方案都面對同樣的限制。它們的共同策略是：承認 client credential 會暴露，把防線從「保護 credential」轉移到「限制 credential 被濫用的影響」。</p>
<p><strong>Google Analytics 4</strong>：Measurement ID（G-XXXXXXXXXX）直接寫在網頁的 JS snippet 中，任何人檢視網頁原始碼都能取得。GA4 的防護在 server-side — Google 用 domain 白名單過濾來源，加上自動的 bot traffic 偵測剔除機器流量。Measurement Protocol（server-to-server）需要額外的 API secret，但 client-side 的 gtag.js 不需要。</p>
<p><strong>Sentry</strong>：DSN（Data Source Name）包含 project ID 和 public key，直接嵌在 SDK init 的程式碼中。Sentry 官方文件明確標示 DSN 是 public 的 — 攻擊者取得 DSN 只能送事件，不能讀取已收集的資料。防護靠 rate limit（每個 project 的 events/sec 上限）、allowed domains（只接受來自白名單 domain 的事件）、和 server-side 的 event 去重。</p>
<p><strong>Firebase</strong>：整個 <code>google-services.json</code> / <code>GoogleService-Info.plist</code> 的內容 — 包含 apiKey、projectId、appId — 都視為公開資訊。Firebase 的安全模型不依賴這些 key 的保密性；它們的功能是識別（identify）而非授權（authorize）。需要保護的資源靠 Firebase Security Rules 和 App Check（device attestation）處理。</p>
<p><strong>Datadog RUM</strong>：Client token 是獨立於 API key 的 credential。API key 可以讀寫所有 Datadog 資料，必須保護在 server-side；client token 只能寫入 RUM 事件，設計上可以暴露在 client 端。Datadog 建議搭配 intake proxy（collector 前面加一層自己的 server），讓 client token 不直接出現在瀏覽器中。</p>
<p>這些方案的共同模式：client-side credential 的角色是「識別來源」而非「授權存取」。即使被提取，攻擊者能做的事被限縮在「寫入事件」— 影響可控。</p>
<h2 id="認證天花板識別-vs-授權">認證天花板：識別 vs 授權</h2>
<p><a href="/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control</a> 的 API key 同時承擔識別和授權 — 有 key 就能寫入，沒 key 就被拒絕。在 server-side 場景下這沒有問題，因為 key 不會暴露。</p>
<p>Client-side 場景需要拆開這兩個功能：</p>
<p><strong>識別（identification）</strong>：這個 request 來自哪個 app、哪個 SDK、哪個部署版本。識別資訊可以公開 — 它的價值是讓 collector 知道事件來自哪裡，用於 access log、per-app rate limit、和事件標記。</p>
<p><strong>授權（authorization）</strong>：這個 request 有沒有權限執行寫入操作。授權依賴 credential 的保密性 — 在 client-side 場景下，credential 保密性的天花板很低。</p>
<p>接受這個區分後，client-side SDK 的 API key 更接近「識別 token」。它的洩漏不是安全事件（像 server-side API key 洩漏那樣），而是預期中的狀態。防護的重點從「防止 key 洩漏」轉移到「限制 key 被濫用時的影響」。</p>
<h2 id="多層緩解策略">多層緩解策略</h2>
<p>以下各層按實作成本遞增排列。前面的層在多數場景下足夠，後面的層在 endpoint 暴露在公開網路且面對主動攻擊時才需要。</p>
<h3 id="第一層寫入限制collector-已有">第一層：寫入限制（collector 已有）</h3>
<p><a href="/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control</a> 的寫入限制 — rate limit、payload size limit、schema validation — 是第一層防護。這些機制不區分「合法 SDK」和「偽造 client」，對所有寫入請求一視同仁地施加約束。</p>
<p>Rate limit 限制每個 API key 的事件速率。Schema validation 拒絕不符合 <a href="/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">event.schema.json</a> 結構的 payload。兩者合起來把偽造流量的影響限制在「每秒 N 筆符合 schema 的事件」— 這個量級的資料汙染對 error tracking 的影響有限（error 事件靠 stack trace fingerprint 去重），對 funnel 分析的影響較大（行為事件的計數會被灌水）。</p>
<h3 id="第二層origin-驗證">第二層：Origin 驗證</h3>
<p>Web SDK 的 HTTP request 帶有瀏覽器自動附加的 <code>Origin</code> header。Collector 可以檢查 Origin 是否在白名單中。</p>





<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="kd">func</span> <span class="nf">originCheck</span><span class="p">(</span><span class="nx">next</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span><span class="p">,</span> <span class="nx">allowed</span> <span class="p">[]</span><span class="kt">string</span><span class="p">)</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">allowedSet</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">bool</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="nx">_</span><span class="p">,</span> <span class="nx">o</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">allowed</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">allowedSet</span><span class="p">[</span><span class="nx">o</span><span class="p">]</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">return</span> <span class="nx">http</span><span class="p">.</span><span class="nf">HandlerFunc</span><span class="p">(</span><span class="kd">func</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">origin</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;Origin&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">if</span> <span class="nx">origin</span> <span class="o">!=</span> <span class="s">&#34;&#34;</span> <span class="o">&amp;&amp;</span> <span class="p">!</span><span class="nx">allowedSet</span><span class="p">[</span><span class="nx">origin</span><span class="p">]</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;forbidden origin&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusForbidden</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">next</span><span class="p">.</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">r</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Origin 驗證擋住的是「從瀏覽器中跨域呼叫」的場景 — 攻擊者在自己的網站用 JS 向你的 collector 發 request，瀏覽器會帶上攻擊者網站的 Origin，被 collector 拒絕。</p>
<p><strong>天花板</strong>：Origin header 只有瀏覽器會自動附加。用 <code>curl</code>、Postman、或任何非瀏覽器 HTTP client 發 request 時，可以自行設定任意 Origin 值。Origin 驗證擋得住瀏覽器中的跨域呼叫，擋不住直接用 HTTP client 偽造的 request。</p>
<p>Mobile SDK（Flutter / native app）的 request 不帶 Origin header。Origin 驗證只對 Web SDK 有效。</p>
<h3 id="第三層request-signing">第三層：Request signing</h3>
<p>SDK 用 HMAC 對每個 request 簽章，collector 驗證簽章有效性。簽章的輸入包含 timestamp 和 payload hash，防止 replay attack 和 payload 竄改。</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">X-Signature: a3f8c2e1b7d94f06...  (HMAC-SHA256 結果的 hex 編碼)
</span></span><span class="line"><span class="ln">2</span><span class="cl">X-Timestamp: 1719216000</span></span></code></pre></div><p>SDK 計算方式：<code>HMAC-SHA256(secret, timestamp + &quot;.&quot; + SHA256(body))</code>，結果轉 hex 字串放入 <code>X-Signature</code> header。</p>
<p>Collector 端的驗證邏輯：</p>





<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="kd">func</span> <span class="nf">verifySignature</span><span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">,</span> <span class="nx">secret</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ts</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;X-Timestamp&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">sig</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;X-Signature&#34;</span><span class="p">)</span>
</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 class="c1">// 拒絕超過 5 分鐘的 request timestamp（防 replay）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="c1">// 5 分鐘容忍 client-server 時鐘漂移和網路延遲；行動裝置偏差大的環境可放寬到 10 分鐘</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="c1">// 此處的 timestamp 是 HTTP request 發出時間，和事件的 timestamp 欄位（事件產生時間）無關</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">tsInt</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">ts</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"> 9</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="o">||</span> <span class="nf">abs</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Unix</span><span class="p">()</span><span class="o">-</span><span class="nx">tsInt</span><span class="p">)</span> <span class="p">&gt;</span> <span class="mi">300</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="kc">false</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">body</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">io</span><span class="p">.</span><span class="nf">ReadAll</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">bodyHash</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="nx">body</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">expected</span> <span class="o">:=</span> <span class="nx">hmac</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="nx">sha256</span><span class="p">.</span><span class="nx">New</span><span class="p">,</span> <span class="p">[]</span><span class="nb">byte</span><span class="p">(</span><span class="nx">secret</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">expected</span><span class="p">.</span><span class="nf">Write</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="nx">ts</span> <span class="o">+</span> <span class="s">&#34;.&#34;</span> <span class="o">+</span> <span class="nx">hex</span><span class="p">.</span><span class="nf">EncodeToString</span><span class="p">(</span><span class="nx">bodyHash</span><span class="p">[:])))</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">sigBytes</span><span class="p">,</span> <span class="nx">err</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">sig</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</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">20</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">return</span> <span class="nx">hmac</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">sigBytes</span><span class="p">,</span> <span class="nx">expected</span><span class="p">.</span><span class="nf">Sum</span><span class="p">(</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="p">}</span></span></span></code></pre></div><p>Request signing 增加偽造成本 — 攻擊者需要提取 HMAC secret 並實作簽章邏輯，而非直接複製一個 API key 貼到 curl 指令。</p>
<p>HMAC secret 和 API key 一樣嵌在 client 端程式碼中，反編譯 APK 或閱讀 JS bundle 可以提取。Signing 增加的是攻擊者的工程投入（需要理解簽章算法並正確實作），而非理論上的安全性。對 casual attacker（看到 API key 就想試試的人）有效，對 motivated attacker（願意花時間逆向工程的人）無效。</p>
<h3 id="第四層行為分析異常偵測">第四層：行為分析異常偵測</h3>
<p>Collector 端統計每個 API key（或 source.app）的事件模式，建立 baseline 後偵測偏離。</p>
<p>正常 SDK 的行為有可預測的特徵：</p>
<table>
  <thead>
      <tr>
          <th>特徵</th>
          <th>正常 SDK 的 pattern</th>
          <th>偽造流量的 pattern</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>事件類型分布</td>
          <td>error / event / lifecycle / metric 四類混合</td>
          <td>可能只有單一類型</td>
      </tr>
      <tr>
          <td>事件間隔</td>
          <td>攢批送出，interval 接近 SDK config 的 flush interval</td>
          <td>固定間隔或連續送出</td>
      </tr>
      <tr>
          <td>Payload 結構</td>
          <td><code>source.sdk</code> / <code>source.platform</code> / <code>source.app</code> 值穩定</td>
          <td>可能缺少 SDK 自動填入的欄位</td>
      </tr>
      <tr>
          <td>Session 行為</td>
          <td>有 lifecycle 事件（session.begin / session.end）</td>
          <td>可能沒有 session 邊界</td>
      </tr>
      <tr>
          <td>時間分布</td>
          <td>跟使用者活動時段相關（工作時間 / 使用高峰）</td>
          <td>可能 24 小時均勻分布</td>
      </tr>
  </tbody>
</table>
<p>Collector 可以用 rule engine 偵測異常模式：</p>
<ul>
<li>單一 API key 的事件量在 10 分鐘內超過過去 24 小時平均值的 10 倍</li>
<li>連續 N 個 request 的事件全是同一個 type</li>
<li><code>source.sdk</code> 欄位的值不在已知的 SDK 版本清單中</li>
</ul>
<p>偵測到異常後的處理方式是標記而非丟棄 — 在事件中加入 <code>_flags.suspicious = true</code> flag，讓 dashboard 和分析查詢可以過濾。直接丟棄有誤殺正常流量的風險（例如行銷活動導致的真實流量暴增）。</p>
<p>攻擊者如果研究過正常 SDK 的行為模式（事件類型分布、送出間隔、payload 結構），可以模擬出相似的流量。行為分析依賴「偽造流量和正常流量有可偵測的差異」這個前提 — 對低投入的攻擊者成立，對高投入的攻擊者不一定。</p>
<h3 id="第五層device-attestation">第五層：Device attestation</h3>
<p>由作業系統或平台層驗證 client 的合法性，提供 SDK 自身無法產生的證明。</p>
<p><strong>Firebase App Check</strong>：整合 DeviceCheck（iOS）、Play Integrity（Android）、reCAPTCHA Enterprise（Web），由裝置平台出具 attestation token。Collector 向 Firebase 驗證 token 的有效性。</p>
<p><strong>Apple DeviceCheck / App Attest</strong>：iOS 裝置向 Apple server 請求 attestation，證明 request 來自一台真實的、未被篡改的 iOS 裝置上的合法 app。</p>
<p><strong>Google Play Integrity</strong>：驗證 request 來自 Google Play 安裝的 app、在未 root 的裝置上、由合法使用者操作。</p>
<p>Device attestation 提供的保證比前四層都強 — 它依賴裝置硬體和平台服務（難以偽造），而非 SDK 嵌入的 secret（可提取）。</p>
<p><strong>天花板</strong>：</p>
<ul>
<li>平台綁定 — 每個平台（iOS / Android / Web）需要各自整合不同的 attestation 服務，跨平台 SDK 的實作成本高</li>
<li>Root / 越獄裝置上 attestation 可能失敗或被繞過</li>
<li>Web 端的 reCAPTCHA 驗證依賴 Google 服務，有隱私和可用性的考量</li>
<li>自架 collector 需要額外整合 Firebase Admin SDK 或各平台的驗證 API</li>
</ul>
<p>Device attestation 適合商業產品級的 mobile app，對自架監控工具而言實作成本通常超出收益。</p>
<h2 id="自架方案的規模對應">自架方案的規模對應</h2>
<p>不同部署規模下，需要做到哪一層取決於 endpoint 的暴露程度和偽造流量的影響大小。</p>
<table>
  <thead>
      <tr>
          <th>部署場景</th>
          <th>暴露程度</th>
          <th>建議做到的層級</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自用（1 人，同機 / 同網段）</td>
          <td>低 — endpoint 不對外</td>
          <td>HTTPS + basic auth</td>
          <td>攻擊面只有同網段，認證足夠</td>
      </tr>
      <tr>
          <td>小型團隊（&lt; 100 人，VPN 內）</td>
          <td>低 — endpoint 在 VPN 後</td>
          <td>API key + rate limit</td>
          <td>VPN 已限制存取範圍，rate limit 防 SDK bug</td>
      </tr>
      <tr>
          <td>公開 endpoint（VPS / 雲端）</td>
          <td>高 — 任何人可存取</td>
          <td>第一到第四層 + WAF</td>
          <td>rate limit + origin + signing + 行為分析 + CDN/WAF 的 IP reputation 過濾</td>
      </tr>
      <tr>
          <td>商業產品（app store 發佈）</td>
          <td>高 — APK 可反編譯，JS 可檢視原始碼</td>
          <td>第一到第五層 + intake proxy</td>
          <td>需要 device attestation 和 proxy 層把 credential 從 client 端移除</td>
      </tr>
  </tbody>
</table>
<p><strong>Intake proxy 架構</strong>：在公開 endpoint 和商業產品場景下，可以在 collector 前面加一層自己的 server（proxy），SDK 送事件到 proxy，proxy 用 server-side API key 轉發到 collector。Client 端的 credential 只指向 proxy，proxy 的 API key 指向 collector — credential 分層，client 端的 key 洩漏不影響 collector 的認證。</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">SDK ──(client token)──→ Intake Proxy ──(server API key)──→ Collector</span></span></code></pre></div><p>Proxy 的額外成本是多一個 server 和網路跳躍。自用場景下不需要；endpoint 公開時值得考慮。</p>
<h2 id="偽造流量的影響分析">偽造流量的影響分析</h2>
<p>偽造流量進入 collector 後，對不同類型的分析影響不同。</p>
<p><strong>Error tracking 影響較低</strong>：error 事件的價值在 stack trace 和 error message。偽造的 error 事件缺少真實的 stack trace — 即使格式正確，內容是編造的。Error 去重靠 fingerprint（error type + message + stack trace top frame），偽造事件產生的 fingerprint 不會和真實 error 碰撞，在 dashboard 上是獨立的 error group，容易識別和過濾。</p>
<p><strong>行為分析影響較高</strong>：funnel 和 cohort 分析依賴事件計數的準確性。偽造的 <code>page.view</code> 和 <code>button.click</code> 事件直接灌水計數，導致轉換率失真。偽造事件越接近真實事件的結構（正確的 event name、合理的 timestamp），影響越大。</p>
<p><strong>資源消耗是固定成本</strong>：無論事件內容是否真實，每筆事件都消耗 collector 的寫入 I/O、儲存空間、和查詢時間。Rate limit 把這個成本限制在可控範圍 — 每秒 N 筆是上限，無論來源是否合法。</p>
<h3 id="事後標記策略">事後標記策略</h3>
<p>偵測到可疑流量後，collector 在事件中加入標記欄位而非直接丟棄。丟棄有誤殺風險 — 行銷活動的流量暴增、SDK 版本升級改變了事件模式、新平台的 SDK 上線 — 這些正常場景可能觸發異常偵測。</p>
<p>標記方式是在 collector 寫入時，對符合異常條件的事件附加 metadata：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;v&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;event&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;button.click&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nt">&#34;source&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;sdk&#34;</span><span class="p">:</span> <span class="s2">&#34;js&#34;</span><span class="p">,</span> <span class="nt">&#34;platform&#34;</span><span class="p">:</span> <span class="s2">&#34;web&#34;</span><span class="p">,</span> <span class="nt">&#34;app&#34;</span><span class="p">:</span> <span class="s2">&#34;main-site&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nt">&#34;_flags&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;suspicious&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&#34;reason&#34;</span><span class="p">:</span> <span class="s2">&#34;rate_anomaly&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Dashboard 查詢預設排除 <code>_flags.suspicious = true</code> 的事件。需要調查時可以包含 — 看可疑事件的模式有助於判斷是攻擊還是誤判。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Collector 端的認證和授權機制 → <a href="/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control 實作</a></li>
<li>Transport 層的加密保護 → <a href="/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全</a></li>
<li>Endpoint 濫用的威脅分析 → <a href="/blog/monitoring/07-security-privacy/monitoring-data-threat-model/" data-link-title="監控資料洩漏的 Threat Model" data-link-desc="監控系統本身是攻擊面 — 四個威脅場景（傳輸竊聽 / 儲存入侵 / endpoint 濫用 / 內部越權存取）的風險評估和防護措施">監控資料洩漏的 Threat Model</a></li>
<li>SDK 端的寫入速率控制 → <a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling</a></li>
<li>行為分析和 rule engine → <a href="/blog/monitoring/04-collector/rule-engine/" data-link-title="Rule engine 設計" data-link-desc="條件 → 動作 → 模板的三段式規則結構 — 讓 collector 從被動儲存變成主動回應">Rule Engine 設計</a></li>
<li>偽造流量對資料完整性的影響 → <a href="/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a></li>
<li>Error fingerprint 讓偽造 error 容易辨識 → <a href="/blog/monitoring/04-collector/error-fingerprint/" data-link-title="Error Fingerprint 與去重分群" data-link-desc="把大量 error 事件歸組成可管理的 issue 列表 — fingerprint 演算法、message normalization、error_groups 表設計、自架方案的務實邊界">Error Fingerprint 與去重分群</a></li>
</ul>
]]></content:encoded></item><item><title>Authentication</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/authentication/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/authentication/</guid><description>&lt;p>Authentication 的核心概念是「確認呼叫者是誰」。它可以透過 password、session、token、OAuth、certificate、API key 或 workload identity 完成。 可先對照 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/authorization/" data-link-title="Authorization" data-link-desc="說明授權如何判斷誰能對哪些資源執行哪些操作">Authorization&lt;/a>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Authentication 是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/authorization/" data-link-title="Authorization" data-link-desc="說明授權如何判斷誰能對哪些資源執行哪些操作">authorization&lt;/a> 的前置條件。系統先確認身份，再判斷該身份能否操作某個資源。身份確認失敗時，後續權限判斷缺少可靠基礎。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>系統需要 authentication 設計的訊號是服務需要區分使用者、管理員、service account 或第三方系統。Webhook 進站可以用 signature 驗證來源；service-to-service 可以用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/tls-mtls/" data-link-title="TLS / mTLS" data-link-desc="說明傳輸加密與雙向憑證驗證如何保護跨邊界資料流">mTLS&lt;/a> 或 workload identity。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Authentication 要處理 credential 保存、過期、撤銷、輪替、錯誤回應、登入風險與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">audit log&lt;/a>。安全事件後要能追查是哪個身份與 credential 被使用。&lt;/p></description><content:encoded><![CDATA[<p>Authentication 的核心概念是「確認呼叫者是誰」。它可以透過 password、session、token、OAuth、certificate、API key 或 workload identity 完成。 可先對照 <a href="/blog/backend/knowledge-cards/authorization/" data-link-title="Authorization" data-link-desc="說明授權如何判斷誰能對哪些資源執行哪些操作">Authorization</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>Authentication 是 <a href="/blog/backend/knowledge-cards/authorization/" data-link-title="Authorization" data-link-desc="說明授權如何判斷誰能對哪些資源執行哪些操作">authorization</a> 的前置條件。系統先確認身份，再判斷該身份能否操作某個資源。身份確認失敗時，後續權限判斷缺少可靠基礎。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>系統需要 authentication 設計的訊號是服務需要區分使用者、管理員、service account 或第三方系統。Webhook 進站可以用 signature 驗證來源；service-to-service 可以用 <a href="/blog/backend/knowledge-cards/tls-mtls/" data-link-title="TLS / mTLS" data-link-desc="說明傳輸加密與雙向憑證驗證如何保護跨邊界資料流">mTLS</a> 或 workload identity。</p>
<h2 id="設計責任">設計責任</h2>
<p>Authentication 要處理 credential 保存、過期、撤銷、輪替、錯誤回應、登入風險與 <a href="/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">audit log</a>。安全事件後要能追查是哪個身份與 credential 被使用。</p>
]]></content:encoded></item><item><title>API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning</title><link>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/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>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/</guid><description>&lt;h2 id="api-認證為什麼要分層">API 認證為什麼要分層&lt;/h2>
&lt;p>&lt;strong>API 認證的核心是「身分維度的分離」&lt;/strong> — 一個 request 同時牽涉「人」「呼叫的系統」「另一個系統有沒有對應身分」三個獨立問題，每個問題的 secret 機制不同、洩漏後果不同、撤銷方式不同。混用一個機制回答全部問題，等於用同一把鑰匙開家、車、保險箱。&lt;/p>
&lt;p>看似一個 API request，其實同時要回答：&lt;/p>
&lt;ul>
&lt;li>發起這個 request 的「&lt;strong>人&lt;/strong>」是誰？（identity）&lt;/li>
&lt;li>把這個 request 傳過來的「&lt;strong>系統&lt;/strong>」是誰？（caller）&lt;/li>
&lt;li>這個人在「&lt;strong>另一個系統&lt;/strong>」有沒有對應身分？（cross-system mapping）&lt;/li>
&lt;/ul>
&lt;p>每個問題都需要不同的 secret 機制來回答。設計時先拆身分維度，再選 token、shared secret、mTLS 或 provisioning workflow，才有辦法讓洩漏範圍、撤銷粒度與排障路由各自清楚。&lt;/p>
&lt;p>這篇整理兩層信任邊界（Layer 1 使用者、Layer 2 系統）跟一個跨系統 workflow（Layer 3 Provisioning），以及它們各自對應的 secret 機制。&lt;strong>每層的實作細節都另有獨立文章深入&lt;/strong>、本文聚焦「為什麼要分」「各層解什麼問題」的心智模型。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>前提假設&lt;/strong>：以下所有機制都假設 transport 走 HTTPS / TLS。Token 與 secret 需要在加密通道內傳輸，否則中間人可直接取得 credential。HTTPS 是所有層共同依賴的 transport 前提。&lt;/p>
&lt;p>&lt;strong>本文 token 範圍&lt;/strong>：本文討論「opaque token」（隨機字串、server 端 lookup），不涵蓋 JWT（self-contained token、簽章驗證）。兩者安全模型不同，比較見 Layer 1 段落。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="layer-1使用者層bearer-token">Layer 1：使用者層（Bearer Token）&lt;/h2>
&lt;p>&lt;strong>使用者層負責把 request 綁到已登入的人類或帳號主體&lt;/strong>。它回答的問題是：「這個 request 是哪個使用者發的？」&lt;/p>
&lt;p>&lt;strong>Bearer Token 是 capability credential（持有即授權）、不是 identity credential（身分證明）&lt;/strong>。差別在於：身分證遺失可以掛失補辦、別人撿到也無法直接領錢；Bearer Token 一旦被取得、攻擊者就能即時用該使用者身分發 request、沒有第二道關卡。這個本質決定了 token 的儲存、傳輸、撤銷機制都必須以「持有即危險」為前提設計。&lt;/p>
&lt;p>「Bearer Token」是 RFC 6750 定義的 HTTP authentication scheme（&lt;code>Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code>）、屬於通用概念 — GitHub PAT、Stripe API Key、OAuth access token、Laravel Sanctum 的 PAT、JWT 都是 Bearer Token 的不同實作。&lt;/p>
&lt;h3 id="opaque-token-vs-jwt兩種根本不同的設計">Opaque Token vs JWT：兩種根本不同的設計&lt;/h3>
&lt;p>「Bearer Token」是上位概念、實作上有兩條主線、安全模型完全不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>項目&lt;/th>
 &lt;th>Opaque Token（如 Sanctum）&lt;/th>
 &lt;th>JWT&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Token 本身&lt;/td>
 &lt;td>隨機字串、無內含資訊&lt;/td>
 &lt;td>簽章 payload、內嵌使用者 claim&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>驗證方式&lt;/td>
 &lt;td>server 查 DB lookup&lt;/td>
 &lt;td>驗簽章、不需 DB&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>載入使用者&lt;/td>
 &lt;td>從 DB row 撈&lt;/td>
 &lt;td>直接讀 claim&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>撤銷&lt;/td>
 &lt;td>刪 DB row、立即生效&lt;/td>
 &lt;td>困難、需 blacklist 或短 TTL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>洩漏暴露範圍&lt;/td>
 &lt;td>該 row 立即停用&lt;/td>
 &lt;td>直到 expire 都有效&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨服務驗證&lt;/td>
 &lt;td>需要共用 DB 或驗證 endpoint&lt;/td>
 &lt;td>共享公鑰即可、stateless&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩者各有適合情境：opaque token 撤銷快、適合「使用者主動登出 / 帳號被盜要立即停權」；JWT 不需 DB lookup、適合「跨多個 microservice、想避免每次都查中央 DB」。下面 Layer 1 的內容&lt;strong>只聚焦 opaque token&lt;/strong> — JWT 的設計細節（簽章演算法選擇、&lt;code>alg: none&lt;/code> 攻擊、key rotation）是獨立議題、不在本篇範圍。&lt;/p>
&lt;h3 id="opaque-token-的格式設計">Opaque Token 的格式設計&lt;/h3>
&lt;p>Opaque token 是隨機字串、但實際 format 在不同產品有兩條主流分流：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>設計&lt;/th>
 &lt;th>範例&lt;/th>
 &lt;th>解的問題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>&lt;code>{PK}|{secret}&lt;/code>&lt;/strong>&lt;/td>
 &lt;td>&lt;code>1|abc123def456...&lt;/code>（Laravel Sanctum）&lt;/td>
 &lt;td>用 PK 收斂 DB 搜尋、把 timing 安全留給應用層&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>&lt;code>{prefix}_{secret}&lt;/code>&lt;/strong>&lt;/td>
 &lt;td>&lt;code>ghp_xxx&lt;/code>（GitHub）、&lt;code>sk_live_xxx&lt;/code>（Stripe）&lt;/td>
 &lt;td>用語意 prefix 支援自動洩漏掃描跟 token type 辨識&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩種設計&lt;strong>沒有絕對優劣&lt;/strong>、取決於 token 的傳播範圍：純內部使用、Sanctum 設計簡潔且足夠；對外開放、容易散落公開 repo、prefix 設計能讓 GitHub Secret Scanning / Stripe webhook 等工具自動偵測洩漏。&lt;/p></description><content:encoded><![CDATA[<h2 id="api-認證為什麼要分層">API 認證為什麼要分層</h2>
<p><strong>API 認證的核心是「身分維度的分離」</strong> — 一個 request 同時牽涉「人」「呼叫的系統」「另一個系統有沒有對應身分」三個獨立問題，每個問題的 secret 機制不同、洩漏後果不同、撤銷方式不同。混用一個機制回答全部問題，等於用同一把鑰匙開家、車、保險箱。</p>
<p>看似一個 API request，其實同時要回答：</p>
<ul>
<li>發起這個 request 的「<strong>人</strong>」是誰？（identity）</li>
<li>把這個 request 傳過來的「<strong>系統</strong>」是誰？（caller）</li>
<li>這個人在「<strong>另一個系統</strong>」有沒有對應身分？（cross-system mapping）</li>
</ul>
<p>每個問題都需要不同的 secret 機制來回答。設計時先拆身分維度，再選 token、shared secret、mTLS 或 provisioning workflow，才有辦法讓洩漏範圍、撤銷粒度與排障路由各自清楚。</p>
<p>這篇整理兩層信任邊界（Layer 1 使用者、Layer 2 系統）跟一個跨系統 workflow（Layer 3 Provisioning），以及它們各自對應的 secret 機制。<strong>每層的實作細節都另有獨立文章深入</strong>、本文聚焦「為什麼要分」「各層解什麼問題」的心智模型。</p>
<blockquote>
<p><strong>前提假設</strong>：以下所有機制都假設 transport 走 HTTPS / TLS。Token 與 secret 需要在加密通道內傳輸，否則中間人可直接取得 credential。HTTPS 是所有層共同依賴的 transport 前提。</p>
<p><strong>本文 token 範圍</strong>：本文討論「opaque token」（隨機字串、server 端 lookup），不涵蓋 JWT（self-contained token、簽章驗證）。兩者安全模型不同，比較見 Layer 1 段落。</p></blockquote>
<hr>
<h2 id="layer-1使用者層bearer-token">Layer 1：使用者層（Bearer Token）</h2>
<p><strong>使用者層負責把 request 綁到已登入的人類或帳號主體</strong>。它回答的問題是：「這個 request 是哪個使用者發的？」</p>
<p><strong>Bearer Token 是 capability credential（持有即授權）、不是 identity credential（身分證明）</strong>。差別在於：身分證遺失可以掛失補辦、別人撿到也無法直接領錢；Bearer Token 一旦被取得、攻擊者就能即時用該使用者身分發 request、沒有第二道關卡。這個本質決定了 token 的儲存、傳輸、撤銷機制都必須以「持有即危險」為前提設計。</p>
<p>「Bearer Token」是 RFC 6750 定義的 HTTP authentication scheme（<code>Authorization: Bearer &lt;token&gt;</code>）、屬於通用概念 — GitHub PAT、Stripe API Key、OAuth access token、Laravel Sanctum 的 PAT、JWT 都是 Bearer Token 的不同實作。</p>
<h3 id="opaque-token-vs-jwt兩種根本不同的設計">Opaque Token vs JWT：兩種根本不同的設計</h3>
<p>「Bearer Token」是上位概念、實作上有兩條主線、安全模型完全不同：</p>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>Opaque Token（如 Sanctum）</th>
          <th>JWT</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Token 本身</td>
          <td>隨機字串、無內含資訊</td>
          <td>簽章 payload、內嵌使用者 claim</td>
      </tr>
      <tr>
          <td>驗證方式</td>
          <td>server 查 DB lookup</td>
          <td>驗簽章、不需 DB</td>
      </tr>
      <tr>
          <td>載入使用者</td>
          <td>從 DB row 撈</td>
          <td>直接讀 claim</td>
      </tr>
      <tr>
          <td>撤銷</td>
          <td>刪 DB row、立即生效</td>
          <td>困難、需 blacklist 或短 TTL</td>
      </tr>
      <tr>
          <td>洩漏暴露範圍</td>
          <td>該 row 立即停用</td>
          <td>直到 expire 都有效</td>
      </tr>
      <tr>
          <td>跨服務驗證</td>
          <td>需要共用 DB 或驗證 endpoint</td>
          <td>共享公鑰即可、stateless</td>
      </tr>
  </tbody>
</table>
<p>兩者各有適合情境：opaque token 撤銷快、適合「使用者主動登出 / 帳號被盜要立即停權」；JWT 不需 DB lookup、適合「跨多個 microservice、想避免每次都查中央 DB」。下面 Layer 1 的內容<strong>只聚焦 opaque token</strong> — JWT 的設計細節（簽章演算法選擇、<code>alg: none</code> 攻擊、key rotation）是獨立議題、不在本篇範圍。</p>
<h3 id="opaque-token-的格式設計">Opaque Token 的格式設計</h3>
<p>Opaque token 是隨機字串、但實際 format 在不同產品有兩條主流分流：</p>
<table>
  <thead>
      <tr>
          <th>設計</th>
          <th>範例</th>
          <th>解的問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong><code>{PK}|{secret}</code></strong></td>
          <td><code>1|abc123def456...</code>（Laravel Sanctum）</td>
          <td>用 PK 收斂 DB 搜尋、把 timing 安全留給應用層</td>
      </tr>
      <tr>
          <td><strong><code>{prefix}_{secret}</code></strong></td>
          <td><code>ghp_xxx</code>（GitHub）、<code>sk_live_xxx</code>（Stripe）</td>
          <td>用語意 prefix 支援自動洩漏掃描跟 token type 辨識</td>
      </tr>
  </tbody>
</table>
<p>兩種設計<strong>沒有絕對優劣</strong>、取決於 token 的傳播範圍：純內部使用、Sanctum 設計簡潔且足夠；對外開放、容易散落公開 repo、prefix 設計能讓 GitHub Secret Scanning / Stripe webhook 等工具自動偵測洩漏。</p>
<p>Sanctum 的 <code>{PK}|{secret}</code> 設計常被誤解為「業界標準」 — 其實是 Laravel 生態的特定選擇。具體機制、跟 GitHub / Stripe 設計的比較、各語言實作範例見 <a href="/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/" data-link-title="Laravel Sanctum 的 Bearer Token 設計剖析：{PK}|{secret} 為什麼這樣設計" data-link-desc="Laravel Sanctum `{PK}|{secret}` 格式的設計理由、hash 儲存取捨、constant-time 比對位置，以及跟 GitHub PAT、Stripe API Key 的差異。">Laravel Sanctum 的 Bearer Token 設計剖析</a>。</p>
<h3 id="token-在-db-的儲存原則簡述">Token 在 DB 的儲存原則（簡述）</h3>
<p>無論用哪種 format、有三條跨設計通用的儲存原則：</p>
<ol>
<li><strong>DB 只存 hash、不存原文</strong> — token 是高熵隨機字串、SHA-256 即可、不需 bcrypt</li>
<li><strong>比對必須是 constant-time</strong> — 用各語言提供的 <code>hash_equals</code> / <code>compare_digest</code> / <code>ConstantTimeCompare</code>、不用 <code>==</code></li>
<li><strong>Lookup 用穩定字段、機密比對放應用層</strong> — DB 引擎不保證 constant-time 比對、把機密比對搬離 DB</li>
</ol>
<p>這三條的詳細推導、各語言 constant-time 函式對照、非 Laravel 環境的實作範例見 <a href="/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/" data-link-title="Laravel Sanctum 的 Bearer Token 設計剖析：{PK}|{secret} 為什麼這樣設計" data-link-desc="Laravel Sanctum `{PK}|{secret}` 格式的設計理由、hash 儲存取捨、constant-time 比對位置，以及跟 GitHub PAT、Stripe API Key 的差異。">Laravel Sanctum 的 Bearer Token 設計剖析</a>。</p>
<h3 id="token-的生命週期">Token 的生命週期</h3>





<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">   Login                  Use                  Expire/Revoke
</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">issued → DB 存 hash  →  Bearer 驗證    →   row deleted
</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">                       set request.user</span></span></code></pre></div><ul>
<li><strong><code>expires_at</code></strong>（例如 7 天、30 天）— 限制洩漏 token 的暴露窗</li>
<li><strong><code>abilities</code> / <code>scopes</code></strong> — 限縮權限粒度（「只能讀」「只能存取某 resource」），降低單一 token 洩漏的破壞範圍</li>
<li><strong>登出即刪 row</strong> — opaque token 的撤銷成本低，這是它相對 JWT 的關鍵優勢</li>
<li><strong>rate limit / brute force 防護</strong> — token 是隨機字串、攻擊者可暴力試。應用層要對「token 驗證失敗」加 rate limit、避免被掃出有效 token</li>
<li><strong>長期 access 用 refresh token pattern</strong> — access token 短 TTL（小時級）、refresh token 長 TTL（月級）。Access token 洩漏只影響短窗、refresh token 撤銷後新的 access token 也無法發放</li>
</ul>
<h3 id="信任邊界">信任邊界</h3>





<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">[ 使用者 ] ─────────▶ [ API server ]
</span></span><span class="line"><span class="ln">2</span><span class="cl">              token        ↑
</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></span></code></pre></div><p>Bearer Token 是 capability credential — 任何持有它的 client 都能以該使用者身分發 request。這也是為什麼 token 一旦離開原本的 API server，就會引發下一層問題：B 系統收到 A 系統的 token、根本不知道該怎麼驗證、也不該驗證。</p>
<hr>
<h2 id="layer-2系統層system-to-system-credential">Layer 2：系統層（System-to-system credential）</h2>
<p><strong>系統層負責驗證呼叫方服務本身的身分</strong>。它回答的問題是：「這個 request 是哪個系統發的？」</p>
<p>當系統 A 需要呼叫系統 B 的 API 時，Layer 1 的使用者 token 只代表「使用者」的身分。系統 B 仍需要獨立驗證「這個 request 來自合法的合作系統 A」，這個判斷要由系統層 credential 承擔。</p>
<h3 id="為什麼分得這麼清楚">為什麼分得這麼清楚</h3>
<p>想像系統 B 收到一個請求：</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">B 收到請求「給我會員 X 的資料」
</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">B 自問：這請求來自...
</span></span><span class="line"><span class="ln">4</span><span class="cl">   ├─ 我的合作夥伴系統 A？  → 可進入授權判斷
</span></span><span class="line"><span class="ln">5</span><span class="cl">   ├─ 未註冊的外部 caller？ → 回 401 / 403
</span></span><span class="line"><span class="ln">6</span><span class="cl">   └─ 偽裝成 A 的 caller？  → 回 401 / 403 並記錄告警</span></span></code></pre></div><p>純粹靠 Layer 1 的使用者 token 只能證明「這位 user 的身分」，無法證明「系統 A 的身分」。這個分工讓帳號被盜與合作系統被冒用分別走不同監控與撤銷流程。</p>
<h3 id="shared-secret與api-key的關係">「Shared Secret」與「API Key」的關係</h3>
<p>兩者常被混用、實際上是同一個機制（一邊發、一邊存的對稱字串）的不同部署方式：</p>
<table>
  <thead>
      <tr>
          <th>區分點</th>
          <th>Shared Secret</th>
          <th>API Key</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Caller identity</td>
          <td>兩邊都用同一把、沒有 caller 區分</td>
          <td>每個 client 一把、server 有 key → identity 對照表</td>
      </tr>
      <tr>
          <td>撤銷粒度</td>
          <td>換一邊、全部斷</td>
          <td>撤一把 key、只影響該 client</td>
      </tr>
      <tr>
          <td>典型部署</td>
          <td>內部固定夥伴系統</td>
          <td>對外開放 API、多 tenant</td>
      </tr>
  </tbody>
</table>
<p>下面討論的「Shared Secret」泛指這個 pattern；要做 per-client identity 與 revoke 時、改成 API Key 結構即可。</p>
<h3 id="常見方案的取捨">常見方案的取捨</h3>
<table>
  <thead>
      <tr>
          <th>方案</th>
          <th>機制</th>
          <th>撤銷粒度</th>
          <th>適合情境</th>
          <th>主要代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Shared Secret</strong></td>
          <td>兩邊放同一把字串</td>
          <td>全部 caller</td>
          <td>內部單一夥伴、低變更頻率</td>
          <td>多 client 時撤銷會牽動所有人</td>
      </tr>
      <tr>
          <td><strong>API Key</strong></td>
          <td>每個 client 一把、server 有對照表</td>
          <td>per-client</td>
          <td>對外開放、多 tenant</td>
          <td>server 需維護 key → identity mapping</td>
      </tr>
      <tr>
          <td><strong>HMAC 簽章</strong></td>
          <td>client 用 secret 簽 request body</td>
          <td>per-key</td>
          <td>secret 不想經過網路、需防 replay / 改寫</td>
          <td>兩邊都要實作簽章邏輯、debug 較難</td>
      </tr>
      <tr>
          <td><strong>mTLS</strong></td>
          <td>雙向 TLS 憑證</td>
          <td>撤憑證</td>
          <td>金融、醫療、零信任網路</td>
          <td>憑證生命週期管理複雜、CA / CRL 基礎建設成本</td>
      </tr>
      <tr>
          <td><strong>OAuth Client Credentials</strong></td>
          <td>client_id + secret 換短期 access token</td>
          <td>撤 long-lived secret、短 token 自然 expire</td>
          <td>跨組織、權限粒度需要、需配合 scope</td>
          <td>多一層 token endpoint、實作成本較高</td>
      </tr>
  </tbody>
</table>
<p>選擇預設值的判斷：純內部固定夥伴可從 Shared Secret 起步；對外或多 client 直接上 API Key；公網跨組織 + 需要短期撤銷上 OAuth Client Credentials；合規或高威脅環境用 mTLS。</p>
<p>mTLS 的 CA 階層、憑證生命週期、撤銷機制、nginx / service mesh 整合見 <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>。</p>
<h3 id="shared-secret-的隱形成本">Shared Secret 的隱形成本</h3>
<p>Shared Secret 部署簡單、但維運上有幾個固定痛點：</p>
<ul>
<li><strong>無法 per-caller 撤銷</strong> — 一旦洩漏，所有用這把 secret 的 client 都得換</li>
<li><strong>輪替需要兩邊同步</strong> — 任何一邊忘了更新就斷線、需要「雙密過渡期」讓兩邊有時間切換。具體實作見 <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></li>
<li><strong>常被放進 query param</strong> — 為了簡便、會留在 nginx access log、CDN log、瀏覽器 history 裡。應放在 request header（例如 <code>X-System-Secret: xxx</code>）或走 HMAC / OAuth</li>
</ul>
<h3 id="信任邊界-1">信任邊界</h3>





<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">[ 系統 A ] ═════════▶ [ 系統 B ]
</span></span><span class="line"><span class="ln">2</span><span class="cl">       shared secret
</span></span><span class="line"><span class="ln">3</span><span class="cl">       (server-to-server, server-only credential)</span></span></code></pre></div><p><strong>Layer 2 secret 的安全邊界是 server-side runtime</strong>。一旦進入瀏覽器或行動 app，攻擊者就能透過反編譯、JS source map、devtools network panel 等管道取得；取得後即可假冒系統 A 呼叫系統 B。Mobile app 的反編譯工具（jadx、Hopper、Ghidra 等）讓這個攻擊成本極低，obfuscation 只能增加時間成本。</p>
<p>如果 client 端需要呼叫 B，安全路由是讓 client 先呼叫 A，由 A 在 server 端用 Layer 2 secret 呼叫 B（A 當 proxy / BFF）；另一條路是用 OAuth 把 short-lived token 發給 client，long-lived secret 留在 server。</p>
<hr>
<h2 id="layer-3跨系統-provisioning身分對應-workflow不是新的信任邊界">Layer 3：跨系統 Provisioning（身分對應 workflow、不是新的信任邊界）</h2>
<p><strong>回答的問題</strong>：「系統 A 的使用者 X、在系統 B 對應到哪個身分？」</p>
<p><strong>Layer 3 跟 Layer 1 / 2 在概念上不對等</strong> — Layer 1 / 2 是「驗證某個身分」的信任邊界、各自需要獨立的 secret 機制；Layer 3 不引入新的 secret、是「<strong>讓兩個系統的使用者身分對應上</strong>」的 workflow。它建立在 Layer 1（A 已驗證使用者）跟 Layer 2（A 已被授權呼叫 B）之上、不取代任何一層。</p>
<p>之所以仍放進「層」的編號系統、是因為實際 API 串接時、開發者會把它跟前兩層一起遇到、必須在同一個心智模型裡處理。但設計時要清楚意識到：<strong>Layer 3 的失敗模式是「身分對不上」、不是「身分被偽造」</strong>、跟 Layer 1 / 2 的安全失敗模式不同。</p>
<h3 id="為什麼需要-provisioning">為什麼需要 provisioning</h3>
<p>當 A 跟 B 是兩個獨立 service 時，「<strong>A 的使用者 X</strong>」跟「<strong>B 的使用者 X</strong>」未必是同一筆資料。可能：</p>
<ul>
<li>B 從來沒見過 X 這個人</li>
<li>B 有自己對 X 的 record、但跟 A 不同 schema</li>
<li>B 看過 X、但兩邊的 user_id 還沒對應上</li>
</ul>
<p>需要一個機制把兩邊綁定 — 這個動作叫 <strong>provisioning</strong>。</p>
<h3 id="eager-vs-lazy-兩種策略">Eager vs Lazy 兩種策略</h3>
<p>Provisioning 策略的判斷核心是「何時承擔跨系統建檔成本」。Eager 把成本前移到註冊流程，Lazy 把成本延後到第一次使用；兩者差異不只是效能，而是資料膨脹、首用體驗與文件契約的取捨。</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">EAGER (註冊時就跨系統建檔)
</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">使用者註冊系統 A
</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">   A 新增會員 row
</span></span><span class="line"><span class="ln">6</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">7</span><span class="cl">   A ──同步呼叫──▶ B.createUser()  ← 即使他可能永遠不用 B
</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">   兩邊都有資料、可以立刻呼叫 B 的 API</span></span></code></pre></div><p>Eager 適合大多數使用者都會用到 B 功能、且首用延遲成本高的服務。主要風險是 B 會累積大量低活躍 user，schema migration、備份與隱私刪除流程都會被放大。</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">LAZY (第一次需要時才建)
</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">使用者註冊系統 A
</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">   A 新增會員 row              ← 只有 A 這邊
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">   ...日後可能很久才用到 B...
</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">使用者第一次需要 B 的功能
</span></span><span class="line"><span class="ln">10</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">11</span><span class="cl">   呼叫 A 的「provision」endpoint
</span></span><span class="line"><span class="ln">12</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">13</span><span class="cl">   A ──呼叫──▶ B.findOrCreateUser()  ← 這時候才建
</span></span><span class="line"><span class="ln">14</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">15</span><span class="cl">   之後就跟 eager 一樣</span></span></code></pre></div><p>Lazy 適合只有一部分使用者會用到 B 功能、且第一次使用可以接受一次 provisioning 延遲的服務。主要風險是「第一次使用」這個時機需要被寫進文件、SDK 或錯誤碼，否則接手者會把 B 的 404 誤判成 request 格式或權限問題。</p>
<h3 id="lazy-的隱性-api-依賴順序">Lazy 的「隱性 API 依賴順序」</h3>
<p>Lazy provisioning 的最大成本是<strong>隱性依賴順序造成的認知負擔</strong>：</p>
<ul>
<li>文件若沒有寫清楚「呼叫 B 前先呼叫 A 的 provision endpoint」，接手者會在「B 回 404 找不到 user」的訊號上花大量時間排查</li>
<li>用 SDK 包裝可以把 provision 自動處理、對外只暴露單一 API</li>
<li>不用 SDK 時，文件需要在快速上手與錯誤碼段落顯眼註明這個依賴順序</li>
</ul>
<p>折衷做法：B 的 API 在第一次發現 user 不存在時、<strong>主動回一個 <code>PROVISIONING_REQUIRED</code> 錯誤碼</strong>、client 看到就知道要去呼叫 A 的 provision endpoint。比起靜默 500 或單純 404 更能引導 client 走到正確流程。</p>
<h3 id="信任邊界示意">信任邊界示意</h3>





<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">[ 使用者 ] ──Layer 1──▶ [ 系統 A ] ══Layer 2══▶ [ 系統 B ]
</span></span><span class="line"><span class="ln">2</span><span class="cl">                            │  Layer 3 workflow：
</span></span><span class="line"><span class="ln">3</span><span class="cl">                            └─ 觸發後在 B 建立對應身分</span></span></code></pre></div><p>Layer 3 不引入新的 secret、是「<strong>建立兩邊身分關聯</strong>」的 lifecycle 動作。它依賴 Layer 1（確認使用者身分）跟 Layer 2（A 被授權對 B 發指令）。沒有 Layer 1 / 2 的話、provisioning 自己無法獨立成立。</p>
<hr>
<h2 id="三層怎麼組合">三層怎麼組合</h2>
<p>把三層擺在一起的典型 request 流程：</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">        ┌─────────────┐                       ┌──────────────┐
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">        │  使用者      │                       │   系統 A     │
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        │  (Browser/  │ ──── Layer 1 ──────▶ │              │
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        │   App)      │      Bearer token     │              │
</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">                                                     │
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">                                            Layer 3  │ Provision
</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">                                              ┌──────────────┐
</span></span><span class="line"><span class="ln">11</span><span class="cl">                                              │   系統 B     │
</span></span><span class="line"><span class="ln">12</span><span class="cl">                                              └──────────────┘
</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">                                                     │
</span></span><span class="line"><span class="ln">15</span><span class="cl">                                            Layer 2  │ Shared secret
</span></span><span class="line"><span class="ln">16</span><span class="cl">                                                     │ (server-to-server)</span></span></code></pre></div><p>每一條線都是一層信任邊界，各自需要不同 secret 機制保護。</p>
<hr>
<h2 id="設計時最常見的三個失效模式">設計時最常見的三個失效模式</h2>
<h3 id="失效模式一讓使用者-token-也能驗-layer-2">失效模式一：讓使用者 token 也能驗 Layer 2</h3>
<p><strong>責任分工</strong>：「使用者身分」跟「呼叫系統身分」是兩個獨立維度、各自需要獨立 credential。系統 B 對「來自 A」的信任應綁定在系統層 credential，而不是任何單一使用者帳號上。</p>
<p><strong>常見誤用</strong>：B 接受「只要 request 帶有任一合法使用者 token 就放行」。</p>
<p><strong>風險判讀</strong>：這會把系統信任降階為使用者信任。任一帳號被盜（釣魚、密碼洩漏、token 外流）時，攻擊者就能用該使用者身分對 B 發 request，執行 B 開放給 A 的系統操作。</p>
<p><strong>操作路由</strong>：使用者層用 Layer 1 token，系統層用 Layer 2 credential，兩層都通過才放行。</p>
<h3 id="失效模式二把-layer-2-secret-放進-client">失效模式二：把 Layer 2 secret 放進 client</h3>
<p><strong>責任分工</strong>：Layer 2 secret 是「server 代表系統 A 對外的證明」，應留在 server 端的受信任執行環境。</p>
<p><strong>常見誤用</strong>：把 shared secret 寫進前端 JS、行動 app 編譯時、甚至 git public repo。</p>
<p><strong>風險判讀</strong>：client 環境（瀏覽器、mobile app）不在受控範圍。JS source 可在 devtools 直接看，mobile binary 可被反編譯出字串。Obfuscation 提高的是時間成本，沒有改變 secret 已散佈到不受信任環境的事實。</p>
<p><strong>操作路由</strong>：client 需要 B 的功能時，走「client → A → B」，由 A 在 server 端用 Layer 2 secret 呼叫 B；或用 OAuth 把 short-lived token 發給 client，long-lived secret 留在 server。</p>
<h3 id="失效模式三layer-3-依賴順序沒文件化">失效模式三：Layer 3 依賴順序沒文件化</h3>
<p><strong>責任分工</strong>：跨系統依賴順序是 API 契約的一部分，屬 publisher 的責任，需要在文件、SDK 或錯誤訊號中顯式表達。</p>
<p><strong>常見誤用</strong>：「呼叫 B 之前要先呼叫 A 的某個 endpoint」這個前置條件只存在於原始設計者的記憶中、文件沒寫、SDK 沒包、B 失敗時也只回 generic error。</p>
<p><strong>風險判讀</strong>：接手者看到「呼叫 B 失敗」時，會優先檢查 B 的文件、request 格式與 network 層。若真正根因是尚未呼叫 A 的 provision endpoint，偵錯路徑會被導到錯誤層級。</p>
<p><strong>操作路由</strong>（任選其一、優先序由上而下）：</p>
<ol>
<li>SDK 包裝、自動處理 provision、對外只暴露單一 API</li>
<li>B 主動回 <code>PROVISIONING_REQUIRED</code> error code、引導 client 補上前置呼叫</li>
<li>文件在「快速上手」段顯眼處註明依賴順序</li>
</ol>
<hr>
<h2 id="何時可以簡化三層">何時可以簡化三層</h2>
<p>三層框架的設計重點是「跨系統身分與 credential 分工」。當某一層回答的問題在架構裡不存在，設計可以縮小到實際存在的身分問題。</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>簡化方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單體 application（沒有跨系統呼叫）</td>
          <td>只需 Layer 1。沒有 system-to-system 互動、Layer 2 / 3 不存在</td>
      </tr>
      <tr>
          <td>內網微服務、共用 identity provider</td>
          <td>Layer 1 透過 service mesh 或共用 token 傳遞、Layer 2 可用 service mesh 內建 mTLS 取代手動 secret 管理</td>
      </tr>
      <tr>
          <td>後端 cron / batch job 之間互呼</td>
          <td>只需 Layer 2（system-to-system credential）、沒有使用者觸發、Layer 1 不適用</td>
      </tr>
      <tr>
          <td>兩個系統共用同一份 user DB</td>
          <td>可省略 Layer 3（身分天然對應），但 Layer 1 / 2 仍各自獨立</td>
      </tr>
  </tbody>
</table>
<p>簡化的判準是「<strong>該層回答的問題是否真實存在於這個架構</strong>」。單體 application 沒有跨系統呼叫時，Layer 2 的 caller 驗證可以省略；兩個系統共用同一份 user DB 時，Layer 3 的身分對應 workflow 可以省略。</p>
<p>簡化不等於降低基礎安全前提。HTTPS / TLS 與 token 儲存原則（hash + constant-time）是任何 Layer 1 的最低要求，跟「層」的數量無關。</p>
<hr>
<h2 id="收尾">收尾</h2>
<p>兩層信任邊界 + 一個身分對應 workflow：</p>
<ul>
<li><strong>Layer 1（使用者）</strong>：解決「你是誰」 — 用 Bearer Token、注意 capability credential 的暴露成本</li>
<li><strong>Layer 2（系統）</strong>：解決「哪個系統呼叫的」 — 用 Shared Secret / API Key / OAuth / mTLS、secret 不離 server</li>
<li><strong>Layer 3（Provisioning workflow）</strong>：解決「兩邊身分怎麼對上」 — 不是新的 secret、是 lifecycle 動作</li>
</ul>
<p>設計後端 API 時，先把這三個問題分開，secret 機制的選擇會變清楚。若排障訊號是「這個 token 在那邊不能用」，下一步是先判斷它卡在使用者層、系統層，還是 provisioning workflow。</p>
<h3 id="各層的深入文章">各層的深入文章</h3>
<p>本文聚焦「為什麼要分層」的心智模型、各層的具體實作細節都另有獨立文章：</p>
<ul>
<li><strong>Layer 1（使用者）</strong> → <a href="/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/" data-link-title="Laravel Sanctum 的 Bearer Token 設計剖析：{PK}|{secret} 為什麼這樣設計" data-link-desc="Laravel Sanctum `{PK}|{secret}` 格式的設計理由、hash 儲存取捨、constant-time 比對位置，以及跟 GitHub PAT、Stripe API Key 的差異。">Laravel Sanctum 的 Bearer Token 設計剖析</a>：<code>{PK}|{secret}</code> format 為什麼這樣設計、DB 儲存三原則、各語言 constant-time 函式對照、跟 GitHub / Stripe 的設計比較</li>
<li><strong>Layer 2（系統）→ Shared Secret 維運</strong> → <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>：雙密過渡期、自動化 rotation 工具（AWS Secrets Manager / Vault / GCP）、緊急 vs 定期流程、多 client 同步難題</li>
<li><strong>Layer 2（系統）→ mTLS 部署</strong> → <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>：CA 階層、憑證生命週期、撤銷機制（CRL / OCSP / short-lived）、nginx / Envoy / service mesh 整合</li>
</ul>
<h3 id="沒展開的延伸議題">沒展開的延伸議題</h3>
<p>JWT 的簽章演算法選擇、<code>alg: none</code> 攻擊、token rotation 的具體實作、零信任網路下的 service-to-service 認證、OAuth flow 的完整 lifecycle、SSO（SAML / OIDC）跟本文三層的對應關係。每個都值得獨立成篇、本文聚焦在「先把層數想清楚」這個前置問題。</p>
]]></content:encoded></item><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>