Sanctum PAT 這篇要解決什麼

Sanctum PAT 的核心設計是把「找 row」與「比對 secret」拆成兩個責任。Laravel Sanctum 的 Personal Access Token(簡稱 PAT)長這樣:

11|abc123def456ghi789jkl012mno345pqr678stu
2↑           ↑
3DB 主鍵     真正的祕密

豎線前的數字是 personal_access_tokens 資料表的 primary key、豎線後是高熵隨機字串。這個設計在 Laravel 生態裡很常見、但常被誤解為「業界標準 token 格式」 — 實際上是 Sanctum 特定的設計選擇、跟 GitHub PAT(ghp_...)、Stripe API Key(sk_live_...)的設計取捨完全不同。

本文拆解 Sanctum PAT 三個關鍵設計決策:

  1. 為什麼把 PK 公開放進 token
  2. DB 為什麼只存 hash 不存原文
  3. constant-time 比對為什麼放在應用層、不放在 DB

讀完後,你可以用 token 的傳播範圍、撤銷需求與洩漏偵測需求,判斷自己的 application 適合 Sanctum 風格還是其他 token format,並把 hash 儲存與 constant-time 比對原則套用到非 Laravel 環境。

本文位置:本文是 API 認證的三層信任邊界 Layer 1 的深入篇。主文聚焦「為什麼要分層」的心智模型、本文聚焦「Sanctum 這個特定實作怎麼設計、為什麼」。


Sanctum 在 Laravel 認證生態的位置

Laravel 官方提供三套認證套件、各自解的問題不同:

