快轉到主要內容
  1. 教學文章/

Python secrets 實戰:安全產生 Token、密碼與一次性連結

·7 分鐘· loading · loading · ·
Python Secrets Security Token Standard-Library Web
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 62: 本文

一. 前言:不要用 random 產生密碼
#

寫程式時很容易遇到這種需求:

  • 幫使用者產生重設密碼連結
  • 幫 API client 發一組臨時 token
  • 產生一次性驗證碼
  • 建立邀請連結
  • 做一組測試用但看起來合理的密碼

很多人第一直覺會寫:

import random
import string

alphabet = string.ascii_letters + string.digits
token = "".join(random.choice(alphabet) for _ in range(32))
print(token)

拍拍君先把這段收起來。 這不是語法錯誤,但它不適合拿來做安全用途。

Python 的 random 模組是給模擬、抽樣、遊戲邏輯、測試資料用的。 它的輸出是偽隨機,而且設計目標不是「讓攻擊者猜不到」。 如果你把它拿來產生密碼、session token、password reset link,那就像把門鎖鑰匙印在便利貼上,再跟自己說「應該沒人會看吧」。

今天要介紹的是 Python 標準庫裡專門處理安全亂數的模組:secrets

它適合這些場景:

  • 產生 API token
  • 產生重設密碼 URL 裡的隨機片段
  • 產生臨時密碼
  • 從候選集合中安全地隨機挑選
  • 做不容易被 timing attack 觀察出差異的字串比對

如果你之前看過 python-dotenv 文章,可以把差異想成這樣:python-dotenv 解決的是「secret 要怎麼放進環境」。 今天的 secrets 解決的是「secret 一開始要怎麼安全產生」。 一個管儲存與載入,一個管生成。 兩件事都重要,但不是同一件事。

二. 安裝:標準庫內建,直接 import
#

好消息,secrets 是 Python 標準庫。 不需要安裝第三方套件。 Python 3.6 之後就可以用:

import secrets

確認版本:

python --version

今天的範例使用 Python 3.11 以上的寫法。 如果你在舊環境裡維護服務,至少確認有 Python 3.6。 但老實說,2026 年還在為新專案選 Python 3.6,拍拍君會沉默三秒。

先看最短範例:

import secrets

token = secrets.token_urlsafe(32)
print(token)

輸出會像這樣:

37fCRii8k2cLJhm-6Qw54a6pNSywks9TKp1UVgSPTrE

每次執行都不一樣,而且適合放進 URL。 這是 secrets 最常見、最實用的入口。

三. random vs secrets:差別不是「看起來亂不亂」
#

先講最重要的觀念。 亂數有很多種,使用情境也不一樣。

random 的目標是:

  • 可重現
  • 速度快
  • 適合模擬與抽樣
  • 可以設定 seed

secrets 的目標是:

  • 不容易被預測
  • 適合安全敏感資料
  • 使用作業系統提供的安全亂數來源
  • 不鼓勵你自己管理 seed

這代表如果你在做資料科學抽樣,random.seed(42) 很棒。 因為你希望結果可以重現。 但如果你在產生登入 token,seed 這件事反而很危險。 安全 token 最怕的就是「可重現」。

看一個對照:

import random

random.seed(42)
print(random.randint(1, 10_000))
print(random.randint(1, 10_000))
print(random.randint(1, 10_000))

只要 seed 一樣,輸出序列就一樣。 這對測試很方便,對安全憑證很糟。

secrets 不走這條路:

import secrets

print(secrets.randbelow(10_000))
print(secrets.randbelow(10_000))
print(secrets.randbelow(10_000))

你不用、也不該指定 seed。 它會向系統要適合安全用途的亂數。

拍拍君的簡單規則是:

  • 模擬、抽樣、洗牌、教學:用 random
  • 密碼、token、驗證碼、邀請連結:用 secrets

不要把兩個模組混著用。 尤其不要想「我先用 random 產生,再 hash 一下就安全了吧」。 通常這種想法會把問題包裝得更難看出來,但不會真的變安全。

四. 產生 URL-safe token:最常用的 API
#

網站和 API 最常見的需求,是產生一段可以放進 URL 的 token。 例如:

https://example.com/reset-password?token=<這裡>

這時候用 token_urlsafe

import secrets

reset_token = secrets.token_urlsafe(32)
print(reset_token)

