Skip to content
Alexander Holbreich
Go back

JWTs in 2026: useful, but not a default

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:

Bad cases:

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:

The four registered claims above are small, but important:

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:

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 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:

  1. which workload is calling;
  2. 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:

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.

References


Share this post on:

Previous Post
How I migrated this blog to Astro with Codex
Next Post
Architecture Decision Records: A Tool for Experienced Engineers