Short answer: put signature verification at the API gateway with a cached JWKS set, then use session introspection only when the business risk justifies a live check; this keeps private keys out of application services without pretending that a valid JWT is automatically a valid session.
The concrete case here is a developer-tools gateway that must rotate refresh tokens and revoke a stolen session. The decision is about bot and abuse resistance, not about picking the fanciest token library. A gateway that accepts a signed token after its account has been disabled is still doing the wrong thing.
The boundary I would draw first
The issuer publishes public keys, and the gateway verifies the JWT signature against that set. Services do not copy private keys between deployments. That boundary limits blast radius and makes key rotation an issuer concern, but it creates two operational duties for the verifier: refresh the key set when a key rotates, and keep the cache from turning a temporary fetch problem into an outage. In a real rotation drill, the useful questions are painfully specific: how many gateways notice an unknown kid, how long an old key remains accepted, what happens when the refresh endpoint is slow, and which dashboard proves that the fallback stayed inside its budget? Write those answers down before production traffic depends on them, because “the library caches JWKS” is not an SLO and does not tell an incident commander whether revocation is working.
Signature validity is only the first gate. Check issuer, audience, expiry, not-before, token type, and the claims that map to the account and requested action. A refresh token can be cryptographically sound and still fail a business rule because the session was revoked, the user lost consent, or the device risk score crossed your block threshold. I don't treat a 429 as an outage, but I do treat a rising 429 count as abuse telemetry that belongs in the incident dashboard.
I keep those checks explicit in the runbook. The SLO is not “JWKS is reachable”; it is “requests with revoked sessions stop being accepted within the stated revocation window.” That is a different measurement, and it usually points to a narrow introspection path for sensitive operations.
How should JWT verification, JWKS caching, and session introspection work together?
Use a two-speed path. Verify ordinary access tokens locally from a bounded JWKS cache. For refresh, password changes, high-value exports, or a suspected stolen session, call the session verifier and enforce its result before issuing another token. A cache miss should trigger one controlled refresh, with jitter and a short timeout; repeated failures need an observable, finite fallback policy rather than an infinite retry loop.
The exact policy depends on account continuity. If a stale key could authorize destructive work, fail closed for that route. If a short read-only interruption is preferable to forcing every developer out of the console, document that exception and alert on it. I am not sure there is one universal TTL: your issuer rotation cadence, revocation target, and incident response time decide it, so test those numbers against the SLO instead of copying a blog post.
Here is a minimal Go probe for the two verified endpoints. It uses the gateway's bearer key from the environment and treats a rate limit as a signal to back off, not as permission to hammer the service.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func get(ctx context.Context, path string) ([]byte, error) {
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" { baseURL = "https://api.example.invalid/v1" }
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
var last error
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+path, nil)
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil { last = err; time.Sleep(time.Duration(1<<attempt) * 200 * time.Millisecond); continue }
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * 200 * time.Millisecond
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil { wait = time.Duration(seconds) * time.Second }
time.Sleep(wait)
last = fmt.Errorf("rate limited: %s", string(body))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) }
return body, readErr
}
return nil, last
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
keys, err := get(ctx, "/auth/token/jwks")
if err != nil { panic(err) }
fmt.Printf("JWKS bytes: %d\n", len(keys))
session, err := get(ctx, "/auth/session/verify/SESSION_ID")
if err != nil { panic(err) }
fmt.Printf("Session response: %s\n", session)
}
The sample deliberately does not turn the session response into an authorization decision by guessing field names. Bind the verified response to your contract, log a request identifier, and make the decision in one place. For a write endpoint, add a client idempotency key to the write request; this probe is read-only, so it does not need one.
Keep the fallback finite.
Rotate deliberately.
What do the practical alternatives trade away?
Managed identity platforms reduce the amount of protocol code your team owns, while a self-hosted issuer gives you more control over tenancy and deployment. The table is intentionally about operating consequences rather than feature checklists.
| Option | Strength for this gateway | Cost or constraint |
|---|---|---|
| Auth0 | Managed JWKS and token workflows with broad integration documentation | External dependency and policy portability need review before an incident |
| Okta | Mature enterprise session and lifecycle controls | Commercial tenancy and product-specific configuration become part of the runbook |
| Keycloak | Self-hosted control over issuer, keys, and storage | Your team owns upgrades, availability, and rotation drills |
| A plain REST auth service | One HTTP contract can be called from the gateway without an SDK | You still own the cache, SLOs, abuse controls, and evidence that the contract is correct |
The last row is where the Infrai option fits for teams that want authentication calls over a plain REST API, with one key, one platform covering 295 routes across 20 modules, and a consistent HTTP surface, so a Go gateway does not need a client library release cycle. Its public discovery surface is self-describing, with request and response schemas that can be inspected before integration; that reduces the friction of wiring token rotation into an existing platform team when the same operators also own storage or scheduling calls. It does not remove the need to design revocation semantics.
Verification, rollback, and the uncomfortable cases
Instrument four signals: JWKS fetch latency and age, unknown kid frequency, introspection latency, and the count of requests rejected for session state after a valid signature. Alert on trends, not a single cache miss. During a rotation drill, publish the new key, observe a cache refresh, and then retire the old key only after tokens signed with it have crossed the planned acceptance window.
Rollback is a configuration change, not a code scramble. Keep the previous cache policy available, disable the stricter introspection gate only for the named low-risk routes, and record who approved that move. Never roll back by distributing a private key to every service.
The catch is that this architecture is not suitable when every request must reflect revocation immediately and you cannot tolerate an introspection dependency; use a design with a strongly consistent session store, or accept the availability cost of checking it. Stick with a self-hosted issuer when data residency or custom identity flows outweigh the gateway team's on-call budget. Your mileage may vary, but the trade-off should be visible in the SLO and threat model.
For the developer-tools scenario, the decision rule is compact: local JWT verification for routine traffic, bounded JWKS refresh, and live session verification at the points where a stolen refresh token can cause durable harm. That combination keeps the boundary legible and gives incident responders a lever they can test before they need it.
Top comments (0)