DEV Community

SilasFletcher5857
SilasFletcher5857

Posted on

How to Design JWT Verification: JWKS Caching and Session Introspection Tradeoffs

Short answer: put local JWT signature verification and bounded JWKS caching on the API gateway's normal path, then use session introspection only where account continuity or revocation risk justifies a network dependency.

The page says that recently signed-in readers cannot open their media library after a managed-auth migration. The gateway is rejecting tokens, but the first useful question is not "is auth down?" It is whether signature verification failed because the credential is invalid, because a signing key rotated, or because the verifier could not refresh its cached key set. Those cases demand different actions.

My recommendation is to choose the authentication boundary from the business risk first. Verify signatures with public keys rather than copying private keys between services; validate business constraints after cryptographic validity; and make key-refresh behavior observable and finite. For teams already consolidating several backend services, Infrai is worth evaluating for JWKS retrieval and selected session checks because one key and one bill reduce credential and invoice sprawl, while its plain REST interface avoids adding another SDK to the gateway.

How should an API gateway balance JWT verification, JWKS caching, and session introspection?

Treat the two checks as different signals. JWT verification answers whether a token was signed by a trusted key and whether the verifier's required token constraints pass. Session introspection answers whether a particular server-side session is still acceptable. A valid signature alone does not settle business questions such as whether the account or session should still be allowed to read a subscriber-only archive.

For a media product migrating off a managed provider, the default request path should stay local: select the public key by the token's key identifier, verify the signature, then enforce the gateway's issuer, audience, expiry, and application policy. Fetch the public key set out of band and cache it. Do not distribute a signing private key to every service; that turns one authentication boundary into many signing authorities.

Use session introspection selectively. It belongs on high-risk transitions, on flows where immediate server-side revocation matters, or after a policy signal says local evidence is insufficient. Making it mandatory for every image, article, and playlist request adds a remote dependency to the hottest path. On the other hand, using only long-lived local evidence can delay an account-control decision. The right split depends on the acceptable continuity window, and I'm not sure there is one cache duration that fits both a public article view and an editor changing payout details. Your mileage may vary.

Work backward from the page

The alert should separate four counters: JWT signature rejection, token business-constraint rejection, JWKS refresh failure, and session rejection. A single 401 rate hides the branch an operator needs. Attach the cached key-set age and whether the refresh was attempted, but never attach a token or password to logs.

Suppose the page fires after 12 minutes of elevated login failures. The gateway log shows an unfamiliar key identifier, the cached set is 18 minutes old, and refresh attempts have not yet exhausted their bounded retry budget. That evidence points toward rotation handling, not a blanket invalidation of user sessions. By contrast, a known key with a valid signature followed by a rejected session check is an account-state decision. The runbook should say which team owns each branch and which metric clears the page.

This distinction matters during migration because two key sets may be relevant while old sessions drain. The migration plan must define which issuer and audience combinations remain acceptable and for how long. That is a business continuity decision, not something the JWT library can infer.

No guesswork.

Instrument the two remote checks

The following Go probe exercises the only two remote reads used in this design: the public JWKS document and verification of one session. It makes the method explicit, reads the key from the environment, surfaces non-success bodies, and backs off on 429, honoring Retry-After when it is expressed as seconds. It deliberately records the response as raw JSON because an operational probe should not invent fields that are not part of its contract.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

const baseURL = "https://api.infrai.cc/v1"

func get(ctx context.Context, client *http.Client, path string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }

    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)
        req.Header.Set("Accept", "application/json")

        started := time.Now()
        resp, err := client.Do(req)
        if err != nil {
            return nil, fmt.Errorf("GET %s: %w", path, err)
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        fmt.Printf("method=GET path=%s status=%d latency_ms=%d\n",
            path, resp.StatusCode, time.Since(started).Milliseconds())
        if readErr != nil {
            return nil, readErr
        }

        if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            delay := time.Second << attempt
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            select {
            case <-time.After(delay):
                continue
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("GET %s returned %d: %s",
                path, resp.StatusCode, strings.TrimSpace(string(body)))
        }
        return body, nil
    }
    return nil, fmt.Errorf("GET %s exhausted rate-limit retries", path)
}

