JWT Across Microservices: How to Do It Without Repeating Yourself
by Arif Ikhsanudin, Backend Developer
The copy-paste security problem
You have eight services. Each one implements JWT validation — the same token extraction, the same signature verification against the same public key, the same expiry check, the same claim parsing. When your auth requirements change (new signing algorithm, new required claim, new validation rule), you update the logic in service one, test it, and then spend the next two weeks making the same change in services two through eight, hoping nobody misses a step.
This is not a hypothetical friction — it's a real pattern in organizations that adopted microservices without thinking through auth first. The consequences range from annoying (upgrade coordination across eight teams) to dangerous (one team forgets to update their validation logic, runs with a deprecated algorithm that's now vulnerable, and nobody notices for three months).
The two defensible architectures
Gateway validation with identity forwarding: validate the JWT once at the API gateway (Kong, NGINX, AWS API Gateway, or a custom gateway service). The gateway verifies signature, expiry, and required claims. On success, it strips the Authorization header and forwards validated identity claims as internal headers to downstream services.
# NGINX with lua-resty-jwt: validate at gateway, forward claims
location /api/ {
access_by_lua_block {
local jwt = require("resty.jwt")
local token = ngx.var.http_authorization:match("Bearer%s+(.+)")
local verified = jwt:verify(os.getenv("JWT_PUBLIC_KEY"), token)
if not verified["verified"] then
ngx.status = 401
ngx.exit(401)
end
-- Forward verified claims as internal headers
ngx.req.set_header("X-User-Id", verified.payload.sub)
ngx.req.set_header("X-User-Roles", table.concat(verified.payload.roles, ","))
ngx.req.clear_header("Authorization")
}
proxy_pass http://upstream;
}
Downstream services trust X-User-Id and X-User-Roles without re-validating the JWT. This is the single-validation pattern — one place to update when auth logic changes.
The security concern with this approach: if any downstream service is reachable without going through the gateway (direct cluster access, misconfigured network policy), it will accept forged X-User-Id headers. Mitigate this with Kubernetes NetworkPolicy that restricts all ingress to downstream services to come only from the gateway's IP range or pod label.
Per-service JWT validation with a shared library: each service validates the JWT, but validation logic lives in a single shared library. Services consume the library, and when validation logic changes, the library is updated and all services pick it up on their next deploy.
// Shared auth library: one place to update
@Library("auth-commons:2.3.1")
public class JwtValidator {
private final JwksClient jwksClient; // fetches public keys from JWKS endpoint
public UserIdentity validate(String token) {
Jwt jwt = Jwts.parserBuilder()
.setSigningKeyResolver(jwksClient.resolver())
.requireIssuer("https://auth.internal")
.requireAudience("internal-services")
.build()
.parseClaimsJws(token);
return UserIdentity.from(jwt.getBody());
}
}
This approach keeps JWT validation distributed (each service validates independently, no trust of internal headers) but centralizes the logic. The downside is that services must be redeployed to pick up library updates — you don't get a single configuration change that takes effect immediately.
JWKS: the right way to distribute public keys
Hardcoding the public key in each service's configuration means a key rotation requires touching every service. Use JWKS (JSON Web Key Set — RFC 7517): your auth server exposes a /jwks.json endpoint with the current public keys. Services fetch and cache this endpoint:
// https://auth.internal/.well-known/jwks.json
{
"keys": [{
"kty": "RSA",
"kid": "2026-04-key-1",
"use": "sig",
"n": "...",
"e": "AQAB"
}]
}
The kid (key ID) in the JWT header tells the validator which key to use. During key rotation, the JWKS endpoint serves both old and new keys simultaneously. Old tokens (signed with the old key) continue to validate until they expire. New tokens use the new key. No service configuration changes needed.
Libraries like java-jwt (Auth0), nimbus-jose-jwt, and python-jose all support JWKS-based validation natively.
Claims to include and claims to avoid
JWTs in microservices often become kitchen sinks — every attribute the auth team thought might be useful gets stuffed in. This creates problems: tokens get large (JWT size matters in HTTP headers), claims go stale (a premium claim embedded in a 24-hour token remains true even if the user's subscription lapses), and different services start relying on different subsets of claims in undocumented ways.
Keep JWTs lean: subject (sub), issuer (iss), audience (aud), expiry (exp), and roles/permissions. For everything else — detailed user profile, current subscription state, feature entitlements — call the authoritative service or maintain a local cache updated via events.
A 1-hour JWT with minimal claims is a better tradeoff than a 24-hour JWT stuffed with mutable user attributes that will be stale by the end of the day.
The one thing to do today
Audit your current services: how many of them have independent JWT validation logic? If the answer is more than one and it's not from a shared library, that's your starting point. Extract the validation logic into a shared library with a test suite, version it, and start migrating services to it one at a time. The migration from N independent implementations to one library pays back immediately in reduced surface area for auth bugs — and significantly when you next need to update your token validation requirements.