JWT Security for Microservices: Complete Guide to Token Invalidation Strategies, Pros, Cons, and Trade-offs
JSON Web Tokens are everywhere. They are the default answer to “how do we authenticate users across microservices,” and for good reason — they are stateless, self-contained, and trivially verifiable without a database call. But that same statelessness is the source of their deepest security problem: once a JWT is issued, the server has no built-in way to take it back.
This post covers the complete picture — how JWTs work, how to secure the full authentication flow, and every practical strategy for token invalidation, with an honest accounting of the pros, cons, and trade-offs of each.
What Is a JWT, Precisely?
A JWT is three Base64URL-encoded JSON objects joined by dots:
header.payload.signature
Header — declares the token type and signing algorithm:
{
"alg": "RS256",
"typ": "JWT"
}
Payload — the claims: who the token is for, when it expires, and any application-specific data:
{
"sub": "user_01HXYZ",
"iss": "https://auth.example.com",
"aud": ["api.example.com"],
"iat": 1719532800,
"exp": 1719536400,
"jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"roles": ["user"],
"plan": "pro"
}
Signature — a cryptographic signature over the header and payload, produced by the issuing authority’s private key. Any service with the corresponding public key can verify it instantly, with no network round-trip.
The critical insight: the signature only proves the token has not been tampered with. It says nothing about whether the issuer still considers the token valid. That distinction is where almost every JWT security problem originates.
Choosing a Signing Algorithm
The choice of algorithm is the first security decision and one of the most consequential.
HS256 (HMAC-SHA256) — Symmetric
The same secret key is used to both sign and verify. Simple to implement; zero key distribution infrastructure.
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
The problem: Every service that verifies tokens must hold the secret. Any service that can verify can also forge. In a microservices architecture where services are built and deployed by different teams, this is a significant blast radius — a compromised inventory service can issue tokens claiming admin privileges.
RS256 (RSA-SHA256) — Asymmetric
The auth service signs with its private key. Every other service verifies with the corresponding public key. Services can verify but cannot forge.
// Signing (auth service only)
privateKey, _ := rsa.GenerateKey(rand.Reader, 2048)
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signed, _ := token.SignedString(privateKey)
// Verification (any service)
publicKey := &privateKey.PublicKey
parsed, _ := jwt.ParseWithClaims(signed, &Claims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return publicKey, nil
})
The tradeoff: RSA operations are computationally expensive and key rotation requires distributing new public keys to all services.
ES256 (ECDSA-SHA256) — Asymmetric, Faster
Same asymmetric security properties as RS256, with significantly shorter keys and faster verification. The preferred choice for new systems.
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
signed, _ := token.SignedString(privateKey)
Algorithm Confusion Attacks
Always explicitly validate the algorithm when parsing:
func keyFunc(token *jwt.Token) (any, error) {
// NEVER accept "none" or a different algorithm than expected
if token.Method.Alg() != jwt.SigningMethodES256.Alg() {
return nil, fmt.Errorf("unexpected signing method: %s", token.Method.Alg())
}
return publicKey, nil
}
A library that accepts whatever algorithm the token header declares is vulnerable to an attacker swapping RS256 for HS256 and signing with the public key — which the verifier then accepts as a valid HMAC secret.
Recommendation: Use ES256 for new systems. Use RS256 if your ecosystem has better library support. Never use HS256 in multi-service architectures.
The Complete Authentication Flow
┌──────────┐ 1. POST /login ┌─────────────┐
│ Client │ ─────────────────────────► │ Auth Service│
│ │ {email, password} │ │
│ │ │ 2. Verify │
│ │ │ bcrypt │
│ │ │ hash │
│ │ 3. {access_token, │ │
│ │ ◄── refresh_token} │ 4. Issue │
│ │ │ tokens │
└──────────┘ └─────────────┘
│
│ 5. GET /api/orders
│ Authorization: Bearer <access_token>
▼
┌─────────────────┐ 6. Verify sig ┌─────────────┐
│ API Gateway / │ ─────────────────►│ JWKS │
│ Middleware │ (cache pub key) │ Endpoint │
│ │ ◄─────────────────│ │
│ 7. Forward │ └─────────────┘
│ with claims │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Order Service │ (trusts claims from gateway,
│ │ no token verification needed)
└─────────────────┘
Step-by-step: Token Issuance
// auth/service.go
package auth
import (
"crypto/ecdsa"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
type Claims struct {
jwt.RegisteredClaims
UserID string `json:"sub"`
Roles []string `json:"roles"`
Plan string `json:"plan"`
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"` // seconds
}
type Service struct {
privateKey *ecdsa.PrivateKey
accessExpiry time.Duration
refreshExpiry time.Duration
refreshStore RefreshTokenStore // database
}
func (s *Service) IssueTokenPair(userID string, roles []string, plan string) (TokenPair, error) {
now := time.Now()
// Access token — short-lived, carries user context
accessClaims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
Issuer: "https://auth.example.com",
Audience: jwt.ClaimStrings{"api.example.com"},
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(s.accessExpiry)),
ID: uuid.New().String(), // jti — unique token ID
},
UserID: userID,
Roles: roles,
Plan: plan,
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, accessClaims)
signedAccess, err := accessToken.SignedString(s.privateKey)
if err != nil {
return TokenPair{}, fmt.Errorf("sign access token: %w", err)
}
// Refresh token — long-lived, opaque, stored server-side
refreshTokenID := uuid.New().String()
err = s.refreshStore.Store(RefreshRecord{
ID: refreshTokenID,
UserID: userID,
ExpiresAt: now.Add(s.refreshExpiry),
IssuedAt: now,
})
if err != nil {
return TokenPair{}, fmt.Errorf("store refresh token: %w", err)
}
return TokenPair{
AccessToken: signedAccess,
RefreshToken: refreshTokenID, // opaque random ID, not a JWT
ExpiresIn: int(s.accessExpiry.Seconds()),
}, nil
}
Step-by-step: Token Verification Middleware
// middleware/jwt.go
package middleware
import (
"context"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
type contextKey string
const ClaimsKey contextKey = "jwt_claims"
type Middleware struct {
publicKey *ecdsa.PublicKey
audience string
issuer string
}
func (m *Middleware) Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, err := extractBearerToken(r)
if err != nil {
http.Error(w, "missing or malformed Authorization header", http.StatusUnauthorized)
return
}
claims, err := m.parseAndValidate(raw)
if err != nil {
http.Error(w, "invalid token: "+err.Error(), http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), ClaimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (m *Middleware) parseAndValidate(raw string) (*Claims, error) {
claims := &Claims{}
_, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
// Reject unexpected algorithms
if t.Method.Alg() != jwt.SigningMethodES256.Alg() {
return nil, fmt.Errorf("unexpected alg: %s", t.Method.Alg())
}
return m.publicKey, nil
},
jwt.WithIssuer(m.issuer),
jwt.WithAudience(m.audience),
jwt.WithExpirationRequired(),
)
if err != nil {
return nil, err
}
return claims, nil
}
func extractBearerToken(r *http.Request) (string, error) {
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
return "", fmt.Errorf("no Bearer token")
}
return strings.TrimPrefix(header, "Bearer "), nil
}
Authorization: Role and Scope Checking
func RequireRole(role string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(ClaimsKey).(*Claims)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
for _, r := range claims.Roles {
if r == role {
next.ServeHTTP(w, r.Context())
return
}
}
http.Error(w, "forbidden", http.StatusForbidden)
})
}
}
JWKS: Distributing Public Keys
Rather than deploying public keys to every service by hand, the auth service exposes a JSON Web Key Set endpoint. Services fetch and cache it, and re-fetch on verification failure (to handle key rotation).
// auth/jwks_handler.go
package auth
import (
"crypto/ecdsa"
"encoding/json"
"math/big"
"net/http"
)
type JWK struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
Use string `json:"use"`
Kid string `json:"kid"` // key ID — matches token header "kid"
Alg string `json:"alg"`
}
type JWKS struct {
Keys []JWK `json:"keys"`
}
func JWKSHandler(publicKey *ecdsa.PublicKey, kid string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
jwk := JWK{
Kty: "EC",
Crv: "P-256",
X: base64URLEncode(publicKey.X.Bytes()),
Y: base64URLEncode(publicKey.Y.Bytes()),
Use: "sig",
Kid: kid,
Alg: "ES256",
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=3600")
json.NewEncoder(w).Encode(JWKS{Keys: []JWK{jwk}})
}
}
Consumer services cache the JWKS with the Cache-Control TTL and only re-fetch when they encounter a kid they do not recognise — a reliable signal that a key rotation has occurred.
The Core Problem: Stateless Tokens Cannot Be Revoked
Here is the fundamental tension. JWTs are valuable precisely because services can verify them without contacting the auth server — the signature is self-contained proof. But that independence means the auth server cannot reach into a service and say “that token is no longer valid.”
If a user:
- logs out,
- changes their password,
- has their account suspended,
- has their session compromised,
…the token they hold remains cryptographically valid until it expires. If the expiry is 24 hours, an attacker with a stolen token has up to 24 hours of access, regardless of anything you do on the server.
Every invalidation strategy is a different answer to the question: how much statefulness are we willing to reintroduce, and where?
Token Invalidation Strategy 1: Short-Lived Access Tokens
The simplest strategy. Set access token expiry to a small window — 5 to 15 minutes. Pair with a refresh token flow.
const (
AccessTokenExpiry = 15 * time.Minute
RefreshTokenExpiry = 30 * 24 * time.Hour // 30 days
)
After the access token expires, the client exchanges its refresh token for a new pair:
// auth/refresh.go
func (s *Service) Refresh(refreshTokenID string) (TokenPair, error) {
record, err := s.refreshStore.Get(refreshTokenID)
if err != nil || record == nil {
return TokenPair{}, fmt.Errorf("refresh token not found or expired")
}
if time.Now().After(record.ExpiresAt) {
s.refreshStore.Delete(refreshTokenID)
return TokenPair{}, fmt.Errorf("refresh token expired")
}
if record.Revoked {
s.refreshStore.Delete(refreshTokenID)
return TokenPair{}, fmt.Errorf("refresh token revoked")
}
// Refresh token rotation — issue new refresh token, invalidate old one
s.refreshStore.Delete(refreshTokenID)
user, _ := s.userStore.Get(record.UserID)
return s.IssueTokenPair(user.ID, user.Roles, user.Plan)
}
Refresh token rotation (delete the old refresh token on every use) is important: if a refresh token is stolen, its first use by an attacker invalidates the legitimate user’s token — and vice versa. Either way, the anomaly surfaces.
Pros
- No infrastructure beyond what you already have. Pure JWT statelessness during the access token window.
- Compromise window is bounded by the access token TTL (15 minutes, not days).
- Refresh tokens can be revoked server-side instantly.
Cons
- Does not prevent misuse of a valid access token during its TTL. A user who logs out is still authenticated for up to 15 minutes.
- Requires clients to implement the refresh flow correctly — mobile and browser clients often get this wrong.
- Shorter expiry means more refresh traffic to the auth service.
Trade-off
Short expiry is the lowest-cost strategy and should be your baseline. The 15-minute window is acceptable for most applications. For higher-security scenarios (banking, admin panels), combine it with another strategy.
Token Invalidation Strategy 2: Token Blocklist (Denylist)
Maintain a store of revoked token IDs (jti claims). On every request, after verifying the signature, check whether the token’s jti is on the blocklist.
// blocklist/redis.go
package blocklist
import (
"context"
"time"
"github.com/redis/go-redis/v9"
)
type RedisBlocklist struct {
client *redis.Client
}
func (b *RedisBlocklist) Revoke(ctx context.Context, jti string, expiresAt time.Time) error {
// Store the jti with a TTL matching the token's own expiry.
// No point keeping it longer — an expired token is already rejected.
ttl := time.Until(expiresAt)
if ttl <= 0 {
return nil // already expired, nothing to do
}
return b.client.Set(ctx, "blocklist:"+jti, "1", ttl).Err()
}
func (b *RedisBlocklist) IsRevoked(ctx context.Context, jti string) (bool, error) {
val, err := b.client.Get(ctx, "blocklist:"+jti).Result()
if err == redis.Nil {
return false, nil // not on blocklist
}
if err != nil {
return false, err
}
return val == "1", nil
}
Updated middleware:
func (m *Middleware) Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, err := extractBearerToken(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
claims, err := m.parseAndValidate(raw)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
// Blocklist check
revoked, err := m.blocklist.IsRevoked(r.Context(), claims.ID)
if err != nil {
// Fail closed: if Redis is down, reject the request
http.Error(w, "service unavailable", http.StatusServiceUnavailable)
return
}
if revoked {
http.Error(w, "token revoked", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), ClaimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Logout becomes explicit:
func (s *AuthService) Logout(ctx context.Context, accessToken string) error {
claims, err := s.parseToken(accessToken)
if err != nil {
return err
}
// Blocklist the access token
if err := s.blocklist.Revoke(ctx, claims.ID, claims.ExpiresAt.Time); err != nil {
return fmt.Errorf("revoke access token: %w", err)
}
// Revoke the associated refresh token
return s.refreshStore.RevokeForUser(ctx, claims.UserID)
}
Pros
- Immediate, precise revocation. A logout takes effect on the next request.
- Handles compromise, password change, account suspension instantly.
- The blocklist only needs to hold tokens until they expire naturally — entries are self-cleaning with TTLs.
Cons
- Every request now has a Redis round-trip. You have reintroduced network latency and a dependency into what was a stateless check.
- Redis becomes a critical dependency. If it is unavailable, you must decide: fail open (security hole) or fail closed (availability hole). Fail closed is almost always correct.
- At high request volume (millions of requests per second), Redis throughput may become a bottleneck, though Redis handles hundreds of thousands of operations per second on modest hardware.
Trade-off
The blocklist is the most common production choice for applications that need real-time revocation. The Redis round-trip (typically under 1ms on a local network) is a modest and well-understood cost. The availability dependency on Redis is real — mitigate it with Redis Sentinel or Cluster, and design your failure mode deliberately.
Token Invalidation Strategy 3: Allowlist (Token Registry)
Instead of tracking revoked tokens, track only valid tokens. Every issued token is stored; verification requires the token to be present in the store.
func (s *Service) IssueToken(...) (string, error) {
// ... build claims ...
signed, _ := token.SignedString(s.privateKey)
// Register the token
s.tokenStore.Register(TokenRecord{
JTI: claims.ID,
UserID: claims.UserID,
ExpiresAt: claims.ExpiresAt.Time,
})
return signed, nil
}
// Verification adds one check:
valid, _ := s.tokenStore.Exists(claims.ID)
if !valid {
return errors.New("token not registered")
}
Logout deletes the token record — it is immediately invalid.
Pros
- Tightest control. No issued token exists without the server knowing about it.
- Revocation is instant and absolute.
- Supports sophisticated session management: list all active sessions, selectively revoke by device or location.
Cons
- Every request hits the store — no stateless verification possible anywhere in the system.
- The store must be low-latency and highly available. It is on the hot path of every authenticated request.
- At scale, the store must hold one entry per active token across the entire user base.
- This is effectively session management under a different name. You have surrendered the primary benefit of JWTs.
Trade-off
The allowlist is the right choice when you need the payload portability of JWTs (carrying claims across services) but your threat model requires immediate revocation with no window of misuse. Banks, healthcare systems, and admin tooling often land here. For most consumer applications, the blocklist is a better balance.
Token Invalidation Strategy 4: User Version / Generation Counter
Embed a version number in the JWT that must match the current version stored against the user. Revoking all of a user’s tokens is a single database write.
type Claims struct {
jwt.RegisteredClaims
UserID string `json:"sub"`
Version int `json:"ver"` // token generation
}
// When issuing a token:
user, _ := s.userStore.Get(userID)
claims := Claims{
UserID: user.ID,
Version: user.TokenVersion, // current generation
// ...
}
// When verifying:
func (m *Middleware) validateVersion(ctx context.Context, claims *Claims) error {
currentVersion, err := m.userStore.GetTokenVersion(ctx, claims.UserID)
if err != nil {
return err
}
if claims.Version != currentVersion {
return fmt.Errorf("token version mismatch: token=%d current=%d",
claims.Version, currentVersion)
}
return nil
}
// Revoking all tokens (logout everywhere, password change):
func (s *Service) RevokeAllTokens(ctx context.Context, userID string) error {
return s.userStore.IncrementTokenVersion(ctx, userID)
}
Pros
- A single database write invalidates every token ever issued to a user — across all devices simultaneously.
- Excellent for “logout everywhere,” password reset, and account suspension.
- The user store is usually already a dependency; no additional infrastructure needed.
- Very small storage overhead — one integer per user.
Cons
- Requires a database lookup on every request — similar overhead to a blocklist check.
- You cannot selectively revoke a single token (for example, to log out of one device but not others) without more granular tracking.
- Does not help if your threat model requires revoking a specific token without revoking all tokens for that user.
Trade-off
The version counter is elegant when the primary use case is revoking all sessions — password change, account compromise, “sign out everywhere.” It pairs well with short-lived access tokens: a version check on refresh is enough to prevent new access tokens from being issued after a revocation.
Token Invalidation Strategy 5: Key Rotation
Instead of revoking individual tokens, rotate the signing key. Tokens signed with the old key immediately fail verification.
// auth/key_manager.go
package auth
type KeyManager struct {
mu sync.RWMutex
currentKey *ecdsa.PrivateKey
currentKID string
previousKey *ecdsa.PublicKey // kept briefly for graceful rotation
previousKID string
}
func (km *KeyManager) Sign(claims jwt.Claims) (string, error) {
km.mu.RLock()
key := km.currentKey
kid := km.currentKID
km.mu.RUnlock()
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = kid
return token.SignedString(key)
}
func (km *KeyManager) PublicKeyFor(kid string) (*ecdsa.PublicKey, bool) {
km.mu.RLock()
defer km.mu.RUnlock()
if kid == km.currentKID {
return &km.currentKey.PublicKey, true
}
if kid == km.previousKID && km.previousKey != nil {
return km.previousKey, true
}
return nil, false
}
func (km *KeyManager) Rotate(newKey *ecdsa.PrivateKey) {
km.mu.Lock()
defer km.mu.Unlock()
// Retain the old public key briefly so in-flight tokens stay valid
km.previousKey = &km.currentKey.PublicKey
km.previousKID = km.currentKID
km.currentKey = newKey
km.currentKID = uuid.New().String()
}
Expose the new key via JWKS. Services re-fetch on unknown kid. Retire the previous key after a grace period matching the access token TTL.
Pros
- Emergency response: a compromised private key is immediately neutralised by rotation.
- Invalidates all outstanding tokens in one operation.
- No per-request database or cache lookup.
Cons
- A blunt instrument — all users are logged out simultaneously. Unsuitable for routine revocation.
- Requires all services to handle unknown
kidgracefully and re-fetch JWKS promptly. - Grace period management is fiddly; too short and valid tokens are rejected, too long and compromised tokens stay valid.
Trade-off
Key rotation is a security hygiene practice (rotate keys every 90 days) and an emergency lever (rotate immediately on key compromise). It is not a replacement for per-token or per-user revocation strategies. Use it alongside one of the strategies above.
Combining Strategies: What Production Looks Like
No single strategy covers every scenario. Production systems layer them:
┌─────────────────────────────────────────────────────────────┐
│ Security Layers │
│ │
│ 1. Short expiry (15 min) ← baseline bound on window │
│ 2. Refresh token rotation ← detect stolen sessions │
│ 3. Blocklist on logout ← immediate revocation │
│ 4. Version counter on ← password change / │
│ password change ← suspend account │
│ 5. Key rotation (90 days) ← hygiene + emergency │
└─────────────────────────────────────────────────────────────┘
A concrete event-to-strategy mapping:
| Event | Strategy |
|---|---|
| User logs out | Blocklist jti + revoke refresh token |
| User changes password | Increment token version + revoke all refresh tokens |
| Account suspended | Increment token version |
| Device stolen / “logout everywhere” | Increment token version |
| Signing key compromised | Emergency key rotation |
| Routine security hygiene | Scheduled key rotation (90 days) |
| Token stolen (unknown) | Short expiry limits blast radius |
Token Storage on the Client
Where the client stores the token determines a large part of your attack surface.
localStorage / sessionStorage
Easy to implement; accessible from JavaScript. Vulnerable to XSS: any injected script can read and exfiltrate the token.
Memory (JavaScript variable)
Not persisted across page loads. Immune to XSS (no script injection reaches it), immune to CSRF. Requires silent refresh on page reload (use an iframe or background request). The best option for SPAs with strict security requirements.
HttpOnly Cookie
Cannot be read by JavaScript at all — immune to XSS. Requires CSRF protection (SameSite=Strict or a CSRF token for cross-origin requests). Works naturally for server-rendered applications.
// Setting the access token in an HttpOnly cookie
http.SetCookie(w, &http.Cookie{
Name: "access_token",
Value: signedToken,
Path: "/",
HttpOnly: true, // no JavaScript access
Secure: true, // HTTPS only
SameSite: http.SameSiteStrictMode,
MaxAge: int(AccessTokenExpiry.Seconds()),
})
Recommendation by context:
| Context | Recommended storage |
|---|---|
| SPA (React, Vue) — high security | Memory + refresh via HttpOnly cookie |
| SPA — pragmatic | HttpOnly cookie |
| Mobile app | Secure OS keychain / keystore |
| Server-side rendered | HttpOnly cookie |
| Machine-to-machine | Environment variable / secrets manager |
Hardening Checklist
Beyond the auth flow and revocation strategies, these controls meaningfully reduce your attack surface:
Token claims
- Always set
exp. A JWT without an expiry is valid forever. - Always set
issandaud. Validate both on every request. A token issued by your staging environment should not be accepted in production. - Always set
jti(a UUID). Without it, you cannot implement a blocklist and cannot reason about individual tokens.
Transport
- HTTPS everywhere. A JWT intercepted in transit is as good as a stolen password.
- Set
Secureon cookies unconditionally.
Secrets and keys
- HS256 secrets: minimum 256 bits of entropy, stored in a secrets manager (AWS Secrets Manager, HashiCorp Vault), never in environment variables on shared hosts.
- RS256/ES256 private keys: generated with a cryptographically secure RNG, stored in a hardware security module (HSM) or managed key service (AWS KMS) for production.
Claims hygiene
- Never store sensitive data in the payload. The payload is Base64-encoded, not encrypted — anyone can decode it. Store the minimum needed: a user ID, roles, plan tier.
- If you need encrypted claims, use JWE (JSON Web Encryption) rather than JWT.
Library hygiene
- Use a well-maintained JWT library. Avoid rolling your own parsing.
- Pin library versions and audit changelogs for security patches.
- Validate
algexplicitly in the key function (demonstrated above).
Pros and Cons: JWT vs. Opaque Session Tokens
It is worth stepping back to compare the fundamental choice.
| Factor | JWT | Opaque Session Token |
|---|---|---|
| Verification | Stateless — signature check only | Stateful — database/cache lookup |
| Revocation | Difficult without extra infrastructure | Trivial — delete the session |
| Scalability | Excellent — no shared state needed | Requires shared session store |
| Payload portability | Claims travel with the token | Must look up user data per request |
| Token size | Larger (hundreds of bytes) | Small (random string) |
| Microservices fit | Strong — services verify independently | Weaker — all services need session store access |
| Security default | Weaker — cannot revoke without strategy | Stronger — revocation is built-in |
JWTs are not strictly better than sessions. They are better for distributed systems where services need to verify identity independently. Sessions are better for monoliths or simple APIs where centralised revocation is more important than distributed verification.
Many real systems use both: JWTs for service-to-service authentication inside the cluster, and opaque tokens or session cookies for the end-user boundary where revocation requirements are highest.
Key Takeaways
JWT security is not a single decision — it is a stack of decisions, each with its own trade-offs:
- Use ES256, not HS256, in any multi-service architecture. Asymmetric keys mean services can verify without being able to forge.
- Always validate
alg,iss,aud, andexpexplicitly. Libraries that trust the token header are the root cause of algorithm confusion attacks. - Short access token expiry is your free first line of defence. Fifteen minutes is a reasonable default. It does not require any additional infrastructure.
- Choose a revocation strategy based on your threat model, not convenience. Blocklist for immediate logout; version counter for compromise scenarios; key rotation for hygiene and emergencies.
- Refresh tokens must be opaque, server-side, and rotated on use. They are the long-lived credential; treat them with the same care as a password.
- Never put sensitive data in the JWT payload. It is encoded, not encrypted. Everyone who holds the token can read it.
- Store tokens in HttpOnly cookies or memory in the browser. localStorage is the easiest choice and the most dangerous one.
- Design your failure mode for blocklist/allowlist checks. Fail closed. An unavailable Redis is not a reason to skip security checks.
The statelessness of JWTs is a performance optimisation, not a security feature. Security requires understanding exactly where you have reintroduced state, why, and what happens when that state is unavailable.