套件解的問題Token 機制
Laravel Breezeserver-rendered 應用的登入註冊 startersession cookie
Laravel SanctumSPA / mobile app / 簡單 API token 認證session cookie + PAT({PK}|{secret}
Laravel Passport完整 OAuth 2.0 server 實作JWT-based access token

Sanctum 的設計目標是「比 Passport 簡單、比手刻 token 嚴謹」 — 不引入 OAuth 的完整 flow,但解決 token issue、storage、revoke 的常見坑。{PK}|{secret} 是這個設計目標下的具體 trade-off。


設計決策一:為什麼把 PK 公開放進 token

驗證 token 的兩個責任

Server 收到 client 傳來的 token、要做兩件事:

  1. 找到 DB 裡對應的 row(這個 token 是哪個 user 的)
  2. 比對 確認 token 沒被偽造

如果 token 只是純隨機字串(沒有 PK 前綴),validation 的 SQL 常會被設計成:

1SELECT * FROM personal_access_tokens WHERE token = ?

這要求 token 欄位有 index,且 server 要讓 DB 同時負責 lookup 與 secret 比對。效能通常不是瓶頸,真正的設計問題是 secret 比對落在應用層控制範圍之外。

DB 比對的 timing 不可控

DB 查詢適合處理索引搜尋,不適合承擔機密字串的 timing-safe 比對。當 WHERE token = ? 在 DB 執行時,執行時間可能洩漏:

  • B-tree index 的查找路徑長度(同 prefix 的 row 多時、走的 page 不同)
  • 字串比對的短路行為(多數 DB 引擎不保證 constant-time 比對)
  • Buffer pool hit / miss 造成的時間差

攻擊者透過大量探測,可能推斷出有效 token 的部分結構。雖然實務上利用這個 leak 攻擊成本很高,但更穩健的設計原則是:安全機制應放在 application 能明確控制的比對函式,而不是依賴 DB 引擎的實作細節。

Sanctum 的解法:用 PK 收斂搜尋、把比對搬到應用層

{PK}|{secret} 的設計把驗證拆成兩步:

 1client 傳來: "1|abc123..."
 2 3   server 拆解
 4 5   ┌──────────────┐
 6   │ PK = 1       │ ──→ SELECT * FROM tokens WHERE id = 1
 7   │ secret = abc │      (O(log N)、行為穩定)
 8   └──────────────┘
 910   拿到該 row 的 hash
1112   hash_equals(stored_hash, sha256(secret))
1314   constant-time 比對、不洩漏 timing

關鍵在於 DB 只負責「找到單一 row」、不負責「比對機密」

動作由誰處理為什麼
用 PK 找到 rowDB(O(log N))PK 是公開資訊、即使 timing 洩漏也沒安全意義
比對 secret hash 是否相等應用層 constant-time在控制範圍內、可保證不依輸入內容變化執行時間

常見誤解:「PK 讓查詢變 O(1)」

PK 前綴的主要價值是安全責任切分,不是把查詢從慢變快。很多 Sanctum 教學文章寫「PK 把查詢變 O(1)、避免 full scan」,這個說法忽略了 hash 欄位也能被索引:

  • hash 欄位也能 indexWHERE token_hash = ? 用 B-tree index 是 O(log N)、不是 full scan
  • 兩條路都是 B-tree index lookup — token 規模下都不會是效能瓶頸;clustered(PK)跟 secondary(hash)的 IO cost 微差在多數場景可忽略

PK 設計的主要價值在安全可預測性、效能差距在多數場景可忽略:把比對機密的責任明確劃在「應用層 constant-time 函式」、不依賴 DB 引擎不保證的 timing 行為。

效能差異反而出現在「hash 欄位是否要 index」 — 如果用 hash lookup、token_hash 欄位需要 unique index、寫入成本變高;用 PK lookup、token_hash 不需要 index、寫入更輕量。但這在 token 規模通常不是 bottleneck。


設計決策二:DB 只存 hash 的威脅模型

威脅模型:DB 被攻陷

Token 是 capability credential — 持有即授權。如果 DB 直接存 plaintext token、任何能讀取 DB 的人(SQL injection、備份外流、運維 dump 不小心 push 到 GitHub)都能直接拿 token 假冒使用者發 request。

Sanctum 的做法:

1// 發放 token
2$plaintext = Str::random(40);  // Sanctum 預設 40 char、base62 字元集
3$hash = hash('sha256', $plaintext);
4DB::table('personal_access_tokens')->insert([
5    'token' => $hash,           // DB 只存 hash
6    'tokenable_id' => $userId,
7]);
8return $tokenId . '|' . $plaintext;  // 只此一次回給 client、之後再也拿不到

意義:DB 被 dump 時,攻擊者拿到的是不可直接使用的 hash。攻擊者要還原 plaintext 需要對 SHA-256 做 preimage attack;對 40 字元高熵隨機字串而言,計算成本實務上不可行。

SHA-256 與 bcrypt 的適用差異

密碼儲存用 bcrypt / Argon2 是因為密碼通常熵低(人類記得住的東西、entropy 通常 < 40 bit)、要刻意慢、抵抗 offline brute-force。

Token 是高熵隨機字串(40 char base62 ≈ 238 bit entropy、比一般人類記得住的 password 高約 6 個數量級的熵)— 攻擊者就算拿到 hash、暴力枚舉 plaintext 的搜尋空間是 62^40 ≈ 10^71、宇宙年齡內試不完。在這個前提下:

演算法處理時間(每次驗證)對 token 是否合理
SHA-256~微秒完全足夠
bcrypt(cost=12)~250ms浪費 CPU、無增益

在高熵 token 的前提下,SHA-256 的速度是優點,因為每次 API request 都需要驗證 token。bcrypt 的慢速設計主要服務低熵 password,套到高熵 token 會增加延遲而沒有對應的安全收益。

Salt 的適用邊界

bcrypt 用 salt 是為了防 rainbow table 攻擊(預算好常見密碼的 hash、查表)。Rainbow table 對「人類選的密碼」有效、對「40 char 高熵 token」無效(搜尋空間太大、預算表的成本超過直接 brute-force)。

所以 Sanctum 對 token 用 unsalted SHA-256,是符合「高熵隨機 token」威脅模型的選擇。若 credential 來源改成人類可記憶密碼,威脅模型就會改變,儲存策略也要回到 password hashing。


設計決策三:constant-time 比對放在應用層

Constant-time 比對在解什麼

==strcmp 比對字串時、會「短路」 — 一發現不同就回傳 false:

1// 偽程式碼:strcmp 的典型實作
2for (i = 0; i < len; i++) {
3    if (a[i] != b[i]) return false;  // ← 在這裡 return、不跑完
4}
5return true;

攻擊者可量測「server 從收到 request 到回 401」的時間、推斷「前幾個 byte 是對的」:

嘗試的 token跑了幾個 byte 才 returnserver 回應時間
aaaaaaaa...1(第 1 byte 就錯)~1 μs
1aaaaaaa...2(第 2 byte 才錯)~2 μs
1a aaaaa...3~3 μs

實務上單次 request 的網路抖動遠大於這幾 μs、但攻擊者可重複幾百萬次取平均、把雜訊濾掉、最終推出整個 hash。這就是 timing attack

Constant-time 函式的實作策略

Constant-time 比對的核心是「不論輸入長什麼樣、都跑完整個比對長度」:

1// 偽程式碼:constant-time 比對
2result = 0;
3for (i = 0; i < len; i++) {
4    result |= a[i] ^ b[i];  // 用 XOR 累積差異、不 return
5}
6return result == 0;

每次呼叫都跑完整個 loop、結果用 bitwise OR 累積、最後一次性比對。執行時間不依輸入內容變化。

各語言的 constant-time 比對函式

語言函式注意事項
PHPhash_equals($known, $user_input)第一個參數要是 known、第二個是 user input
Pythonhmac.compare_digest(a, b)也可用 secrets.compare_digest
Gosubtle.ConstantTimeCompare(a, b)回傳 int (0 / 1)、不是 bool
RubyActiveSupport::SecurityUtils.secure_compare(a, b)Rails;純 Ruby 用 OpenSSL.fixed_length_secure_compare
JavaMessageDigest.isEqual(a, b)Java 6+ 保證 constant-time
Node.jscrypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))兩個 Buffer 長度必須相同、否則 throw

