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 scope contains openid) 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/tokeninfo on a sampled or cached basis — never call them per request to validate a JWT. Both are rate-limited (600 req/min per IP for tokeninfo; 600 per client app plus a 1200 per-IP ceiling for introspect), so a fleet calling per request gets 429 first. The only credential that requires a round-trip is an opaque sgk_ 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

sequenceDiagram participant Client as Client App participant Signet as Signet participant RS as Resource Server Client->>Signet: POST /oauth/token (authenticate) Signet-->>Client: access_token (JWT signed with RS256/ES256) Client->>RS: GET /api/resource (Authorization Bearer JWT) note over RS: First request or cache expired RS->>Signet: GET /.well-known/jwks.json Signet-->>RS: JWKS document (public keys by kid) note over RS: Cache JWKS (max-age=3600) note over RS: Verify JWT signature locally note over RS: Validate claims (exp, iss, scope) RS-->>Client: 200 OK (resource data)

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_uri field is only present when RS256 or ES256 is configured. When present, id_token_signing_alg_values_supported reflects the configured JWT_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 sub starts with client: 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 aud to a resource the administrator added to that client’s allowed-resources allowlist (deny-all by default). This means an aud you receive was authorized both by the client and by an operator-curated allowlist — but you must still validate aud against 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 aud matching your own resource identifier (e.g. https://api.example.com). This is what stops a token minted for https://api-a.example.com from being silently accepted at https://api-b.example.com.
  • aud may be a single string OR a JSON array — most JWT libraries (jwt.WithAudience in 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 the type=access check, 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 a resource parameter or a non-empty JWT_AUDIENCE, require aud strictly; otherwise accept its absence but log it.

ID tokens are unaffected — their aud continues to be the OAuth client_id per OIDC Core 1.0. See OpenID Connect.

Verification Steps

  1. Decode the JWT header to extract kid and alg
  2. Fetch JWKS from /.well-known/jwks.json (use cached copy if available)
  3. Find the key matching the kid from the JWT header
  4. Verify the signature using the public key
  5. Validate claims:
    • exp — token is not expired
    • iss — matches your Signet URL
    • type — must be access (reject refresh)
    • aud — must contain your resource server’s identifier — see Audience Binding
  6. Check authorization: verify scope matches your requirements; optionally validate client_id if 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

  1. Generate a new key pair and update JWT_PRIVATE_KEY_PATH in Signet
  2. Restart Signet — new tokens are signed with the new key
  3. Resource servers detect the unknown kid and 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 kid gracefully 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 kid header — Always match the JWT’s kid against 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. Configure WithAudience (Go), audience= (PyJWT), or audience: (jose) with your own resource identifier — this enforces both the RFC 8707 per-request binding and the static JWT_AUDIENCE fallback in one check
  • Accepting refresh tokens at resource servers — Always verify type=access. Refresh tokens are signed with the same key and carry the static JWT_AUDIENCE as their aud; without the type check, 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 the extra_claims parameter (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