這裡的 32 是 byte 數,不是最後字串長度。 因為 URL-safe token 會用 base64 類似的表示法,輸出長度通常會比 byte 數更長。

你可以包成函式:

import secrets

def make_reset_token() -> str:
    return secrets.token_urlsafe(32)

print(make_reset_token())

實務上,你不會只把 token 印出來。 你會把它存進資料庫,搭配使用者、用途、過期時間:

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import secrets

@dataclass
class ResetToken:
    user_id: int
    token: str
    expires_at: datetime
    used: bool = False

def create_reset_token(user_id: int) -> ResetToken:
    return ResetToken(
        user_id=user_id,
        token=secrets.token_urlsafe(32),
        expires_at=datetime.now(timezone.utc) + timedelta(minutes=30),
    )

reset = create_reset_token(user_id=42)
print(reset)

這裡有幾個設計重點。

第一,token 要夠長。 對密碼重設、登入連結、API key 這類敏感用途,拍拍君通常會從 32 bytes 起跳。

第二,token 要有過期時間。 永久有效的一次性連結,聽起來就很矛盾。

第三,token 用過後要標記。 不要讓同一個重設連結可以重複使用。

第四,不要把 token 放進 log。 這個很常被忽略。 Debug 時順手 print(url),然後 token 就進了 CI log、server log、error tracker。 這種事故很不華麗,但很實際。

五. token_hex 與 token_bytes:不同格式,不同用途
#

secrets 還有兩個常用函式:token_hextoken_bytes

token_hex 會回傳十六進位字串:

import secrets

api_key = secrets.token_hex(32)
print(api_key)

輸出像這樣:

f1bff5f09f1d4fbbf495d1e448d8a7a0a0df18b7f2a0a64d2415e8fb3d5a83d9

十六進位的好處是非常穩定。 它只會出現 0-9a-f,很適合放在設定檔、CLI 輸出、資料庫欄位裡。 缺點是同樣 byte 數下,字串會比較長。

token_bytes 則會回傳原始 bytes:

import secrets

raw = secrets.token_bytes(32)
print(raw)

如果你要把亂數餵給加密函式、HMAC、或其他底層 API,bytes 可能比較適合。 但如果你要給人複製貼上,通常選 token_urlsafetoken_hex

拍拍君的選擇規則:

  • 要放 URL:token_urlsafe
  • 要顯示或複製:token_hex
  • 要餵給底層加密流程:token_bytes

範例,建立 CLI 用的 API key:

import secrets

def issue_api_key(prefix: str = "pypy") -> str:
    body = secrets.token_hex(24)
    return f"{prefix}_{body}"

print(issue_api_key())

加 prefix 的好處是容易辨識來源。 很多服務會用 sk_ghp_pypy_ 這種前綴。 但注意,prefix 不是安全來源。 真正的安全性來自後面的隨機部分。

六. randbelow 與 choice:安全地從範圍或集合中挑選
#

有時候你不是要一整段 token,而是要安全地從範圍或集合裡挑一個值。

例如產生 6 位數驗證碼:

import secrets

code = f"{secrets.randbelow(1_000_000):06d}"
print(code)

randbelow(n) 會回傳 0n - 1 之間的整數。 這裡用 :06d 補零,確保永遠是 6 位數。

但驗證碼有個現實問題:6 位數空間只有一百萬種。 它不是拿來長期保護帳號的。 如果你用簡訊或 email 驗證碼,務必搭配:

  • 短過期時間
  • 嘗試次數限制
  • 速率限制
  • 使用後作廢

不要幻想 6 位數驗證碼本身很強。 它只是使用者體驗和安全性之間的折衷。

另一個常見需求是產生臨時密碼:

import secrets
import string

alphabet = string.ascii_letters + string.digits

def make_temp_password(length: int = 16) -> str:
    return "".join(secrets.choice(alphabet) for _ in range(length))

print(make_temp_password())

這裡用的是 secrets.choice,不是 random.choice

如果你在做真正的使用者密碼,最好讓使用者使用密碼管理器產生長密碼,或乾脆使用 passphrase。 臨時密碼應該只用來首次登入或重設流程,並且要求使用者立刻更換。

七. compare_digest:比對 secret 時不要太天真
#

很多程式會需要比對 token:

def is_valid(provided: str, expected: str) -> bool:
    return provided == expected

