This guide explains how resource servers (your APIs and microservices) can verify AuthGate-issued JWT tokens locally using public keys, without calling back to AuthGate on every request.
Important tradeoff: Local JWT verification cannot detect server-side token revocation or status changes (revoked/disabled). Tokens remain valid until they expire. If your application requires real-time revocation enforcement, use AuthGate's
/oauth/tokeninfoendpoint for online validation, or combine local verification with periodic introspection for a balanced approach.
- Overview
- Algorithm Comparison
- How It Works
- Configuring AuthGate
- OIDC Discovery
- JWKS Endpoint
- JWT Token Structure
- Verification Steps
- Code Examples
- JWKS Caching Best Practices
- Key Rotation
- Common Pitfalls
- Related Documentation
With symmetric signing (HS256), every service that needs to verify a JWT must either share the same secret or call AuthGate's /oauth/tokeninfo endpoint. This creates tight coupling and additional load on AuthGate.
Asymmetric signing (RS256/ES256) solves this:
- AuthGate signs tokens with a private key that only it holds
- AuthGate publishes the public key via the JWKS endpoint (
/.well-known/jwks.json) - Resource servers fetch the public key once, cache it, and verify every token locally
Benefits:
- No shared secrets — the private key never leaves AuthGate
- Offline verification — resource servers validate tokens without network calls to AuthGate
- Reduced load — AuthGate only handles authentication; token verification is fully distributed
- Standard protocol — uses JWKS (RFC 7517) and OIDC Discovery, supported by every major language and framework
| 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.
flowchart LR
Client[Client App] -->|authenticate| AuthToken[AuthGate<br/>/oauth/token]
Client -->|"access_token (Bearer JWT)"| RS[Resource Server<br/>your API]
RS -->|lookup by kid| Cache[JWKS Cache]
RS -->|"fetch JWKS (cached)"| AuthJWKS[AuthGate<br/>/.well-known/jwks.json]
RS --> Verify["✓ Verify signature<br/>✓ Validate claims<br/>✓ Grant/deny access"]
Sequence diagram:
sequenceDiagram
participant Client as Client App
participant AuthGate as AuthGate
participant RS as Resource Server
Client->>+AuthGate: POST /oauth/token<br/>(authenticate)
AuthGate-->>-Client: access_token (JWT)
Client->>+RS: GET /api/resource<br/>Authorization: Bearer [JWT]
RS->>+AuthGate: GET /.well-known/jwks.json<br/>(first time or cache miss)
AuthGate-->>-RS: {"keys": [...]}
Note over RS: Verify JWT signature<br/>+ validate claims<br/>(all local, no call back to AuthGate)
RS-->>-Client: 200 OK (resource data)
# RS256: Generate RSA 2048-bit private key
openssl genrsa -out rsa-private.pem 2048
# ES256: Generate ECDSA P-256 private key
openssl ecparam -genkey -name prime256v1 -noout -out ec-private.pem# For RS256
JWT_SIGNING_ALGORITHM=RS256
JWT_PRIVATE_KEY_PATH=/path/to/rsa-private.pem
JWT_KEY_ID= # Optional: auto-generated from key fingerprint
# For ES256
JWT_SIGNING_ALGORITHM=ES256
JWT_PRIVATE_KEY_PATH=/path/to/ec-private.pem
JWT_KEY_ID= # Optional: auto-generated from key fingerprintFor complete configuration details including supported PEM formats, validation rules, and key rotation, see the Configuration Guide.
Resource servers can automatically discover the JWKS endpoint using OIDC Discovery:
curl https://your-authgate/.well-known/openid-configurationResponse (relevant fields):
{
"issuer": "https://your-authgate",
"jwks_uri": "https://your-authgate/.well-known/jwks.json",
"id_token_signing_alg_values_supported": ["RS256"],
"token_endpoint": "https://your-authgate/oauth/token",
"authorization_endpoint": "https://your-authgate/oauth/authorize",
"userinfo_endpoint": "https://your-authgate/oauth/userinfo"
}Note: The
jwks_urifield is only present when AuthGate is configured with RS256 or ES256. It is omitted for HS256. Theid_token_signing_alg_values_supportedvalue reflects the configuredJWT_SIGNING_ALGORITHM(e.g.,["ES256"]when using ES256), and may be omitted if ID tokens are not supported.
curl https://your-authgate/.well-known/jwks.json{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "abc123...",
"alg": "RS256",
"n": "0vx7agoebGc...base64url-encoded-modulus...",
"e": "AQAB"
}
]
}{
"keys": [
{
"kty": "EC",
"use": "sig",
"kid": "def456...",
"alg": "ES256",
"crv": "P-256",
"x": "f83OJ3D2xF1B...base64url-encoded-x...",
"y": "x_FEzRu9m36H...base64url-encoded-y..."
}
]
}The JWKS endpoint includes a cache directive:
Cache-Control: public, max-age=3600
Resource servers should cache the JWKS response for up to 1 hour.
When AuthGate uses HS256, the JWKS endpoint returns an empty key set:
{
"keys": []
}This is correct — symmetric secrets are never exposed via JWKS.
{
"alg": "RS256",
"kid": "abc123...",
"typ": "JWT"
}The kid (Key ID) header identifies which key was used to sign the token. Use this to look up the correct key from the JWKS response.
{
"user_id": "user-uuid",
"client_id": "client-uuid",
"scope": "openid profile email",
"type": "access",
"exp": 1700000000,
"iat": 1699996400,
"iss": "https://your-authgate",
"sub": "user-uuid",
"jti": "unique-token-id"
}| Claim | Description |
|---|---|
user_id |
End-user identifier, or client:<client_id> for client_credentials tokens |
client_id |
OAuth client that requested the token |
scope |
Space-separated list of granted scopes |
type |
access or refresh |
exp |
Expiration time (Unix timestamp) |
iat |
Issued-at time (Unix timestamp) |
iss |
Issuer URL (AuthGate's BASE_URL) |
sub |
Subject: user UUID for user tokens, or client:<client_id> for client_credentials tokens |
jti |
Unique token identifier (UUID) |
Note: For access tokens issued via the
client_credentialsgrant, there is no end user. Bothsubanduser_idare set to a synthetic machine identity (client:<client_id>).
- Decode the JWT header (without verifying) to extract
kidandalg - Fetch JWKS from
/.well-known/jwks.json(use cached response if available) - Find the key in the
keysarray whosekidmatches the JWT header'skid - Verify the signature using the matched public key and the algorithm from the header
- Validate standard claims:
exp— token is not expirediss— matches your expected AuthGate URLtype— isaccess(notrefresh)
- Check authorization — verify
scopeandclient_idmatch your requirements
Using github.com/MicahParks/keyfunc/v3 for automatic JWKS fetching and caching:
package main
import (
"fmt"
"log"
"net/http"
"strings"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
)
func main() {
jwksURL := "https://your-authgate/.well-known/jwks.json"
// Create a keyfunc that auto-refreshes JWKS every hour
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) {
// Extract Bearer token
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
token, err := jwt.Parse(tokenString, k.Keyfunc,
jwt.WithIssuer("https://your-authgate"),
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
}
// Check token type
tokenType, ok := claims["type"].(string)
if !ok || tokenType != "access" {
http.Error(w, "Invalid token type", http.StatusUnauthorized)
return
}
// Check scopes
scopeStr, ok := claims["scope"].(string)
if !ok || scopeStr == "" {
http.Error(w, "Insufficient scope", http.StatusForbidden)
return
}
scopes := strings.Fields(scopeStr)
if !contains(scopes, "read") {
http.Error(w, "Insufficient scope", http.StatusForbidden)
return
}
// For user tokens, user_id 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))
}
func contains(ss []string, s string) bool {
for _, v := range ss {
if v == s {
return true
}
}
return false
}Using PyJWT with built-in JWKS client:
import jwt
from jwt import PyJWKClient
from flask import Flask, request, jsonify
app = Flask(__name__)
AUTHGATE_URL = "https://your-authgate"
JWKS_URL = f"{AUTHGATE_URL}/.well-known/jwks.json"
# PyJWKClient caches JWKS keys automatically
jwks_client = PyJWKClient(JWKS_URL, cache_keys=True, lifespan=3600)
@app.route("/api/resource")
def protected_resource():
# Extract Bearer token
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return jsonify({"error": "Missing Bearer token"}), 401
token = auth.removeprefix("Bearer ")
try:
# Fetch the signing key by kid
signing_key = jwks_client.get_signing_key_from_jwt(token)
# Decode and verify the JWT
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256", "ES256"],
issuer=AUTHGATE_URL,
options={"require": ["exp", "iss", "sub"]},
)
except jwt.InvalidTokenError as e:
return jsonify({"error": f"Invalid token: {e}"}), 401
# Check token type
if payload.get("type") != "access":
return jsonify({"error": "Invalid token type"}), 401
# Check scopes
scopes = payload.get("scope", "").split()
if "read" not in scopes:
return jsonify({"error": "Insufficient scope"}), 403
return jsonify({"message": f"Hello, user {payload['user_id']}!"})Using jose (zero-dependency, Web Crypto API):
import { createRemoteJWKSet, jwtVerify } from "jose";
import { createServer } from "node:http";
const AUTHGATE_URL = "https://your-authgate";
const JWKS = createRemoteJWKSet(
new URL(`${AUTHGATE_URL}/.well-known/jwks.json`),
);
const server = createServer(async (req, res) => {
if (req.url !== "/api/resource") {
res.writeHead(404);
res.end("Not found");
return;
}
// Extract Bearer token
const auth = req.headers.authorization || "";
if (!auth.startsWith("Bearer ")) {
res.writeHead(401);
res.end(JSON.stringify({ error: "Missing Bearer token" }));
return;
}
const token = auth.slice(7);
try {
// Verify the JWT using JWKS (auto-fetched and cached)
const { payload } = await jwtVerify(token, JWKS, {
issuer: AUTHGATE_URL,
algorithms: ["RS256", "ES256"],
requiredClaims: ["exp", "sub", "scope"],
});
// Check token type
if (payload.type !== "access") {
res.writeHead(401);
res.end(JSON.stringify({ error: "Invalid token type" }));
return;
}
// Check scopes
const scopes = (payload.scope || "").split(" ");
if (!scopes.includes("read")) {
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"));| Practice | Details |
|---|---|
Respect Cache-Control |
AuthGate sets max-age=3600 (1 hour). Don't fetch more frequently. |
| 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 |
If a JWT contains a kid not in your cache, re-fetch JWKS once. If it still doesn't match, reject the token. |
| Pre-warm cache | Fetch JWKS at service startup, not on the first request. This avoids latency spikes. |
AuthGate supports key rotation. Since AuthGate currently serves a single active key in the JWKS response, all instances must be updated to the new key at the same time to avoid intermittent verification failures:
- Generate a new key pair (see Configuring AuthGate)
- Update
JWT_PRIVATE_KEY_PATH(and optionallyJWT_KEY_ID) in AuthGate's configuration on all instances - Pre-warm resource server JWKS caches — optionally, have resource servers fetch the new JWKS before the switch
- Restart all AuthGate instances — new tokens are signed with the new key; the JWKS endpoint serves the new public key
- Resource servers adapt automatically — tokens with an unknown
kidtrigger a JWKS re-fetch
Note: During the brief window between restart and JWKS cache refresh, resource servers with stale caches may reject tokens signed with the new key. To minimize this, ensure resource servers re-fetch JWKS on unknown
kid(as shown in the Code Examples).
T+0: AuthGate restarts with new key
T+0: New tokens signed with new kid
T+0: JWKS endpoint serves new public key
T+0~1h: Resource servers with cached old JWKS re-fetch on unknown kid
T+10h: All old access tokens have expired (default expiry = 10 hours)
- AuthGate serves a single active public key in the JWKS response (multi-key JWKS is not currently supported)
- During rotation, resource servers that don't handle unknown
kidgracefully may reject new tokens until their JWKS cache expires
For key management security practices, see the Security Guide.
Always match the JWT's kid against the JWKS keys. Without this, you can't handle key rotation and may use the wrong key.
Embedding the public key in your application config defeats the purpose of JWKS. Use a JWKS client library that fetches keys dynamically.
Always verify that iss matches your expected AuthGate URL. Without this check, tokens from other issuers could be accepted.
When you encounter an unknown kid, re-fetch JWKS once before rejecting. This allows seamless key rotation.
The JWKS endpoint returns an empty key set for HS256. Symmetric secrets are never exposed. If you need JWKS-based verification, switch to RS256 or ES256.
Always check the type claim. Refresh tokens (type: "refresh") should never be accepted by resource server endpoints.
If your servers' clocks are not synchronized, token expiration checks may fail. Use NTP to keep clocks in sync, or configure a small clock skew tolerance in your JWT library (typically 30-60 seconds).
- Configuration Guide — JWT Signing Algorithm — Full configuration reference for key generation, PEM formats, and validation rules
- Security Guide — Secrets Management — Private key storage, permissions, and rotation security practices
- Troubleshooting — JWT Signature Verification — Debugging verification failures
- Architecture Guide — Overall system design and component overview