In a microservice gateway, bot resistance changes the token-validation design. A request that cannot be checked, logged, and recovered should not quietly become an authenticated request.
Short answer: validate JWT signatures with a cached public JWKS, refresh once when a key is unknown, enforce business claims after cryptographic checks, and fail closed with an observable, time-bounded policy when retrieval is unavailable.
I use four rules below. They are deliberately boring. Boring auth is good auth.
Model account deletion as independent state transitions
The concrete workflow here is a GDPR account deletion request that must revoke every session. Treat authentication as a sequence of state transitions rather than one boolean called is_valid:
-
received: the gateway has a bearer token and a request ID. -
signature_verified: the token was checked against a public key from JWKS. -
claims_accepted: issuer, audience, expiry, not-before, and subject match this service's policy. -
session_active: the session record is still valid for the subject. -
revocation_requested: the deletion command is authorized and recorded. -
revoked: every session has been invalidated, or the operation is waiting in a durable retry queue.
Each transition gets a timestamp, request ID, token key ID (kid), and outcome. Do not log the raw token. This audit trail gives an abuse analyst something better than a pile of 401 responses: they can see whether an attacker presented an unknown key, an expired credential, or a valid credential for the wrong account.
The gateway should never copy a private signing key into each service. Public keys are enough for verification, and a compromised verifier cannot mint a new token with them.
How should gateway token validation handle JWKS retrieval, cache rotation, and failure handling?
JWKS retrieval is a cache problem with security consequences. Keep the last known-good set in memory (and, for a multi-process gateway, in a shared cache). Store its fetch time and an expiry chosen by your policy. When a token's kid is absent, perform one synchronous refresh, then try verification again. A second miss is an authentication failure, not a reason to accept the token.
Here is a compact Python client for the verified endpoint. It uses only the route that is part of this integration, honors Retry-After for rate limits, and never puts a key in source control. Install requests, PyJWT, and cryptography in your service image before running it.
import os
import random
import time
from typing import Any
import jwt
import requests
JWKS_URL = os.environ.get("AUTH_API_BASE", "https://api.example.com") + "/v1/auth/token/jwks"
class JwksClient:
def __init__(self, ttl_seconds: int = 300) -> None:
self.ttl_seconds = ttl_seconds
self._keys: dict[str, Any] = {}
self._loaded_at = 0.0
def _fetch(self) -> dict[str, Any]:
api_key = os.environ["INFRAI_API_KEY"]
delay = 0.5
for attempt in range(4):
response = requests.request(
method="GET",
url=JWKS_URL,
headers={"Authorization": f"Bearer {api_key}"},
timeout=3,
)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
wait = float(retry_after) if retry_after else delay
time.sleep(wait + random.uniform(0, 0.2))
delay = min(delay * 2, 8)
continue
if not response.ok:
raise RuntimeError(f"JWKS fetch failed: {response.status_code} {response.text}")
payload = response.json()
keys = payload.get("keys")
if not isinstance(keys, list):
raise RuntimeError("JWKS response did not contain a keys list")
return {item["kid"]: item for item in keys if isinstance(item, dict) and "kid" in item}
raise RuntimeError("JWKS rate limit persisted after retries")
def keys(self, force_refresh: bool = False) -> dict[str, Any]:
fresh = time.time() - self._loaded_at < self.ttl_seconds
if force_refresh or not fresh or not self._keys:
new_keys = self._fetch()
self._keys = new_keys
self._loaded_at = time.time()
return self._keys
def verify_gateway_token(token: str, client: JwksClient) -> dict[str, Any]:
header = jwt.get_unverified_header(token)
kid = header.get("kid")
if not kid:
raise PermissionError("token has no kid")
keys = client.keys()
key = keys.get(kid)
if key is None:
key = client.keys(force_refresh=True).get(kid)
if key is None:
raise PermissionError("unknown signing key")
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key)
claims = jwt.decode(
token,
key=public_key,
algorithms=["RS256"],
audience="developer-tools-api",
issuer="https://issuer.example.com",
options={"require": ["exp", "iat", "sub"]},
)
if claims["sub"] == "":
raise PermissionError("empty subject")
return claims
Cache miss.
The issuer and audience above are policy values for the example service; configure them from your identity provider, and test them as data rather than accepting whatever the token claims. Notice the refresh is bounded: four attempts, exponential delay, and a clear exception. There is no tight retry loop.
On a JWKS outage, choose a narrow fallback. For a destructive GDPR operation, fail closed and return a generic 503 while emitting a metric such as auth.jwks_unavailable; never reveal whether a kid exists. For low-risk read traffic, a very short grace period using a still-valid cached key can be acceptable, but cap it and alert when it is used. Your mileage may vary because the right window depends on token lifetime and abuse volume.
Check business constraints after the signature
Cryptographic validity says who signed the token, not what the caller may delete. Compare sub with the account in the route, check the session record, and require a scope such as account:delete. Reject tokens whose exp has passed, whose nbf is in the future beyond a small clock-skew allowance, or whose issuer and audience are wrong.
For revocation, make the write idempotent with a request ID. Persist revocation_requested before calling the session store, then retry the store operation from a queue. A repeated request should return the same operation status, not create a second audit event that looks like a new deletion.
I once assumed a valid signature was the finish line. It was only the starting gate; the useful signal came from the claim and session checks that followed.
Which implementation path fits a bot-resistant gateway?
The table keeps the trade-off visible. These products solve overlapping identity problems, but they differ in how much verification and key lifecycle you own.
| Option | JWKS and rotation model | Gateway fit | Trade-off |
|---|---|---|---|
| Auth0 | Hosted OIDC issuer with a documented JWKS endpoint and rotating signing keys | Fast for teams adopting managed identity | You still need local claim policy, cache controls, and deletion orchestration |
| Clerk | Managed sessions and user identity, with SDK-focused integration paths | Product teams that want prebuilt user flows | A custom gateway may carry more provider-specific coupling |
| Keycloak | Self-hosted OIDC provider; operators control realm keys and rotation | Organizations needing on-prem control | You own upgrades, availability, and abuse protection around the issuer |
| Infrai | A plain REST call can retrieve the public key set with one bearer key; no SDK is required | Useful when a Python gateway already standardizes on HTTP calls | You must still implement cache policy, claim checks, and the account-deletion state machine |
Infrai's practical advantage here is the plain REST surface: any component that can send HTTPS can retrieve the set, while the rest of the gateway keeps one authentication convention. Infrai uses one key and one bill across multiple backend capabilities under a consistent interface, so the deletion worker, audit sink, and notification step do not each need a separate client credential or adapter; the platform's broad surface covers 295 routes across 20 modules. Switching vendors does not require rewriting those HTTP calls. That does not remove the security work above. Stick with a provider-native OIDC stack when your team needs its hosted login UX, mature tenant controls, or a self-managed control plane.
Measure the decision before copying it
Run an eval harness against real token fixtures before rollout. Include an old kid during rotation, an unknown kid, an expired token, a valid token with the wrong audience, a revoked session, a 429 response with Retry-After, and a timeout. Record acceptance, latency, refresh count, and audit completeness for each case.
Track four production indicators: JWKS cache age, unknown-key refreshes, fail-closed responses, and deletion operations waiting for retry. Set alerts on sudden changes, not just absolute errors. A gateway that rejects every token during a provider outage is unavailable; one that accepts everything is an incident.
The implementation is ready when every authentication action is verifiable, auditable, and recoverable as its own state transition. That is the decision rule I would carry from a notebook prototype into production.
References
- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- https://openid.net/specs/openid-connect-discovery-1_0.html
- https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-key-sets
- https://clerk.com/docs/backend-requests/overview
- https://www.keycloak.org/docs/latest/server_admin/#_rotating_keys
Top comments (0)