一般情況下這樣可以工作。 但在安全敏感的比對裡,字串比較可能洩漏微小的時間差。 如果攻擊者可以大量測量回應時間,就有機會慢慢猜出內容。 這類攻擊叫 timing attack。

Python 提供 secrets.compare_digest

import secrets

def is_valid(provided: str, expected: str) -> bool:
    return secrets.compare_digest(provided, expected)

它會用比較不容易洩漏時間資訊的方式比對。 你可以用在:

  • webhook signature
  • API token
  • password reset token
  • HMAC digest

範例,檢查簡單 webhook token:

import os
import secrets

EXPECTED_TOKEN = os.environ["WEBHOOK_TOKEN"]

def check_webhook_token(header_value: str | None) -> bool:
    if header_value is None:
        return False
    return secrets.compare_digest(header_value, EXPECTED_TOKEN)

這裡也順便示範一個實務搭配:真正部署時,token 通常從環境變數讀進來。 產生 token 可以用 secrets。 載入 token 可以用環境變數或 dotenv。 兩者各司其職。

八. 常見陷阱:安全問題通常輸在小地方
#

第一個陷阱:token 太短。

# 不推薦
token = secrets.token_urlsafe(4)

這看起來很亂,但空間太小。 對安全用途,除非你非常清楚威脅模型,否則不要省這幾個 byte。 儲存空間不是問題,猜中才是問題。

第二個陷阱:把 token 放進可公開的地方。

print(f"reset url: {url}")
logger.info("issued reset url %s", url)

這種 log 在本機開發時很方便。 到 production 就會變成事故素材。 比較好的做法是只記錄事件,不記錄完整 token:

logger.info("issued password reset token for user_id=%s", user_id)

第三個陷阱:把 token 當密碼長期保存。

API key、session token、reset token 都應該有生命週期。 有些可能很短,有些可能幾個月,但都應該能撤銷、能輪替、能追蹤最後使用時間。

第四個陷阱:自己發明編碼規則。

如果你要 URL-safe,直接用 token_urlsafe。 不要自己拿 bytes 轉字串、替換符號、再祈禱每個 framework 都能接受。 安全程式設計最不需要的就是「我覺得這樣應該也可以」。

第五個陷阱:把 secrets 當成密碼雜湊工具。

secrets 負責產生安全亂數,不負責儲存使用者密碼。 使用者密碼應該用專門的 password hashing 演算法,例如 Argon2、bcrypt、scrypt。 不要自己用 hashlib.sha256(password) 交差。 拍拍君看到會把鍵盤拿遠一點。

結語
#

secrets 是那種不花俏、但非常值得記住的標準庫模組。

今天的核心規則其實很短:

  • 安全用途不要用 random
  • URL token 用 secrets.token_urlsafe
  • CLI 或設定值可用 secrets.token_hex
  • 範圍取值用 secrets.randbelow
  • 集合挑選用 secrets.choice
  • 比對敏感字串用 secrets.compare_digest
  • token 要夠長、會過期、能撤銷、不要進 log

安全不是靠某一行神奇程式碼完成的。 但用對基礎工具,至少可以少踩很多不必要的坑。 下次你要產生 reset link、API key、邀請碼,先別伸手拿 random。 讓 secrets 做它該做的事。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 62: 本文

相關文章

Python tempfile 實戰:安全建立暫存檔案、目錄與測試資料
·9 分鐘· loading · loading
Python Tempfile Filesystem Testing Standard-Library Developer-Tools
Python zoneinfo 實戰:時區、DST 與排程時間處理完全攻略
·8 分鐘· loading · loading
Python Zoneinfo Datetime Timezone DST Standard-Library
Python tomllib 實戰:內建 TOML 解析、設定檔管理與 pyproject.toml 完全攻略
·7 分鐘· loading · loading
Python Tomllib TOML 設定檔 Pyproject.toml
Python tenacity 實戰:重試、退避與容錯機制完全攻略
·9 分鐘· loading · loading
Python Tenacity Retry Backoff 容錯
Python loguru 實戰:告別複雜的 logging 設定,寫出漂亮的日誌
·6 分鐘· loading · loading
Python Logging Loguru 除錯 工具
Python match/case:結構化模式匹配完全攻略
·6 分鐘· loading · loading
Python Match Pattern-Matching Python 3.10