失效模式:用 =====strcmpString.equals 比對 hash,會讓執行時間受到第一個不同 byte 的位置影響。判讀訊號是驗證邏輯直接使用語言的一般字串相等運算;下一步路由是改用標準庫或框架提供的 constant-time 函式。

為什麼不放在 DB 層

DB 引擎大多不保證 constant-time 比對。MySQL、PostgreSQL 的字串比對為了效能,底層仍可能走短路邏輯;因此「WHERE hash = ?」即使加 index,也不適合被當成 timing-safe 的安全邊界。

Sanctum 的設計把 secret 比對完全搬到應用層用 hash_equals — DB 只負責「用 PK 找到單一 row」、應用層負責「比對 hash」。職責清楚、安全可預測。


Sanctum vs GitHub PAT vs Stripe API Key

三者都是 opaque token(隨機字串、server lookup)、但 format 設計取捨完全不同:

維度Sanctum {PK}|{secret}GitHub ghp_xxxStripe sk_live_xxx
找到 row 的方式用 PK lookup用 hash lookup用 hash lookup
格式可辨識性低(看起來像一般字串)高(ghp_ 前綴)高(sk_live_ / sk_test_ 前綴)
洩漏掃描困難容易(GitHub 自己 scan 公開 repo)容易(Stripe webhook scan)
Token type 辨識需查 DB從前綴直接知道(user / app / OAuth)從前綴直接知道(live / test、public / secret)
適合場景單一 Laravel app 內部使用對外開放、需要洩漏偵測對外開放、多環境(live / test)

各自的設計動機

Sanctum:使用情境是「單一 Laravel application 自己發、自己驗」。Token 不會散落在公開 repo(除非開發者犯錯)、洩漏偵測不是首要需求。把 PK 直接放進 token、換 timing 安全與設計簡潔。

GitHub PAT:使用情境是「使用者把 token 寫進 CI config、push 到 public repo」。GitHub 把 ghp_ 前綴標準化、自家服務(Push Protection、Secret Scanning)會主動 scan 公開 repo、發現 ghp_... pattern 就通知 user 並 revoke。Token 的可辨識性是洩漏偵測 infrastructure 的一環、不是浪費字元。

Stripe API Key:使用情境跨 live 跟 test 環境、且有 public / secret 兩種 key。前綴設計:

  • sk_live_ — secret key、live 環境(會收真錢)
  • sk_test_ — secret key、test 環境
  • pk_live_ — publishable key、live 環境(可放 client)
  • pk_test_ — publishable key、test 環境

工程師看一眼就知道「這把 key 能幹嘛」、避免把 live key 寫進 test config。

怎麼選