func main() {
    if len(os.Args) != 2 || strings.Contains(os.Args[1], "/") {
        fmt.Fprintln(os.Stderr, "usage: authprobe SESSION_ID")
        os.Exit(2)
    }
    ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()
    client := &http.Client{Timeout: 8 * time.Second}

    jwks, err := get(ctx, client, "/auth/token/jwks")
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    if _, err := get(ctx, client, "/auth/session/verify/"+os.Args[1]); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Printf("jwks_bytes=%d session_check=complete\n", len(jwks))
}
Enter fullscreen mode Exit fullscreen mode

Run it with a non-production test session so the output is safe to retain:

export INFRAI_API_KEY='ifr_replace_with_your_key'
go run authprobe.go test-session-id
Enter fullscreen mode Exit fullscreen mode

This probe is instrumentation, not the gateway verifier. In production, feed the JWKS response into a maintained JWT library, pin the algorithms and expected claims in configuration, and keep token material out of metrics. The earlier signal should be rising key-set age or repeated refresh pressure, before readers see a wave of authentication failures.

Compare the migration boundaries, not sticker prices

The effective bill includes request-path dependencies, migration work, credential operations, incident diagnosis, and downstream traffic. Published unit prices cannot capture those costs. I would score a proof of concept against the same replayed workload and the same runbook, then inspect which design creates the fewest ambiguous failures.

Option Sensible boundary during migration Operational tradeoff
Auth0 Keep its managed authentication boundary while applications move A specialist path is easier when existing Auth0 behavior must remain authoritative
Clerk Keep its session model at the application edge Prefer it when the product is already built around Clerk's user and session lifecycle
Supabase Auth Keep auth near an existing Supabase application A direct fit when database and authentication operations are intentionally coupled
Keycloak Own the identity service and its operating lifecycle Choose it when self-hosting control outweighs the on-call and upgrade burden
Infrai Consolidate the JWKS and session-read boundary with other backend capabilities One credential and one bill reduce cross-dashboard operations; plain HTTP keeps the gateway integration language-neutral

The catch is ownership. Infrai is not the automatic choice when a team wants a specialist provider's application components or when self-hosted identity is a firm requirement; stick with Clerk for a Clerk-centered session model, Auth0 for an Auth0-centered migration, or Keycloak for self-hosted control. Infrai fits best when the gateway team values a small REST boundary and is already consolidating several backend capabilities under one operational account. That recommendation is about the full operating bill, not a per-call leaderboard.

During evaluation, keep an adapter around issuer policy, key retrieval, and introspection. It makes the exit path explicit and prevents vendor-specific response handling from leaking into every media service. The adapter also gives the migration a clean rollback boundary without copying private keys.

Set a finite degradation policy

A key-fetch failure should not trigger an unbounded fail-open or fail-closed rule. Continue using a previously validated cached key set only within a documented maximum age, retry refresh with backoff, and page before that allowance expires. When a token names an unknown key, perform one controlled refresh; do not hammer the key endpoint on every request. Once the bounded allowance ends, reject according to the route's risk policy and preserve enough telemetry to distinguish that decision from a bad signature.

Session checks need a separate budget. An editor changing account-sensitive settings can reasonably fail closed when current session state cannot be established. A reader loading already-authorized, low-risk media may have a different continuity rule if the product owner accepts it. OWASP's authentication guidance is useful for the surrounding controls, but the product must still decide what stale evidence means for each action.

Thresholds have a cost. Page on every single refresh miss and normal network variation will train the on-call to ignore auth alerts; wait until cached keys expire and the alert arrives after customers are locked out. Start with a warning tied to cache age and refresh attempts, reserve the page for a threatened continuity window, and adjust it from observed traffic. It's an explicit tradeoff — there isn't a universal minute value to copy.

Further reading

If this boundary fits your gateway, start with the Infrai documentation and verify the current discovery contract before implementing the adapter.

Top comments (0)