JWTs are commonly described as "stateless" — but this description glosses over an important caveat: the claims inside the token are stateless, but the decision of whether to trust those claims may require state, and confusing these two things leads to security decisions that seem logical but aren't
The previous articles on this site covered JWT inspection, debugging authentication errors, JWT security vulnerabilities, refresh rotation strategies, and JWT vs opaque tokens. This article addresses JWT in microservices architecture — specifically how JWTs propagate between internal services, the "gateway validates, services trust" pattern, and what can go wrong when this pattern is applied too broadly.
The microservices JWT problem: who validates the token?
In a monolithic application, JWT validation is straightforward: every request hits one application, which validates the JWT and processes the request.
In a microservices architecture, a single user request might flow through an API gateway, then trigger calls to multiple internal services (user service, order service, inventory service, payment service). Who validates the JWT?
Option 1: Every service validates the JWT — each internal service independently verifies the JWT signature and checks expiry. This is robust (each service makes its own trust decision) but has costs:
- Every service needs access to the signing key (or JWT verification key for asymmetric algorithms)
- JWT validation adds latency to every internal service call
- Key rotation requires updating every service simultaneously
Option 2: Gateway validates, internal services trust — the API gateway validates the JWT and, on successful validation, forwards a trusted "identity header" to internal services. Internal services trust whatever identity they receive from the gateway without re-validating the JWT.
- Internal services are simpler (no JWT libraries needed)
- Key management is centralized at the gateway
- But: if any service can be reached without going through the gateway (e.g., internal services reachable within the cluster), or if the gateway is compromised, the entire trust model fails
The service mesh approach: mTLS + JWT separation of concerns
Modern microservices infrastructure increasingly uses service meshes (Istio, Linkerd) that handle service-to-service authentication separately from user authentication:
mTLS (mutual TLS): service-to-service calls authenticate both endpoints with client certificates. Service B can verify that "this call actually came from Service A" cryptographically, without headers that could be forged.
JWTs for user identity: the user's identity (claims from the original JWT) propagate through the service call chain as a header or context object — but the authentication of the call itself (did this really come from Service A?) is handled by mTLS.
This separation of concerns is cleaner than either "every service validates the full JWT" or "services trust gateway headers": mTLS handles infrastructure-level service authentication; the JWT (or claims extracted from it) handles user-level authorization.
JWT claims for authorization vs. authentication
A common architectural mistake: putting authorization logic in JWT claims that can't be updated until token expiry.
Example: a user's permissions are encoded in the JWT: "roles": ["user", "editor"]. The user's role changes (promoted to admin). But the user still has a valid JWT token with the old "roles" claim — they don't get the "admin" role until their token expires and they get a new one.
The window between role change and token expiry is a period where the system's authorization model is wrong: the source of truth (the database) says "admin," but the JWT says "editor." Conversely, if a role is revoked (an editor loses access), they retain that access until token expiry.
Solutions:
- Short-lived tokens: 5-15 minute access tokens mean the window is short. Users might need to re-authenticate (or use a refresh token) to pick up permission changes — acceptable if the refresh flow is seamless.
- Check critical permissions in the database: for high-stakes authorization decisions (admin operations, payment processing), verify the current permission from the database even if the JWT claims say it's allowed. The JWT claim is a hint; the database is the truth.
- Invalidation mechanisms: a permissions-change event can trigger token invalidation via the previously-discussed denylist approach.
JWT in machine-to-machine contexts: service tokens
JWTs aren't only for user sessions. Machine-to-machine (M2M) authentication — service accounts, API clients, automated systems — uses JWTs with different claim sets:
Standard M2M JWT claims:
iss(issuer): the authorization server that issued the tokensub(subject): the client/application identifier (not a user)aud(audience): which services/APIs this token is valid forexp(expiry): when the token expiresscope: what actions the token is authorized to perform
The aud claim is particularly important in M2M contexts: a token issued for Service A should only be accepted by Service A, not by Service B. Without aud validation, a token stolen from one service's API call can be replayed against any other service that trusts the same issuer.
Many JWT validation implementations skip aud validation — this is a security weakness. Every service that accepts JWTs should validate that the aud claim includes that service's identifier.
JWT debugging in microservices: distributed tracing integration
When a request fails authentication across a microservices system, tracing the JWT through the call chain is necessary. Practical approaches:
Correlation IDs: include a jti (JWT ID) claim and propagate it as a trace header through all internal service calls. When investigating a failed request, you can find all log entries associated with that specific JWT.
Structured logging: log JWT validation events (validation success, failure reason, token claims) in a consistent, structured format (JSON logs) at each service, enabling aggregation across services.
Not logging the JWT itself: never log the full JWT token — it's a secret equivalent to a session cookie. Log the non-sensitive claims (sub, exp, jti, aud) but not the token string.
How to use the JWT Decoder on sadiqbd.com
- For debugging microservice auth failures: decode the JWT at the point of failure to inspect which claims the service received — compare against expected claims (correct audience, correct scope, not expired)
- For M2M token inspection: verify
audandscopeclaims are correct for the target service; missing or wrong audience is a common M2M configuration error - For permission timing issues: check the
iat(issued at) andexptimes — if a permission change happened betweeniatandexp, the JWT may carry outdated claims; the time window tells you whether this is likely
Frequently Asked Questions
Should I encode all user permissions in the JWT, or just the user ID and look up permissions per request? For most applications, a hybrid approach works best: encode coarse-grained, rarely-changing information (user ID, organization ID, basic role category) in the JWT — enough for most authorization decisions without a database call. For fine-grained, frequently-changing permissions, look up from the database when needed. This balances the "stateless JWT" efficiency benefit against the "stale claims" risk. A "is this user authenticated?" check can use the JWT alone; "is this user allowed to delete this specific resource?" may warrant a database check, especially for high-stakes operations.
Is the JWT Decoder free? Yes — completely free, no sign-up required.
Try the JWT Decoder free at sadiqbd.com — decode and inspect any JWT's header, payload, and claims instantly.