I wrote about JWTs and microservices years ago. The basic idea still holds: a service can validate a signed token locally and does not need to call the authorization server on every request.
That is useful. It is not automatically a good architecture.
sequenceDiagram
autonumber
participant User
participant Client
participant Auth as Authorization Server
participant API as Orders API
participant JWKS as JWKS endpoint
User->>Client: Sign in
Client->>Auth: Request access token
Auth-->>Client: Short-lived JWT
Client->>API: API call with Bearer JWT
API->>JWKS: Fetch public key by kid
JWKS-->>API: Public key
API->>API: Verify signature, alg, iss, aud, exp
API-->>Client: Response
Note over API,JWKS: JWKS is cached. The API should not call this endpoint on every request.
The practical rule
Use JWT access tokens when local validation is worth the operational cost.
Good cases:
- many services need to validate the same user context;
- the authorization service should not be a runtime dependency for every API request;
- services are deployed across boundaries where a compact signed assertion is useful;
- permissions are coarse-grained and can be slightly stale for a few minutes.
Bad cases:
- you need immediate logout or immediate permission revocation;
- claims change often;
- tokens would need to contain too much user or business data;
- you do not have solid key rotation and token validation discipline.
In those cases an opaque access token plus introspection can be simpler. A database lookup or introspection call is not automatically bad. It is a trade-off.
Signed is not encrypted
Most JWT access tokens are JWS tokens: signed, not encrypted. Base64URL is only encoding. Anyone with the token can decode the header and payload.
So do not put secrets into a JWT. Be careful even with email addresses, tenant names, internal ids, roles, or feature flags. The token often ends up in logs, browser storage, traces, support tickets, and screenshots.
If the data must be confidential, either do not put it into the token or use JWE. In most API systems the better answer is: keep the token small.
Validate like you mean it
A service should not just parse a JWT. It must validate it.
At minimum check:
- signature;
- allowed algorithm, configured on the server side;
iss— expected issuer;aud— this API must be the intended audience;exp— expiration;nbf, if used;- token type or profile, if your system uses more than one kind of JWT;
- scopes, roles, or permissions needed for the endpoint.
The four registered claims above are small, but important:
issmeans issuer. It identifies the authority that created the token, for examplehttps://auth.example.com/. Your API should accept tokens only from issuers it explicitly trusts.audmeans audience. It says who the token is for. An access token forbilling-apishould not be accepted byorders-api, even if the signature is valid.expmeans expires at. After this timestamp the token must be rejected. This is the main safety mechanism for self-contained tokens.nbfmeans not before. Before this timestamp the token is not valid yet. It is optional, but useful when tokens should only become active at a specific time.
alg is the algorithm value in the JWT header, for example RS256 or HS256. Treat it as untrusted input: an attacker controls the header bytes, so your server must decide which algorithm is allowed from configuration, not from the token itself.
Do not trust the alg header as a decision source. Do not accept none. Do not let a token issued for Service A be replayed to Service B. That is exactly what aud is for.
Small validation examples
These examples assume an RS256 access token. In production, pick the public key by kid from your trusted JWKS and cache it. The important part is the same in every language: fix the accepted algorithm, verify the signature, issuer, audience, and expiration.
Java, using com.auth0:java-jwt and com.auth0:jwks-rsa:
String issuer = "https://auth.example.com/";
String audience = "orders-api";
JwkProvider jwks = new UrlJwkProvider(new URL(issuer));
DecodedJWT untrusted = JWT.decode(token); // only for kid lookup, not for claims
Jwk jwk = jwks.get(untrusted.getKeyId());
Algorithm alg = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey(), null);
DecodedJWT jwt = JWT.require(alg)
.withIssuer(issuer)
.withAudience(audience)
.acceptLeeway(30)
.build()
.verify(token);
String userId = jwt.getSubject();
Go, using github.com/golang-jwt/jwt/v5:
claims := &jwt.RegisteredClaims{}
parsed, err := jwt.ParseWithClaims(rawToken, claims, func(t *jwt.Token) (any, error) {
if t.Method != jwt.SigningMethodRS256 {
return nil, fmt.Errorf("unexpected alg: %s", t.Header["alg"])
}
kid, _ := t.Header["kid"].(string)
key := publicKeysByKid[kid] // loaded from trusted JWKS
if key == nil {
return nil, fmt.Errorf("unknown kid: %s", kid)
}
return key, nil
}, jwt.WithIssuer("https://auth.example.com/"),
jwt.WithAudience("orders-api"),
jwt.WithExpirationRequired())
if err != nil || !parsed.Valid {
return err
}
userId := claims.Subject
TypeScript, using jose:
import { createRemoteJWKSet, jwtVerify } from "jose";
const issuer = "https://auth.example.com/";
const audience = "orders-api";
const jwks = createRemoteJWKSet(new URL(`${issuer}.well-known/jwks.json`));
const { payload } = await jwtVerify(token, jwks, {
issuer,
audience,
algorithms: ["RS256"],
clockTolerance: "30s",
});
const userId = payload.sub;
These snippets are intentionally small. Real code should also decide how to handle scopes, roles, tenant boundaries, logging, metrics, and JWKS refresh failures.
Prefer asymmetric signing for many services
HMAC is fine for small systems, but it has an ugly property: every verifier also has the secret needed to mint tokens.
For a larger microservice landscape, prefer asymmetric signing:
- authorization server signs with a private key;
- services validate with public keys;
- public keys are exposed via JWKS;
- tokens carry
kidso services can pick the right key; - services cache keys and tolerate rotation overlap.
Key rotation is not a side topic. It is part of the design. If you cannot rotate keys without a production incident, the JWT setup is not finished.
Keep access tokens short-lived
JWT revocation is hard because the token is self-contained. Once issued, it is valid until it expires unless every service checks some central revocation state. At that point you have partly rebuilt reference-token validation.
The common pattern is:
- short-lived access tokens, often minutes;
- refresh tokens handled only by the authorization server;
- refresh token rotation;
- denylist only for exceptional cases;
- emergency key rotation for serious incidents.
Short lifetime is not a silver bullet, but it limits damage.
Service-to-service calls
Do not blindly forward the user’s browser token through the whole backend.
A downstream service needs to know two things:
- which workload is calling;
- on whose behalf the call is made, if there is a user.
Those are different identities. Use mTLS, workload identity, SPIFFE/SPIRE, or client credentials for service identity. Use user tokens or token exchange for delegated user context.
For complex call chains, OAuth 2.0 Token Exchange is often cleaner than passing one broad token everywhere. The downstream token can have the right audience and reduced scope.
My current default
For browser-facing applications I usually prefer:
- authorization code flow with PKCE;
- short-lived access tokens;
- refresh tokens kept away from frontend JavaScript where possible;
- JWT access tokens only when APIs benefit from local validation;
- opaque tokens when central control and revocation matter more.
For internal service calls I separate service identity from user delegation. A JWT can carry the delegation context, but it should not replace workload authentication.
JWTs are boring infrastructure. That is a good thing. Use them deliberately, validate them strictly, rotate keys, keep claims small, and do not sell them as stateless magic.