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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Clear Acceptance Criteria in Backend Development

Clear acceptance criteria define exactly when a backend deliverable is considered complete. By setting measurable standards for performance, testing, and reliability, both the client and developer can verify the result with objective benchmarks.

Read more

Charging What You Are Worth Is Hard Until You Understand What You Are Actually Selling

Contractors who struggle to charge high rates are usually selling the wrong thing. The shift from selling time to selling outcomes changes everything about the pricing conversation.

Read more

NYC Backend Engineers Cost $165K+ and Still Leave After 18 Months — The Async Alternative

You finally closed the hire. Six months later, they're fielding recruiter DMs from a company offering $20K more. Meanwhile, your API still isn't done.

Read more

How San Francisco Founders Cut Backend Burn Rate by 60% Without Sacrificing Code Quality

Your backend team costs $80K a month fully loaded. The output is good. The question is whether you need $80K a month of permanent cost to get it.

Read more