JWT 驗證
在您的 resource server 以公鑰驗證 Signet 簽發的 access token,不用回呼 Signet。
本頁只涵蓋 access token。 ID token(
scope含openid時簽發)同樣透過 JWKS 驗證,但 claim 集合與驗證規則更嚴格 — 見 OpenID Connect。重要取捨:本機 JWT 驗證無法偵測伺服器端的撤銷或狀態變更(撤銷 / 停用的 token 在密碼學上仍然是有效的,直到過期為止)。若您需要即時撤銷生效,請在本機驗證之外抽樣或帶快取地搭配
/oauth/introspect(RFC 7662)或/oauth/tokeninfo— 絕不要每個請求都打它們來驗 JWT。兩個端點都有速率限制(tokeninfo每 IP 600 req/min;introspect每客戶端應用 600 次,再加每 IP 1200 次上限),整個 fleet 逐請求打的話會先收到429。唯一真的需要往返的憑證是不透明的sgk_個人 API 金鑰 — 請短暫快取其驗證結果。見 Token 與撤銷 與 API 金鑰。
適用情境
下列情況適合搭配 JWKS 做 JWT 驗證:
- 有 多個微服務 各自獨立驗證 token
- 想 降低 Signet 負載,不用每次都回打驗證
- 需要 離線驗證,不想依賴對 Signet 的網路
- 零信任架構 中,服務不應共享 secret
前置條件:Signet 必須設定為 RS256 或 ES256 簽章。若為 HS256(對稱),JWKS 端點存在但會回空集合,OIDC discovery 文件也會省略
jwks_uri。
演算法比較
| 演算法 | 類型 | 金鑰材料 | Token 大小 | 適用情境 |
|---|---|---|---|---|
HS256 |
對稱 | JWT_SECRET(共享 secret) |
~300 bytes | 單一服務的簡單部署 |
RS256 |
非對稱 | RSA 2048-bit 私鑰 | ~600 bytes | 廣泛生態支援,以 JWKS 分發 |
ES256 |
非對稱 | ECDSA P-256 私鑰 | ~400 bytes | 體積小,適合現代部署 |
建議:最大相容性選 RS256;想要小體積 token 選 ES256。多服務架構請避免 HS256。
運作方式
初次抓 JWKS 後,後續所有 token 驗證都在本機進行,不再呼叫 Signet。
確認您的 Signet 使用非對稱簽章
基於 JWKS 的驗證只在 Signet 設定為 RS256 或 ES256 時可行。從 OIDC Discovery 確認 — 若 jwks_uri 欄位不存在,或 /.well-known/jwks.json 回傳空 keys 陣列,表示該部署是 HS256,無法用本方式驗證。
遇到這情況,請管理員將部署切到 RS256/ES256。對稱 secret 絕對不會透過 JWKS 暴露出來。
OIDC Discovery
Resource server 從 OIDC Discovery 發現 JWKS 網址:
curl https://your-signet/.well-known/openid-configuration
{
"issuer": "https://your-signet",
"jwks_uri": "https://your-signet/.well-known/jwks.json",
"id_token_signing_alg_values_supported": ["RS256"]
}
jwks_uri只在 RS256 / ES256 啟用時出現。出現時,id_token_signing_alg_values_supported會反映設定的JWT_SIGNING_ALGORITHM(例如 ES256 時是["ES256"]),但若不支援 ID token 則可能完全省略此欄位。
JWKS 端點
curl https://your-signet/.well-known/jwks.json
RS256 回應:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "abc123...",
"alg": "RS256",
"n": "0vx7agoebGc...",
"e": "AQAB"
}
]
}
ES256 回應:
{
"keys": [
{
"kty": "EC",
"use": "sig",
"kid": "def456...",
"alg": "ES256",
"crv": "P-256",
"x": "f83OJ3D2xF1B...",
"y": "x_FEzRu9m36H..."
}
]
}
回應帶 Cache-Control: public, max-age=3600 — 可快取最多 1 小時。
HS256:JWKS 端點回傳
{"keys": []}。對稱 secret 絕不會透過 JWKS 暴露。
JWT 結構
Header:
{
"alg": "RS256",
"kid": "abc123...",
"typ": "JWT"
}
Payload:
{
"user_id": "user-uuid",
"client_id": "client-uuid",
"scope": "openid profile email",
"type": "access",
"exp": 1700000000,
"iat": 1699996400,
"iss": "https://your-signet",
"sub": "user-uuid",
"jti": "unique-token-id"
}
| Claim | 說明 |
|---|---|
user_id |
與 sub 相同 |
client_id |
索取此 token 的 OAuth 客戶端 |
scope |
空白分隔的授予 scope |
type |
您接受為 Bearer 的 token 一定是 access;refresh token 帶 type=refresh,resource server 絕對不可 接受。一律只接受 access |
aud |
Audience — 這張 token 適用的 resource server。當客戶端傳入 resource 參數(RFC 8707)時設為該值;否則回退為部署層級的 JWT_AUDIENCE。舊部署若兩者皆未設定可能無此 claim。可能是單一字串或 JSON 陣列。詳見下方 Audience Binding |
exp |
過期時間(Unix 秒) |
iat |
簽發時間(Unix 秒) |
iss |
Issuer URL(探索文件中的 issuer) |
sub |
使用者 UUID;Client Credentials token 則為 client:<client_id> |
jti |
唯一 token id(UUID) |
M2M token:當
sub以client:開頭,代表這張 token 來自 Client Credentials 流程,沒有終端使用者。若您的 API 要區別處理服務呼叫,可以依此分流。
Audience Binding (RFC 8707)
Signet 支援 Resource Indicators (RFC 8707)。當 OAuth 客戶端在 /oauth/authorize、/oauth/device/code 或 /oauth/token 帶入一個以上的 resource=<URL> 參數時,簽發當下 就把存取 token 的 aud 綁到這些值。若沒帶 resource,aud 會回退到部署層級的 JWT_AUDIENCE 設定(若有),或完全省略。
客戶端只能把
aud綁到管理員已加入該 客戶端允許資源清單 的resource(預設全部拒絕)。這代表您收到的aud同時經過客戶端 以及 營運者維護的允許清單授權 — 但您仍必須 對自己的識別字驗證aud;允許清單是簽發端的控制,不能取代 RS 端的檢查。
Resource server 必須驗 aud:
- 在 JWT 函式庫設定要求
aud等於 您自己的 resource 識別字 (例如https://api.example.com)。這是阻止「為 A 服務簽的 token 被 B 服務悄悄接受」的關鍵。
aud可能是單一字串或 JSON 陣列 — 多數 JWT 函式庫(Go 的jwt.WithAudience、PyJWT 的audience=...、jose 的audience:)兩種形狀都會處理。
- Refresh token 一律以靜態
JWT_AUDIENCE簽,不會帶每次請求的 resource。配合type=access檢查,才能阻止 refresh token 被誤當成 access token 拿到 resource server 用。請一定 兩個 claim 都檢查。
- audience binding 部署前簽發的 token 可能不含
aud。若您的部署保證會帶resource或設定了非空JWT_AUDIENCE,請嚴格要求aud;否則容許缺失但記錄起來。
ID token 不受影響 — 依 OIDC Core 1.0,其
aud仍為 OAuthclient_id。見 OpenID Connect。
驗證步驟
- 解碼 JWT header 取出
kid與alg
- 抓取 JWKS
/.well-known/jwks.json(有快取就用快取)
- 找金鑰 對應 JWT header 的
kid
- 驗簽 用對應公鑰
- 驗 claim:
exp— 未過期
iss— 對應 Signet URL
type— 必須是access(拒絕refresh)
aud— 必須包含您 resource server 的識別字 — 見 Audience Binding
- 授權檢查:驗
scope是否滿足需求;若您的 API 只允許特定客戶端,也驗client_id
程式範例
Go
使用 keyfunc 自動抓取並快取 JWKS:
package main
import (
"fmt"
"log"
"net/http"
"slices"
"strings"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
)
func main() {
jwksURL := "https://your-signet/.well-known/jwks.json"
// 建立會自動重新抓取 JWKS 的 keyfunc
k, err := keyfunc.NewDefault([]string{jwksURL})
if err != nil {
log.Fatalf("Failed to create JWKS keyfunc: %v", err)
}
http.HandleFunc("/api/resource", func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
http.Error(w, "Missing Bearer token", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(auth, "Bearer ")
// 以 JWKS 解析並驗證 JWT。
// WithAudience 要求 JWT 的 `aud` 必須包含此 resource server 的識別字 —
// Signet 在客戶端帶 RFC 8707 `resource` 時用該值;否則回退到靜態 JWT_AUDIENCE。
// 不論哪種來源,RS 端驗法都一樣。
token, err := jwt.Parse(tokenString, k.Keyfunc,
jwt.WithIssuer("https://your-signet"),
jwt.WithAudience("https://api.example.com"), // 您 resource server 的識別字
jwt.WithExpirationRequired(),
jwt.WithValidMethods([]string{"RS256", "ES256"}),
)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid token: %v", err), http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
return
}
tokenType, ok := claims["type"].(string)
if !ok || tokenType != "access" {
http.Error(w, "Invalid token type", http.StatusUnauthorized)
return
}
// 檢查 scope — 視您的 API 所需替換 "profile"。
scopeStr, _ := claims["scope"].(string)
scopes := strings.Fields(scopeStr)
if !slices.Contains(scopes, "profile") {
http.Error(w, "Insufficient scope", http.StatusForbidden)
return
}
// 使用者 token 的 sub 是 UUID;client_credentials 則為 "client:<client_id>"
subject, ok := claims["sub"].(string)
if !ok || subject == "" {
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
return
}
fmt.Fprintf(w, "Hello, %s!", subject)
})
log.Fatal(http.ListenAndServe(":8081", nil))
}
Python
使用 PyJWT 內建的 JWKS client:
import jwt
from jwt import PyJWKClient
from flask import Flask, request, jsonify
app = Flask(__name__)
SIGNET_URL = "https://your-signet"
JWKS_URL = f"{SIGNET_URL}/.well-known/jwks.json"
MY_RESOURCE_ID = "https://api.example.com" # 本 resource server 的識別字
# PyJWKClient 會自動快取 JWKS
jwks_client = PyJWKClient(JWKS_URL, cache_keys=True, lifespan=3600)
@app.route("/api/resource")
def protected_resource():
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return jsonify({"error": "Missing Bearer token"}), 401
token = auth.removeprefix("Bearer ")
try:
signing_key = jwks_client.get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256", "ES256"],
issuer=SIGNET_URL,
audience=MY_RESOURCE_ID, # 強制 RFC 8707 audience binding
options={"require": ["exp", "iss", "sub", "aud"]},
)
except jwt.InvalidTokenError as e:
return jsonify({"error": f"Invalid token: {e}"}), 401
if payload.get("type") != "access":
return jsonify({"error": "Invalid token type"}), 401
# 檢查 scope — 視您的 API 所需替換 "profile"。
scopes = payload.get("scope", "").split()
if "profile" not in scopes:
return jsonify({"error": "Insufficient scope"}), 403
return jsonify({"message": f"Hello, user {payload['user_id']}!"})
Node.js
使用 jose(零依賴):
import { createRemoteJWKSet, jwtVerify } from "jose";
import { createServer } from "node:http";
const SIGNET_URL = "https://your-signet";
const MY_RESOURCE_ID = "https://api.example.com"; // 本 resource server 的識別字
const JWKS = createRemoteJWKSet(new URL(`${SIGNET_URL}/.well-known/jwks.json`));
const server = createServer(async (req, res) => {
const auth = req.headers.authorization || "";
if (!auth.startsWith("Bearer ")) {
res.writeHead(401);
res.end(JSON.stringify({ error: "Missing Bearer token" }));
return;
}
try {
const { payload } = await jwtVerify(auth.slice(7), JWKS, {
issuer: SIGNET_URL,
audience: MY_RESOURCE_ID, // 強制 RFC 8707 audience binding
algorithms: ["RS256", "ES256"],
requiredClaims: ["exp", "sub", "aud", "scope"],
});
if (payload.type !== "access") {
res.writeHead(401);
res.end(JSON.stringify({ error: "Invalid token type" }));
return;
}
// 檢查 scope — 視您的 API 所需替換 "profile"。
const scopes = (payload.scope || "").trim().split(/\s+/).filter(Boolean);
if (!scopes.includes("profile")) {
res.writeHead(403);
res.end(JSON.stringify({ error: "Insufficient scope" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ message: `Hello, user ${payload.user_id}!` }));
} catch (err) {
res.writeHead(401);
res.end(JSON.stringify({ error: `Invalid token: ${err.message}` }));
}
});
server.listen(8081, () => console.log("Resource server on :8081"));
快取最佳實務
| 做法 | 細節 |
|---|---|
尊重 Cache-Control |
Signet 回 max-age=3600(1 小時)。不要抓得更頻繁。 |
| 使用 JWKS 函式庫 | keyfunc(Go)、PyJWKClient(Python)、jose(Node.js)都會自動快取。 |
以 kid 當鍵 |
以 kid 做 O(1) 查表。 |
遇到未知的 kid |
重抓 JWKS 一次;仍找不到才拒絕此 token。 |
| 預熱快取 | 服務啟動時先抓一次 JWKS,避免首個請求卡延遲。 |
金鑰輪替
- 產生新金鑰對並更新 Signet 的
JWT_PRIVATE_KEY_PATH
- 重啟 Signet — 新 token 會以新金鑰簽
- Resource server 看到未知的
kid會自動重抓 JWKS
時程
| 時間 | 事件 |
|---|---|
| T+0 | Signet 帶新金鑰重啟;JWKS 端點開始提供新公鑰 |
| T+0~1h | 還在舊 JWKS 快取的 resource server 遇到未知 kid 後重抓(JWKS max-age=3600) |
| T+token-TTL | 舊 access token 依各自 profile 過期(short ≈ 15 分鐘 · standard ≈ 10 小時 · long ≈ 24 小時;Client Credentials 用自己的 CLIENT_CREDENTIALS_TOKEN_EXPIRATION);使用中壽命最長者過期後,就不再有舊金鑰簽的 access token 會到達 resource server |
限制:Signet 在 JWKS 回應中只公開一把有效公鑰。輪替期間,沒有正確處理未知
kid的 resource server 可能拒絕新 token,直到 JWKS 快取過期(最長 1 小時)。一旦某台 resource server 更新到新 JWKS,它就無法驗證仍未過期、由舊金鑰簽的 token。為減少中斷,請使用短效 access token 或在離峰時段輪替。Refresh token 會立即失效,而非等到 access token TTL。 Refresh token 是用同一把金鑰簽的 JWT、壽命長得多(數天),但 Signet 只保留這一把有效金鑰 — 所以輪替會一次讓所有未過期的 refresh token 失效(下次刷新回
invalid_grant),全部使用者都得重新登入。Refresh token 沒有雙金鑰緩衝期;上面的 access token 時間軸只適用於 resource server 驗證。
常見陷阱
- 不檢查
kidheader — 一律以 JWT 的kid對應 JWKS key,才能支援金鑰輪替
- 遇到未知
kid不重抓 JWKS — 拒絕前先重抓一次,才能無縫輪替
- HS256 時 JWKS 是空的 — 要改用 RS256 / ES256 才能以 JWKS 驗證
- 不驗
iss— 一律驗 issuer 與 Signet URL 相符
- 不驗
aud— 為其他 resource server 簽的 token 不該在您這裡被接受。把WithAudience(Go)/audience=(PyJWT)/audience:(jose)設成 您自己的 resource 識別字 — 這同時涵蓋 RFC 8707 每次請求綁定與靜態JWT_AUDIENCE回退
- resource server 接受 refresh token — 一律檢查
type=access。Refresh token 用同一把金鑰簽,且aud為靜態JWT_AUDIENCE;沒檢查type的話,偷到的 refresh token 就能在任何只驗簽章/iss/exp/aud 的 RS 重放
- 把公鑰寫死 — 改用 JWKS,才能自動支援金鑰輪替
- 時鐘偏移 — 伺服器保持 NTP 同步;在 JWT 函式庫設 30–60 秒容許誤差
- 信任呼叫端自帶的
extra_claims— token 可能帶有客戶端透過extra_claims參數注入的額外 claim(Token 與撤銷 §呼叫端自帶的額外 Claim)。這些是 自我宣稱的,並非由 Signet 背書。絕不可把某個未知 claim 當成授權端已為其擔保那樣拿來做授權判斷;簽章只證明此 token 是被簽發出來的,不代表客戶端宣稱的值為真
相關文件
- 開始使用
- OpenID Connect — 驗證 ID token 與使用
/oauth/userinfo
- Token 與撤銷 — 本地驗證不夠時的線上 introspection
- Device Authorization Flow
- Authorization Code Flow
- Client Credentials Flow
- 錯誤處理