JWT Verification
Verify Signet-issued access tokens at your resource servers using public keys — no callback to Signet needed.
This page covers access tokens only. ID tokens (issued when
scopecontainsopenid) use JWKS the same way but have a different claim set and stricter validation rules — see OpenID Connect.Important tradeoff: Local JWT verification cannot detect server-side revocation or status changes (revoked/disabled tokens remain technically valid until expiry). If you need real-time revocation enforcement, combine local verification with
/oauth/introspect(RFC 7662) or/oauth/tokeninfoon a sampled or cached basis — never call them per request to validate a JWT. Both are rate-limited (600 req/min per IP fortokeninfo; 600 per client app plus a 1200 per-IP ceiling forintrospect), so a fleet calling per request gets429first. The only credential that requires a round-trip is an opaquesgk_personal API key — cache those verdicts briefly. See Tokens & Revocation and API Keys.
When to Use
Use JWT verification with JWKS when:
- You have multiple microservices that need to verify tokens independently
- You want to reduce load on Signet by eliminating token validation callbacks
- You need offline verification without network dependencies on Signet
- You are deploying in a zero-trust architecture where services should not share secrets
Prerequisite: Signet must be configured with RS256 or ES256 signing. For HS256 (symmetric) signing, the JWKS endpoint exists but returns an empty key set, and the OIDC discovery document omits
jwks_uri.
Algorithm Comparison
| Algorithm | Type | Key Material | Token Size | Use Case |
|---|---|---|---|---|
HS256 |
Symmetric | JWT_SECRET (shared secret) |
~300 bytes | Simple single-service deployments |
RS256 |
Asymmetric | RSA 2048-bit private key | ~600 bytes | Wide ecosystem support, JWKS-based |
ES256 |
Asymmetric | ECDSA P-256 private key | ~400 bytes | Compact tokens, modern deployments |
Recommendation: Use RS256 for maximum compatibility or ES256 for smaller tokens. Avoid HS256 in multi-service architectures.
How It Works
After the initial JWKS fetch, all subsequent token verifications happen locally — no network call to Signet is needed.
Confirm Your Signet Instance Uses Asymmetric Signing
JWKS-based verification only works when Signet is configured with RS256 or ES256. Confirm via OIDC Discovery — if the jwks_uri field is absent or /.well-known/jwks.json returns an empty keys array, your deployment is using HS256 and you cannot verify tokens this way.
If you hit that wall, ask your administrator to switch the deployment to RS256/ES256. Symmetric secrets are never exposed through JWKS.
OIDC Discovery
Resource servers discover the JWKS URL via OIDC Discovery:
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"]
}
The
jwks_urifield is only present when RS256 or ES256 is configured. When present,id_token_signing_alg_values_supportedreflects the configuredJWT_SIGNING_ALGORITHM(e.g.,["ES256"]when using ES256), but this field may be omitted entirely when ID tokens are not supported.
JWKS Endpoint
curl https://your-signet/.well-known/jwks.json
RS256 response:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "abc123...",
"alg": "RS256",
"n": "0vx7agoebGc...",
"e": "AQAB"
}
]
}
ES256 response:
{
"keys": [
{
"kty": "EC",
"use": "sig",
"kid": "def456...",
"alg": "ES256",
"crv": "P-256",
"x": "f83OJ3D2xF1B...",
"y": "x_FEzRu9m36H..."
}
]
}
The response includes Cache-Control: public, max-age=3600 — cache for up to 1 hour.
HS256: The JWKS endpoint returns
{"keys": []}for HS256. Symmetric secrets are never exposed via JWKS.
JWT Token Structure
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 | Description |
|---|---|
user_id |
Same as sub |
client_id |
OAuth client that requested the token |
scope |
Space-separated granted scopes |
type |
access on tokens you accept as a Bearer; refresh on refresh tokens (which must never be accepted by a resource server). Reject anything other than access |
aud |
Audience — the resource server(s) this token is valid for. Set to the per-request resource value(s) (RFC 8707) when the client supplied one; otherwise falls back to the static JWT_AUDIENCE config. May be absent on older deployments that set neither. May be a single string OR a JSON array. See Audience Binding below |
exp |
Expiration time (Unix timestamp) |
iat |
Issued-at time (Unix timestamp) |
iss |
Issuer URL (the discovery document’s issuer) |
sub |
User UUID for user-delegated tokens, or client:<client_id> for client_credentials tokens |
jti |
Unique token identifier (UUID) |
M2M tokens: when
substarts withclient:the token is from the Client Credentials flow — no end user is involved. Branch on this if your API treats service calls differently.
Audience Binding (RFC 8707)
Signet supports Resource Indicators (RFC 8707). When an OAuth client includes one or more resource=<URL> parameters on /oauth/authorize, /oauth/device/code, or /oauth/token, the issued access-token JWT’s aud claim is bound to those values at issuance. With no resource parameter, aud falls back to the deployment-wide JWT_AUDIENCE config (if set) or is omitted entirely.
A client may only bind
audto aresourcethe administrator added to that client’s allowed-resources allowlist (deny-all by default). This means anaudyou receive was authorized both by the client and by an operator-curated allowlist — but you must still validateaudagainst your own identifier; the allowlist is an issuance-side control, not a substitute for RS-side checking.
Resource servers must validate aud:
- Configure your JWT library to require
audmatching your own resource identifier (e.g.https://api.example.com). This is what stops a token minted forhttps://api-a.example.comfrom being silently accepted athttps://api-b.example.com.
audmay be a single string OR a JSON array — most JWT libraries (jwt.WithAudiencein Go,audience=...in PyJWT,audience: ...in jose) handle both shapes.
- Refresh tokens are signed with the static
JWT_AUDIENCE, never the per-request resource. Combined with thetype=accesscheck, this is what prevents a refresh token from being mistakenly accepted at a resource server. Always check both claims.
- Tokens issued before audience binding was deployed may omit
aud. If your deployment guarantees aresourceparameter or a non-emptyJWT_AUDIENCE, requireaudstrictly; otherwise accept its absence but log it.
ID tokens are unaffected — their
audcontinues to be the OAuthclient_idper OIDC Core 1.0. See OpenID Connect.
Verification Steps
- Decode the JWT header to extract
kidandalg
- Fetch JWKS from
/.well-known/jwks.json(use cached copy if available)
- Find the key matching the
kidfrom the JWT header
- Verify the signature using the public key
- Validate claims:
exp— token is not expired
iss— matches your Signet URL
type— must beaccess(rejectrefresh)
aud— must contain your resource server’s identifier — see Audience Binding
- Check authorization: verify
scopematches your requirements; optionally validateclient_idif your API restricts access to specific clients
Code Examples
Go
Using keyfunc for automatic JWKS fetching and caching:
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"
// Create a keyfunc that auto-refreshes JWKS
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 ")
// Parse and verify the JWT using JWKS.
// WithAudience enforces that the JWT's `aud` claim includes this
// resource server's identifier — Signet sets `aud` from a per-request
// RFC 8707 `resource` parameter when supplied, otherwise from the static
// JWT_AUDIENCE config; the RS-side check is the same in either case.
token, err := jwt.Parse(tokenString, k.Keyfunc,
jwt.WithIssuer("https://your-signet"),
jwt.WithAudience("https://api.example.com"), // your resource server identifier
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
}
// Check scopes — replace "profile" with whatever your API requires.
scopeStr, _ := claims["scope"].(string)
scopes := strings.Fields(scopeStr)
if !slices.Contains(scopes, "profile") {
http.Error(w, "Insufficient scope", http.StatusForbidden)
return
}
// For user tokens, sub is a UUID; for client_credentials, it is "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
Using PyJWT with built-in 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" # this resource server's identifier
# PyJWKClient caches JWKS keys automatically
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, # enforce 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
# Check scopes — replace "profile" with whatever your API requires.
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
Using jose (zero-dependency):
import { createRemoteJWKSet, jwtVerify } from "jose";
import { createServer } from "node:http";
const SIGNET_URL = "https://your-signet";
const MY_RESOURCE_ID = "https://api.example.com"; // this resource server's identifier
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, // enforce 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;
}
// Check scopes — replace "profile" with whatever your API requires.
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"));
Caching Best Practices
| Practice | Details |
|---|---|
Respect Cache-Control |
Signet sets max-age=3600 (1 hour). Don’t fetch more often. |
| Use JWKS libraries | Libraries like keyfunc (Go), PyJWKClient (Python), and jose (Node.js) handle caching automatically. |
Cache by kid |
Index cached keys by their kid value for O(1) lookup. |
Handle unknown kid |
Re-fetch JWKS once on unknown kid. If still no match, reject the token. |
| Pre-warm cache | Fetch JWKS at service startup to avoid latency on the first request. |
Key Rotation
- Generate a new key pair and update
JWT_PRIVATE_KEY_PATHin Signet
- Restart Signet — new tokens are signed with the new key
- Resource servers detect the unknown
kidand re-fetch JWKS automatically
Timeline
| Time | Event |
|---|---|
| T+0 | Signet restarts with new key; JWKS endpoint serves new public key |
| T+0~1h | Resource servers with cached old JWKS re-fetch on unknown kid (JWKS max-age=3600) |
| T+token-TTL | Old access tokens expire per client profile (short ≈ 15m · standard ≈ 10h · long ≈ 24h; Client Credentials uses its own CLIENT_CREDENTIALS_TOKEN_EXPIRATION); after the longest lifetime in use, no old-key access token still reaches a resource server |
Limitations: Signet serves a single active public key in the JWKS response. During rotation, resource servers that don’t handle unknown
kidgracefully may reject new tokens until their JWKS cache expires (up to 1 hour). Once a resource server refreshes to the new JWKS, it can no longer verify still-unexpired tokens signed with the old key. To minimize disruption, use short-lived access tokens or schedule rotation during low-traffic periods.Refresh tokens break immediately, not at the access-token TTL. Refresh tokens are JWTs signed with the same key and live far longer (days), but Signet keeps only the one active key — so rotation invalidates every outstanding refresh token at once (the next refresh fails with
invalid_grant) and all users must re-authenticate. There is no dual-key grace period for refresh tokens; the access-token timeline above applies only to resource-server verification.
Common Pitfalls
- Not checking
kidheader — Always match the JWT’skidagainst JWKS keys to support key rotation
- Not re-fetching JWKS on unknown
kid— Re-fetch once before rejecting; this enables seamless key rotation
- JWKS empty for HS256 — Switch to RS256 or ES256 for JWKS-based verification
- Not validating
iss— Always check the issuer matches your Signet URL
- Not validating
aud— A token minted for another resource server must not be accepted at yours. ConfigureWithAudience(Go),audience=(PyJWT), oraudience:(jose) with your own resource identifier — this enforces both the RFC 8707 per-request binding and the staticJWT_AUDIENCEfallback in one check
- Accepting refresh tokens at resource servers — Always verify
type=access. Refresh tokens are signed with the same key and carry the staticJWT_AUDIENCEas theiraud; without thetypecheck, a stolen refresh token can be replayed at any RS that only validates signature/iss/exp/aud
- Hardcoding public keys — Use JWKS for automatic key rotation support
- Clock skew — Keep server clocks synchronized with NTP; configure a 30-60 second tolerance in your JWT library
- Trusting caller-supplied
extra_claims— A token may carry extra claims the client injected via theextra_claimsparameter (Tokens & Revocation §Caller-Supplied Extra Claims). These are self-asserted, not attested by Signet. Never base an authorization decision on an unrecognized claim as if the authority had vouched for it; the signature only proves the token was minted, not that the client’s asserted values are true
Related
- Getting Started
- OpenID Connect — Verify ID tokens and use
/oauth/userinfo
- Tokens & Revocation — Online introspection when local verify isn’t enough
- Device Authorization Flow
- Authorization Code Flow
- Client Credentials Flow
- Errors