你的場景建議設計
單一 Laravel app、token 只內部用Sanctum 預設即可
對外開放 API、token 會散落第三方環境學 GitHub / Stripe 加 prefix
多環境(dev / staging / prod)容易誤用加環境 prefix(如 _live_
多 token type(user / bot / OAuth)加 type prefix

表格的判準是 token 會不會離開受控環境。單一 Laravel app 內部使用時,Sanctum 的 PK 前綴足以支撐 lookup 與撤銷;對外 API、第三方整合或多環境部署時,prefix 可提供洩漏掃描與人工辨識訊號。也可以混用成 {prefix}|{PK}|{secret},同時保留 lookup 收斂與語意辨識。


在非 Laravel 環境怎麼套用

Sanctum 的三個原則跨語言通用:

  1. DB 只存 hash — 用任何語言的 SHA-256 / SHA-512 即可。Python: hashlib.sha256、Go: crypto/sha256、Node: crypto.createHash('sha256')
  2. Lookup 用穩定字段 — 把「找到 row」跟「比對機密」分開、WHERE id = ? 是穩定的、WHERE hash = ? 在 timing 上不可控
  3. 應用層 constant-time 比對 — 用本文上面表格列的函式、絕不用 ==

非 Laravel 框架的等效實作:

 1# Python + SQLAlchemy 範例
 2import secrets, hashlib, hmac
 3
 4def issue_token(user_id):
 5    plaintext = secrets.token_urlsafe(32)
 6    hash_value = hashlib.sha256(plaintext.encode()).hexdigest()
 7    token = PersonalAccessToken(user_id=user_id, hash=hash_value)
 8    db.session.add(token)
 9    db.session.commit()
10    return f"{token.id}|{plaintext}"  # 只此一次回給 client
11
12def verify_token(raw_token):
13    # production 範例需多一層 try-except 涵蓋 int() 轉型與 DB 例外
14    try:
15        token_id, plaintext = raw_token.split('|', 1)
16        token = PersonalAccessToken.query.get(int(token_id))
17    except (ValueError, TypeError):
18        return None
19    if not token:
20        return None
21    expected_hash = hashlib.sha256(plaintext.encode()).hexdigest()
22    if not hmac.compare_digest(token.hash, expected_hash):
23        return None
24    return token.user
 1// Go + sqlx 範例
 2func IssueToken(ctx context.Context, userID int64) (string, error) {
 3    plaintext := generateRandomString(40)
 4    hash := sha256.Sum256([]byte(plaintext))
 5    var tokenID int64
 6    err := db.QueryRowContext(ctx,
 7        "INSERT INTO personal_access_tokens (user_id, hash) VALUES ($1, $2) RETURNING id",
 8        userID, hex.EncodeToString(hash[:]),
 9    ).Scan(&tokenID)
10    if err != nil {
11        return "", err
12    }
13    return fmt.Sprintf("%d|%s", tokenID, plaintext), nil
14}
15
16func VerifyToken(ctx context.Context, raw string) (*Token, error) {
17    parts := strings.SplitN(raw, "|", 2)
18    if len(parts) != 2 {
19        return nil, ErrInvalidFormat
20    }
21    tokenID, err := strconv.ParseInt(parts[0], 10, 64)
22    if err != nil {
23        return nil, ErrInvalidFormat
24    }
25    var token Token
26    err = db.GetContext(ctx, &token, "SELECT * FROM personal_access_tokens WHERE id = $1", tokenID)
27    if err != nil {
28        return nil, err
29    }
30    expectedHash := sha256.Sum256([]byte(parts[1]))
31    storedHash, _ := hex.DecodeString(token.Hash)
32    if subtle.ConstantTimeCompare(storedHash, expectedHash[:]) != 1 {
33        return nil, ErrInvalidToken
34    }
35    return &token, nil
36}

兩者的關鍵都是:SELECT WHERE id = ? + 應用層 compare_digest / ConstantTimeCompare、不依賴 DB 比對 hash。


收尾

Sanctum 的 {PK}|{secret} 是一個特定情境下的設計取捨,不是業界通用標準:

  • 它假設 token 不會散落到公開環境、所以不需要 prefix-based 洩漏偵測
  • 它把比對機密的責任明確劃在應用層、不依賴 DB 引擎的 timing 行為
  • 它用 SHA-256 + 不加 salt、因為 token 高熵時這個選擇符合威脅模型

如果你的場景符合這些假設,Sanctum 的設計可以直接使用。若場景是對外 API、需要洩漏偵測、多環境或多 token type,prefix-based format 會提供更好的操作訊號;儲存原則(hash + constant-time)則跨設計通用。

延伸閱讀: