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绑定到管理员已加进该 客户端 allowed-resources 白名单(默认全部拒绝)的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 与撤销 §Caller-Supplied Extra Claims)。这些是 调用方自行声称的,不是 Signet 背书的。绝不要把某个未识别的 claim 当成授权方已为其担保那样用来做授权决策;签名只证明 token 是真的被签发出来的,并不证明客户端声称的值为真
相关文档
- 开始使用
- OpenID Connect — 验证 ID token 与使用
/oauth/userinfo
- Token 与撤销 — 本地验证不够时的在线 introspection
- Device Authorization Flow
- Authorization Code Flow
- Client Credentials Flow
- 错误处理