All articles
Authentication Security Microservices Architecture

JWT for Service-to-Service Authentication Server-to-Server Authentication in Microservices

Palakorn Voramongkol
April 26, 2025 13 min read

“A comprehensive implementation guide to JWT for service-to-service authentication — covering signed JWTs between services, asymmetric keys (RS256/ES256) for distributed verification, token propagation patterns, and Node.js implementation.”

Deep Dive: JWT for Service-to-Service Authentication

What is JWT Service-to-Service Authentication?

JSON Web Tokens (JWTs) are most commonly discussed in the context of user authentication — a user logs in, gets a token, and presents it to APIs. But JWTs are equally powerful for service-to-service authentication: a service signs a JWT asserting its own identity (or the identity of the request it’s proxying), and the receiving service verifies that JWT independently without calling any central server.

The key difference from the user auth pattern is the signer. In user auth, an authorization server (Auth0, Keycloak) signs the token. In service-to-service JWT auth, the calling service signs its own tokens using its private key, and any other service can verify those tokens using the matching public key.

This creates a decentralized authentication model: there’s no central token server in the hot path. Services are self-sovereign identity issuers, and trust is established by distributing public keys. It combines the simplicity of shared-key approaches with the scalability of distributed verification.

Core Principles

  • Asymmetric signing (RS256 or ES256): The calling service holds a private key for signing. Any downstream service can verify using only the public key — no secret distribution.
  • Self-contained tokens: The JWT payload carries service identity, target audience, expiry, and any needed context claims.
  • Short-lived tokens: Service JWTs should expire in minutes (1-15 min), not hours. Short TTL limits replay windows.
  • Audience restriction: The aud claim limits which services can accept a given token — a token for payment-service is rejected by inventory-service.
  • Stateless verification: No database or network call needed to verify a token — just the public key.
  • Token propagation: Services can pass JWT context downstream so the entire call chain knows the ultimate origin.

Authentication Flow

sequenceDiagram
    participant OS as Order Service
    participant PS as Payment Service
    participant IS as Inventory Service
    participant JWKS as JWKS Endpoint (order-service/.well-known/jwks.json)

    Note over OS: Wants to call Payment Service
    OS->>OS: Sign JWT with own private key
    Note right of OS: iss: order-service\nsub: order-service\naud: payment-service\nexp: now+5min\njti: unique-id

    OS->>PS: POST /api/payments/charge
    Note right of OS: Authorization: Bearer eyJhbG...

    PS->>JWKS: GET /.well-known/jwks.json (cached)
    JWKS-->>PS: {"keys": [{kid, n, e, ...}]}
    PS->>PS: Verify JWT signature using public key
    PS->>PS: Validate iss=order-service, aud=payment-service, exp
    PS-->>OS: 200 OK

    Note over PS: Calls Inventory Service on behalf of Order Service
    PS->>PS: Sign new JWT or forward original token
    PS->>IS: GET /api/inventory/check
    Note right of PS: Authorization: Bearer [new JWT or forwarded]
    IS->>IS: Verify JWT
    IS-->>PS: Available stock

Part 1: Key Pair Management

Generating Service Key Pairs

Each service that issues JWTs needs its own RSA or EC key pair:

import crypto from 'crypto';
import fs from 'fs';
import { promisify } from 'util';

const generateKeyPair = promisify(crypto.generateKeyPair);

// ============================================================================
// Key Generation Utilities
// ============================================================================

interface ServiceKeyPair {
  privateKeyPem: string;
  publicKeyPem: string;
  keyId: string;  // Unique identifier for this key (for rotation)
}

async function generateServiceKeyPair(algorithm: 'rsa' | 'ec' = 'ec'): Promise<ServiceKeyPair> {
  const keyId = crypto.randomBytes(8).toString('hex');

  if (algorithm === 'ec') {
    // ES256: smaller keys, faster operations — preferred for high-throughput services
    const { privateKey, publicKey } = await generateKeyPair('ec', {
      namedCurve: 'P-256',
      publicKeyEncoding: { type: 'spki', format: 'pem' },
      privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
    });

    return { privateKeyPem: privateKey, publicKeyPem: publicKey, keyId };
  } else {
    // RS256: widely supported, larger keys — use for broader compatibility
    const { privateKey, publicKey } = await generateKeyPair('rsa', {
      modulusLength: 2048,
      publicKeyEncoding: { type: 'spki', format: 'pem' },
      privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
    });

    return { privateKeyPem: privateKey, publicKeyPem: publicKey, keyId };
  }
}

// ============================================================================
// Convert PEM public key to JWK (for JWKS endpoint)
// ============================================================================

import { createPublicKey } from 'crypto';

function pemToJwk(publicKeyPem: string, keyId: string, algorithm: 'ES256' | 'RS256'): object {
  const keyObject = createPublicKey(publicKeyPem);
  const jwk = keyObject.export({ format: 'jwk' });

  return {
    ...jwk,
    kid: keyId,
    alg: algorithm,
    use: 'sig',
  };
}
// Maven: com.nimbusds:nimbus-jose-jwt
import com.nimbusds.jose.jwk.ECKey;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.JWSAlgorithm;

import java.security.KeyPairGenerator;
import java.security.KeyPair;
import java.security.SecureRandom;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.ECGenParameterSpec;
import java.util.HexFormat;
import java.util.Map;

public class ServiceKeyUtils {

    public record ServiceKeyPair(String privateKeyPem, String publicKeyPem, String keyId) {}

    // =========================================================================
    // Key Generation Utilities
    // =========================================================================

    public static ServiceKeyPair generateServiceKeyPair(String algorithm) throws Exception {
        byte[] idBytes = new byte[8];
        new SecureRandom().nextBytes(idBytes);
        String keyId = HexFormat.of().formatHex(idBytes);

        if ("ec".equalsIgnoreCase(algorithm)) {
            // ES256: smaller keys, faster operations
            KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
            kpg.initialize(new ECGenParameterSpec("secp256r1"));
            KeyPair kp = kpg.generateKeyPair();

            String privatePem = toPem("PRIVATE KEY",
                java.util.Base64.getEncoder().encode(kp.getPrivate().getEncoded()));
            String publicPem  = toPem("PUBLIC KEY",
                java.util.Base64.getEncoder().encode(kp.getPublic().getEncoded()));
            return new ServiceKeyPair(privatePem, publicPem, keyId);

        } else {
            // RS256: widely supported, larger keys
            KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
            kpg.initialize(2048);
            KeyPair kp = kpg.generateKeyPair();

            String privatePem = toPem("PRIVATE KEY",
                java.util.Base64.getEncoder().encode(kp.getPrivate().getEncoded()));
            String publicPem  = toPem("PUBLIC KEY",
                java.util.Base64.getEncoder().encode(kp.getPublic().getEncoded()));
            return new ServiceKeyPair(privatePem, publicPem, keyId);
        }
    }

    // =========================================================================
    // Convert public key to JWK (for JWKS endpoint)
    // =========================================================================

    public static Map<String, Object> pemToJwk(
            java.security.PublicKey publicKey,
            String keyId,
            String algorithm) throws Exception {

        if (publicKey instanceof ECPublicKey ecPub) {
            ECKey jwk = new ECKey.Builder(com.nimbusds.jose.jwk.Curve.P_256, ecPub)
                .keyID(keyId)
                .algorithm(JWSAlgorithm.ES256)
                .keyUse(KeyUse.SIGNATURE)
                .build();
            return jwk.toJSONObject();
        } else {
            RSAKey jwk = new RSAKey.Builder((RSAPublicKey) publicKey)
                .keyID(keyId)
                .algorithm(JWSAlgorithm.RS256)
                .keyUse(KeyUse.SIGNATURE)
                .build();
            return jwk.toJSONObject();
        }
    }

    private static String toPem(String type, byte[] encoded) {
        return "-----BEGIN " + type + "-----\n"
            + new String(encoded) + "\n"
            + "-----END " + type + "-----\n";
    }
}
# pip install cryptography pyjwt
import os
import secrets
from dataclasses import dataclass
from cryptography.hazmat.primitives.asymmetric import ec, rsa, padding
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
import json, base64

@dataclass
class ServiceKeyPair:
    private_key_pem: str
    public_key_pem: str
    key_id: str

# =============================================================================
# Key Generation Utilities
# =============================================================================

def generate_service_key_pair(algorithm: str = "ec") -> ServiceKeyPair:
    key_id = secrets.token_hex(8)

    if algorithm == "ec":
        # ES256: smaller keys, faster operations
        private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
    else:
        # RS256: widely supported, larger keys
        private_key = rsa.generate_private_key(
            public_exponent=65537, key_size=2048, backend=default_backend()
        )

    private_pem = private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.PKCS8,
        encryption_algorithm=serialization.NoEncryption(),
    ).decode()

    public_pem = private_key.public_key().public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo,
    ).decode()

    return ServiceKeyPair(private_key_pem=private_pem, public_key_pem=public_pem, key_id=key_id)

# =============================================================================
# Convert public key to JWK (for JWKS endpoint)
# =============================================================================

def pem_to_jwk(public_key_pem: str, key_id: str, algorithm: str) -> dict:
    """Convert a PEM public key to JWK format."""
    from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
    from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
    from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat

    public_key = serialization.load_pem_public_key(public_key_pem.encode())

    if isinstance(public_key, EllipticCurvePublicKey):
        pub_numbers = public_key.public_key().public_numbers()
        key_size = (public_key.key_size + 7) // 8
        x = base64.urlsafe_b64encode(pub_numbers.x.to_bytes(key_size, "big")).rstrip(b"=").decode()
        y = base64.urlsafe_b64encode(pub_numbers.y.to_bytes(key_size, "big")).rstrip(b"=").decode()
        return {"kty": "EC", "crv": "P-256", "x": x, "y": y,
                "kid": key_id, "alg": algorithm, "use": "sig"}
    else:
        pub_numbers = public_key.public_numbers()
        def int_to_b64(n):
            length = (n.bit_length() + 7) // 8
            return base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode()
        return {"kty": "RSA", "n": int_to_b64(pub_numbers.n), "e": int_to_b64(pub_numbers.e),
                "kid": key_id, "alg": algorithm, "use": "sig"}
// dotnet add package Microsoft.IdentityModel.Tokens System.IdentityModel.Tokens.Jwt
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;
using System.Text.Json;

public record ServiceKeyPair(string PrivateKeyPem, string PublicKeyPem, string KeyId);

public static class ServiceKeyUtils
{
    // =========================================================================
    // Key Generation Utilities
    // =========================================================================

    public static ServiceKeyPair GenerateServiceKeyPair(string algorithm = "ec")
    {
        string keyId = Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLower();

        if (algorithm == "ec")
        {
            // ES256: smaller keys, faster operations
            using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
            string privatePem = ecdsa.ExportPkcs8PrivateKeyPem();
            string publicPem  = ecdsa.ExportSubjectPublicKeyInfoPem();
            return new ServiceKeyPair(privatePem, publicPem, keyId);
        }
        else
        {
            // RS256: widely supported, larger keys
            using var rsa = RSA.Create(2048);
            string privatePem = rsa.ExportPkcs8PrivateKeyPem();
            string publicPem  = rsa.ExportSubjectPublicKeyInfoPem();
            return new ServiceKeyPair(privatePem, publicPem, keyId);
        }
    }

    // =========================================================================
    // Convert public key to JWK (for JWKS endpoint)
    // =========================================================================

    public static Dictionary<string, object> PemToJwk(
        string publicKeyPem, string keyId, string algorithm)
    {
        if (algorithm == "ES256")
        {
            using var ecdsa = ECDsa.Create();
            ecdsa.ImportFromPem(publicKeyPem);
            var p = ecdsa.ExportParameters(false);
            string x = Base64UrlEncoder.Encode(p.Q.X!);
            string y = Base64UrlEncoder.Encode(p.Q.Y!);
            return new() {
                ["kty"] = "EC", ["crv"] = "P-256",
                ["x"] = x, ["y"] = y,
                ["kid"] = keyId, ["alg"] = algorithm, ["use"] = "sig"
            };
        }
        else
        {
            using var rsa = RSA.Create();
            rsa.ImportFromPem(publicKeyPem);
            var p = rsa.ExportParameters(false);
            string n = Base64UrlEncoder.Encode(p.Modulus!);
            string e = Base64UrlEncoder.Encode(p.Exponent!);
            return new() {
                ["kty"] = "RSA", ["n"] = n, ["e"] = e,
                ["kid"] = keyId, ["alg"] = algorithm, ["use"] = "sig"
            };
        }
    }
}

Loading Keys at Startup

import { SignJWT, importPKCS8, importSPKI, JWTPayload } from 'jose';

// ============================================================================
// Key Loader (from env or secrets manager)
// ============================================================================

interface ServiceKeyConfig {
  privateKeyPem: string;
  publicKeyPem: string;
  keyId: string;
  algorithm: 'ES256' | 'RS256';
  serviceName: string;
}

class ServiceIdentity {
  private privateKey: Awaited<ReturnType<typeof importPKCS8>>;
  private publicKey: Awaited<ReturnType<typeof importSPKI>>;
  private config: ServiceKeyConfig;

  private constructor(
    config: ServiceKeyConfig,
    privateKey: any,
    publicKey: any
  ) {
    this.config = config;
    this.privateKey = privateKey;
    this.publicKey = publicKey;
  }

  static async create(config: ServiceKeyConfig): Promise<ServiceIdentity> {
    const alg = config.algorithm;
    const privateKey = await importPKCS8(config.privateKeyPem, alg);
    const publicKey = await importSPKI(config.publicKeyPem, alg);
    return new ServiceIdentity(config, privateKey, publicKey);
  }

  // Sign a new service JWT targeting a specific audience
  async signToken(
    audience: string,
    customClaims: Record<string, unknown> = {},
    ttlSeconds = 300 // 5 minutes default
  ): Promise<string> {
    return new SignJWT({
      ...customClaims,
      // Standard service identity claims
      iss: this.config.serviceName,
      sub: this.config.serviceName,
    })
      .setProtectedHeader({
        alg: this.config.algorithm,
        kid: this.config.keyId,
        typ: 'JWT',
      })
      .setAudience(audience)
      .setIssuedAt()
      .setExpirationTime(`${ttlSeconds}s`)
      .setJti(crypto.randomUUID()) // Unique token ID for replay detection
      .sign(this.privateKey);
  }

  // Expose public key for JWKS endpoint
  getJwk(): object {
    return pemToJwk(
      this.config.publicKeyPem,
      this.config.keyId,
      this.config.algorithm
    );
  }

  get serviceName(): string {
    return this.config.serviceName;
  }
}

// ============================================================================
// Initialize service identity (called at startup)
// ============================================================================

let orderServiceIdentity: ServiceIdentity;

async function initServiceIdentity(): Promise<void> {
  orderServiceIdentity = await ServiceIdentity.create({
    privateKeyPem: process.env.SERVICE_PRIVATE_KEY!,
    publicKeyPem: process.env.SERVICE_PUBLIC_KEY!,
    keyId: process.env.SERVICE_KEY_ID!,
    algorithm: 'ES256',
    serviceName: 'order-service',
  });

  console.info('Service identity initialized', {
    service: 'order-service',
    keyId: process.env.SERVICE_KEY_ID,
    algorithm: 'ES256',
  });
}
// Maven: com.nimbusds:nimbus-jose-jwt, org.springframework.boot:spring-boot-starter
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.*;
import com.nimbusds.jose.jwk.*;
import com.nimbusds.jwt.*;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;

import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.time.Instant;
import java.util.*;
import java.util.logging.Logger;

// ============================================================================
// Service Identity — loaded from environment / secrets manager at startup
// ============================================================================

@Component
public class ServiceIdentity {

    private static final Logger log = Logger.getLogger(ServiceIdentity.class.getName());

    @Value("${SERVICE_PRIVATE_KEY}")
    private String privateKeyPem;

    @Value("${SERVICE_PUBLIC_KEY}")
    private String publicKeyPem;

    @Value("${SERVICE_KEY_ID}")
    private String keyId;

    @Value("${SERVICE_NAME:order-service}")
    private String serviceName;

    private ECKey ecKey;

    @PostConstruct
    public void init() throws Exception {
        // Load EC key pair (ES256)
        ECKey privateJwk = (ECKey) JWK.parseFromPEMEncodedObjects(privateKeyPem);
        ECKey publicJwk  = (ECKey) JWK.parseFromPEMEncodedObjects(publicKeyPem);

        this.ecKey = new ECKey.Builder(publicJwk)
            .privateKey(privateJwk.toECPrivateKey())
            .keyID(keyId)
            .algorithm(JWSAlgorithm.ES256)
            .keyUse(KeyUse.SIGNATURE)
            .build();

        log.info("Service identity initialized service=" + serviceName + " keyId=" + keyId);
    }

    // Sign a new service JWT targeting a specific audience
    public String signToken(String audience, Map<String, Object> customClaims,
                            int ttlSeconds) throws Exception {
        Instant now = Instant.now();

        JWTClaimsSet.Builder claims = new JWTClaimsSet.Builder()
            .issuer(serviceName)
            .subject(serviceName)
            .audience(audience)
            .issueTime(Date.from(now))
            .expirationTime(Date.from(now.plusSeconds(ttlSeconds)))
            .jwtID(UUID.randomUUID().toString());

        customClaims.forEach(claims::claim);

        SignedJWT jwt = new SignedJWT(
            new JWSHeader.Builder(JWSAlgorithm.ES256)
                .keyID(keyId)
                .type(JOSEObjectType.JWT)
                .build(),
            claims.build()
        );
        jwt.sign(new ECDSASigner(ecKey.toECPrivateKey()));
        return jwt.serialize();
    }

    public String signToken(String audience) throws Exception {
        return signToken(audience, Map.of(), 300);
    }

    // Expose public key for JWKS endpoint
    public Map<String, Object> getJwk() {
        return ecKey.toPublicJWK().toJSONObject();
    }

    public String getServiceName() { return serviceName; }
}
# pip install python-jose[cryptography] cryptography
import os
import uuid
import logging
from dataclasses import dataclass, field
from typing import Any
from datetime import datetime, timedelta, timezone

from jose import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.backends import default_backend

logger = logging.getLogger(__name__)

@dataclass
class ServiceKeyConfig:
    private_key_pem: str
    public_key_pem: str
    key_id: str
    algorithm: str  # 'ES256' or 'RS256'
    service_name: str

# =============================================================================
# Service Identity — loaded from environment / secrets manager at startup
# =============================================================================

class ServiceIdentity:
    def __init__(self, config: ServiceKeyConfig):
        self._config = config
        self._private_key = serialization.load_pem_private_key(
            config.private_key_pem.encode(), password=None, backend=default_backend()
        )
        self._public_key = serialization.load_pem_public_key(
            config.public_key_pem.encode(), backend=default_backend()
        )

    @classmethod
    def from_env(cls, service_name: str = "order-service") -> "ServiceIdentity":
        config = ServiceKeyConfig(
            private_key_pem=os.environ["SERVICE_PRIVATE_KEY"],
            public_key_pem=os.environ["SERVICE_PUBLIC_KEY"],
            key_id=os.environ["SERVICE_KEY_ID"],
            algorithm="ES256",
            service_name=service_name,
        )
        identity = cls(config)
        logger.info("Service identity initialized service=%s keyId=%s", service_name, config.key_id)
        return identity

    def sign_token(
        self,
        audience: str,
        custom_claims: dict[str, Any] | None = None,
        ttl_seconds: int = 300,
    ) -> str:
        now = datetime.now(tz=timezone.utc)
        payload = {
            **(custom_claims or {}),
            "iss": self._config.service_name,
            "sub": self._config.service_name,
            "aud": audience,
            "iat": now,
            "exp": now + timedelta(seconds=ttl_seconds),
            "jti": str(uuid.uuid4()),
        }
        headers = {
            "kid": self._config.key_id,
            "typ": "JWT",
        }
        return jwt.encode(payload, self._private_key, algorithm=self._config.algorithm,
                          headers=headers)

    def get_jwk(self) -> dict:
        return pem_to_jwk(self._config.public_key_pem, self._config.key_id, self._config.algorithm)

    @property
    def service_name(self) -> str:
        return self._config.service_name

# Initialize at startup (module-level singleton)
order_service_identity: ServiceIdentity | None = None

def init_service_identity() -> None:
    global order_service_identity
    order_service_identity = ServiceIdentity.from_env("order-service")
// dotnet add package Microsoft.IdentityModel.Tokens System.IdentityModel.Tokens.Jwt
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;

public record ServiceKeyConfig(
    string PrivateKeyPem,
    string PublicKeyPem,
    string KeyId,
    string Algorithm,
    string ServiceName);

// ============================================================================
// Service Identity — loaded from environment / secrets manager at startup
// ============================================================================

public sealed class ServiceIdentity
{
    private readonly ServiceKeyConfig _config;
    private readonly ECDsa _ecdsa;
    private readonly ILogger<ServiceIdentity> _logger;

    private ServiceIdentity(ServiceKeyConfig config, ECDsa ecdsa, ILogger<ServiceIdentity> logger)
    {
        _config = config;
        _ecdsa  = ecdsa;
        _logger = logger;
    }

    public static ServiceIdentity Create(ServiceKeyConfig config, ILogger<ServiceIdentity> logger)
    {
        var ecdsa = ECDsa.Create();
        ecdsa.ImportFromPem(config.PrivateKeyPem);
        var identity = new ServiceIdentity(config, ecdsa, logger);
        logger.LogInformation("Service identity initialized service={Service} keyId={KeyId}",
            config.ServiceName, config.KeyId);
        return identity;
    }

    public static ServiceIdentity FromEnvironment(
        string serviceName, ILogger<ServiceIdentity> logger)
    {
        var config = new ServiceKeyConfig(
            PrivateKeyPem: Environment.GetEnvironmentVariable("SERVICE_PRIVATE_KEY")!,
            PublicKeyPem:  Environment.GetEnvironmentVariable("SERVICE_PUBLIC_KEY")!,
            KeyId:         Environment.GetEnvironmentVariable("SERVICE_KEY_ID")!,
            Algorithm:     "ES256",
            ServiceName:   serviceName
        );
        return Create(config, logger);
    }

    // Sign a new service JWT targeting a specific audience
    public string SignToken(string audience,
                            Dictionary<string, object>? customClaims = null,
                            int ttlSeconds = 300)
    {
        var now = DateTimeOffset.UtcNow;
        var handler = new JwtSecurityTokenHandler();

        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Iss, _config.ServiceName),
            new(JwtRegisteredClaimNames.Sub, _config.ServiceName),
            new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
        };

        if (customClaims != null)
            foreach (var (k, v) in customClaims)
                claims.Add(new Claim(k, v?.ToString() ?? ""));

        var signingCredentials = new SigningCredentials(
            new ECDsaSecurityKey(_ecdsa) { KeyId = _config.KeyId },
            SecurityAlgorithms.EcdsaSha256
        );

        var token = new JwtSecurityToken(
            issuer:   _config.ServiceName,
            audience: audience,
            claims:   claims,
            notBefore: now.UtcDateTime,
            expires:   now.AddSeconds(ttlSeconds).UtcDateTime,
            signingCredentials: signingCredentials
        );

        return handler.WriteToken(token);
    }

    // Expose public key for JWKS endpoint
    public Dictionary<string, object> GetJwk()
        => ServiceKeyUtils.PemToJwk(_config.PublicKeyPem, _config.KeyId, _config.Algorithm);

    public string ServiceName => _config.ServiceName;
}

Part 2: JWKS Endpoint

Every service that issues JWTs should expose a JWKS (JSON Web Key Set) endpoint. This is how downstream services discover the public key needed to verify tokens:

import express from 'express';

// ============================================================================
// JWKS Endpoint
// ============================================================================

// Cache-Control: the JWKS is public — cache aggressively
// During key rotation, publish new key BEFORE using it for signing
// Keep old key in JWKS for the duration of the longest token TTL after rotation

interface JwksStore {
  currentKeyId: string;
  keys: Map<string, object>; // keyId -> JWK
}

const jwksStore: JwksStore = {
  currentKeyId: process.env.SERVICE_KEY_ID!,
  keys: new Map(),
};

// On startup, add the current key
async function initJwks(): Promise<void> {
  const identity = await getServiceIdentity();
  jwksStore.keys.set(process.env.SERVICE_KEY_ID!, identity.getJwk());
}

// GET /.well-known/jwks.json
app.get('/.well-known/jwks.json', (req: express.Request, res: express.Response) => {
  res.setHeader('Content-Type', 'application/json');
  // Cache for 1 hour — downstream services refresh periodically
  res.setHeader('Cache-Control', 'public, max-age=3600');

  res.json({
    keys: Array.from(jwksStore.keys.values()),
  });
});
// Spring Boot REST controller — JWKS endpoint
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

// ============================================================================
// JWKS Store — holds current and previous keys during rotation
// ============================================================================

@org.springframework.stereotype.Component
class JwksStore {
    // keyId -> JWK object
    private final ConcurrentHashMap<String, Map<String, Object>> keys = new ConcurrentHashMap<>();
    private volatile String currentKeyId;

    public void putKey(String keyId, Map<String, Object> jwk) {
        keys.put(keyId, jwk);
        currentKeyId = keyId;
    }

    public void removeKey(String keyId) { keys.remove(keyId); }

    public List<Map<String, Object>> allKeys() { return List.copyOf(keys.values()); }
}

// GET /.well-known/jwks.json
// Cache-Control: public, max-age=3600 — downstream services cache the JWKS for 1 hour
// During key rotation: publish new key BEFORE using it for signing;
// keep old key for at least the longest token TTL after switching

@RestController
class JwksController {

    private final JwksStore jwksStore;
    private final ServiceIdentity identity;

    JwksController(JwksStore jwksStore, ServiceIdentity identity) {
        this.jwksStore = jwksStore;
        this.identity  = identity;
        // Register current key on startup
        jwksStore.putKey(
            System.getenv("SERVICE_KEY_ID"),
            identity.getJwk()
        );
    }

    @GetMapping("/.well-known/jwks.json")
    public ResponseEntity<Map<String, Object>> jwks() {
        return ResponseEntity.ok()
            .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic())
            .body(Map.of("keys", jwksStore.allKeys()));
    }
}
# FastAPI JWKS endpoint
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from collections import OrderedDict

app = FastAPI()

# =============================================================================
# JWKS Store
# Cache-Control: public, max-age=3600
# During key rotation: publish new key BEFORE signing with it;
# keep old key in JWKS for the duration of the longest token TTL.
# =============================================================================

_jwks_store: dict[str, dict] = {}  # keyId -> JWK
_current_key_id: str | None = None

def register_key(key_id: str, jwk: dict) -> None:
    global _current_key_id
    _jwks_store[key_id] = jwk
    _current_key_id = key_id

def remove_key(key_id: str) -> None:
    _jwks_store.pop(key_id, None)

@app.on_event("startup")
async def startup():
    init_service_identity()
    register_key(os.environ["SERVICE_KEY_ID"], order_service_identity.get_jwk())

@app.get("/.well-known/jwks.json")
async def jwks_endpoint():
    return JSONResponse(
        content={"keys": list(_jwks_store.values())},
        headers={"Cache-Control": "public, max-age=3600"},
    )
// ASP.NET Core JWKS endpoint
using Microsoft.AspNetCore.Mvc;
using System.Collections.Concurrent;

// ============================================================================
// JWKS Store
// ============================================================================

public class JwksStore
{
    private readonly ConcurrentDictionary<string, Dictionary<string, object>> _keys = new();
    public string? CurrentKeyId { get; private set; }

    public void PutKey(string keyId, Dictionary<string, object> jwk)
    {
        _keys[keyId] = jwk;
        CurrentKeyId = keyId;
    }

    public void RemoveKey(string keyId) => _keys.TryRemove(keyId, out _);

    public IEnumerable<Dictionary<string, object>> AllKeys() => _keys.Values;
}

// GET /.well-known/jwks.json
// Cache-Control: public, max-age=3600
// During key rotation: publish new key BEFORE signing with it;
// keep old key in JWKS for at least the longest token TTL after switching.

[ApiController]
public class JwksController : ControllerBase
{
    private readonly JwksStore _store;

    public JwksController(JwksStore store) => _store = store;

    [HttpGet("/.well-known/jwks.json")]
    public IActionResult GetJwks()
    {
        Response.Headers["Cache-Control"] = "public, max-age=3600";
        return Ok(new { keys = _store.AllKeys() });
    }
}

// Register in Program.cs:
// builder.Services.AddSingleton<JwksStore>();
// On startup:
// var store = app.Services.GetRequiredService<JwksStore>();
// var identity = app.Services.GetRequiredService<ServiceIdentity>();
// store.PutKey(Environment.GetEnvironmentVariable("SERVICE_KEY_ID")!, identity.GetJwk());

Part 3: Token Verification

Receiving services verify incoming JWTs using the caller’s JWKS endpoint:

import { createRemoteJWKSet, jwtVerify, decodeProtectedHeader } from 'jose';
import express from 'express';

// ============================================================================
// Per-issuer JWKS caches
// ============================================================================

const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();

function getJwksForIssuer(issuer: string): ReturnType<typeof createRemoteJWKSet> {
  if (!jwksCache.has(issuer)) {
    // Derive JWKS URL from issuer (convention-based or configured)
    const jwksUrl = new URL(
      `/.well-known/jwks.json`,
      getServiceBaseUrl(issuer) // e.g. https://order-service.internal
    );

    jwksCache.set(
      issuer,
      createRemoteJWKSet(jwksUrl, {
        cacheMaxAge: 3_600_000, // 1 hour JWKS cache
        cooldownDuration: 30_000, // 30s cooldown between refresh attempts
      })
    );
  }

  return jwksCache.get(issuer)!;
}

// Lookup service base URL (from config, service registry, or DNS convention)
function getServiceBaseUrl(serviceName: string): string {
  const serviceUrls: Record<string, string> = {
    'order-service': 'https://order-service.production.svc.cluster.local',
    'inventory-service': 'https://inventory-service.production.svc.cluster.local',
    'payment-service': 'https://payment-service.production.svc.cluster.local',
  };

  const url = serviceUrls[serviceName];
  if (!url) throw new Error(`Unknown service: ${serviceName}`);
  return url;
}

// ============================================================================
// JWT Verification
// ============================================================================

interface ServiceTokenClaims {
  iss: string;          // Calling service name
  sub: string;          // Same as iss (service identity)
  aud: string | string[];
  exp: number;
  iat: number;
  jti: string;
  // Optional propagated user context
  user_id?: string;
  user_role?: string;
  request_id?: string;
}

const ALLOWED_ISSUERS = new Set([
  'order-service',
  'inventory-service',
  'notification-service',
]);

async function verifyServiceToken(
  token: string,
  expectedAudience: string
): Promise<ServiceTokenClaims> {
  // Decode header to get the issuer claim (without full verification)
  // This lets us look up the right JWKS before verifying
  const { iss } = JSON.parse(
    Buffer.from(token.split('.')[1], 'base64url').toString()
  );

  if (!iss || !ALLOWED_ISSUERS.has(iss)) {
    throw new Error(`Unknown or disallowed issuer: ${iss}`);
  }

  const JWKS = getJwksForIssuer(iss);

  const { payload } = await jwtVerify(token, JWKS, {
    issuer: iss,
    audience: expectedAudience,
    algorithms: ['ES256', 'RS256'],
    clockTolerance: 30, // 30 second clock skew tolerance
  });

  return payload as unknown as ServiceTokenClaims;
}

// ============================================================================
// Auth Middleware
// ============================================================================

const PAYMENT_SERVICE_AUDIENCE = 'payment-service';
const ALLOWED_CALLERS: Record<string, string[]> = {
  'POST /api/payments/charge': ['order-service', 'subscription-service'],
  'GET /api/payments/history': ['order-service', 'admin-service'],
  'POST /api/payments/refund': ['order-service'],
};

async function serviceJwtMiddleware(
  req: express.Request,
  res: express.Response,
  next: express.NextFunction
): Promise<void> {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    res.status(401).json({ error: 'Missing Bearer token' });
    return;
  }

  const token = authHeader.slice(7);

  try {
    const claims = await verifyServiceToken(token, PAYMENT_SERVICE_AUDIENCE);

    // Check if this issuer is allowed for this specific endpoint
    const routeKey = `${req.method} ${req.path}`;
    const allowedCallers = ALLOWED_CALLERS[routeKey];

    if (allowedCallers && !allowedCallers.includes(claims.iss)) {
      res.status(403).json({
        error: 'Service not authorized for this endpoint',
        caller: claims.iss,
      });
      return;
    }

    // Attach to request
    (req as any).callerService = claims.iss;
    (req as any).tokenClaims = claims;
    (req as any).requestId = claims.request_id ?? req.headers['x-request-id'];

    next();
  } catch (error: any) {
    if (error.code === 'ERR_JWT_EXPIRED') {
      res.status(401).json({ error: 'Service token expired' });
    } else if (error.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
      res.status(401).json({ error: 'Invalid token signature' });
    } else {
      console.error('JWT verification error', { error: error.message });
      res.status(401).json({ error: 'Token verification failed' });
    }
  }
}
// Maven: com.nimbusds:nimbus-jose-jwt, org.springframework.boot:spring-boot-starter-web
import com.nimbusds.jose.jwk.source.*;
import com.nimbusds.jose.proc.*;
import com.nimbusds.jwt.*;
import com.nimbusds.jwt.proc.*;

import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.*;
import jakarta.servlet.http.*;
import java.io.IOException;
import java.net.URL;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Logger;

// ============================================================================
// Per-issuer JWKS caches
// ============================================================================

@Component
public class ServiceJwtVerifier {

    private static final Logger log = Logger.getLogger(ServiceJwtVerifier.class.getName());

    private static final Set<String> ALLOWED_ISSUERS = Set.of(
        "order-service", "inventory-service", "notification-service"
    );

    private static final Map<String, String> SERVICE_URLS = Map.of(
        "order-service",      "https://order-service.production.svc.cluster.local",
        "inventory-service",  "https://inventory-service.production.svc.cluster.local",
        "payment-service",    "https://payment-service.production.svc.cluster.local"
    );

    // Cache JWKSSource per issuer (Nimbus handles HTTP caching internally)
    private final ConcurrentHashMap<String, JWKSource<SecurityContext>> jwksCache =
        new ConcurrentHashMap<>();

    private JWKSource<SecurityContext> getJwksForIssuer(String issuer) throws Exception {
        return jwksCache.computeIfAbsent(issuer, iss -> {
            try {
                String baseUrl = SERVICE_URLS.get(iss);
                if (baseUrl == null) throw new IllegalArgumentException("Unknown service: " + iss);
                URL jwksUrl = new URL(baseUrl + "/.well-known/jwks.json");
                // RemoteJWKSet caches keys and refreshes on cache miss
                return new RemoteJWKSet<>(jwksUrl);
            } catch (Exception e) { throw new RuntimeException(e); }
        });
    }

    // Lookup issuer from JWT payload without full verification
    private String extractIssuerUnchecked(String token) {
        try {
            String[] parts = token.split("\\.");
            String payload = new String(java.util.Base64.getUrlDecoder().decode(parts[1]));
            return (String) new com.nimbusds.jose.util.JSONObjectUtils().parse(payload).get("iss");
        } catch (Exception e) { return null; }
    }

    // =========================================================================
    // JWT Verification
    // =========================================================================

    public JWTClaimsSet verifyServiceToken(String token, String expectedAudience) throws Exception {
        String iss = extractIssuerUnchecked(token);
        if (iss == null || !ALLOWED_ISSUERS.contains(iss))
            throw new IllegalArgumentException("Unknown or disallowed issuer: " + iss);

        JWKSource<SecurityContext> jwks = getJwksForIssuer(iss);

        ConfigurableJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
        processor.setJWSKeySelector(new JWSVerificationKeySelector<>(
            com.nimbusds.jose.JWSAlgorithm.ES256, jwks));

        DefaultJWTClaimsVerifier<SecurityContext> verifier = new DefaultJWTClaimsVerifier<>(
            new JWTClaimsSet.Builder().issuer(iss).audience(expectedAudience).build(),
            Set.of("iss", "sub", "aud", "exp", "iat", "jti")
        );
        verifier.setMaxClockSkew(30); // 30 second clock skew tolerance
        processor.setJWTClaimsSetVerifier(verifier);

        return processor.process(token, null);
    }
}

// ============================================================================
// Auth Filter
// ============================================================================

@Component
public class ServiceJwtFilter extends OncePerRequestFilter {

    private static final String PAYMENT_SERVICE_AUDIENCE = "payment-service";

    private static final Map<String, List<String>> ALLOWED_CALLERS = Map.of(
        "POST /api/payments/charge", List.of("order-service", "subscription-service"),
        "GET /api/payments/history",  List.of("order-service", "admin-service"),
        "POST /api/payments/refund",  List.of("order-service")
    );

    private final ServiceJwtVerifier verifier;
    ServiceJwtFilter(ServiceJwtVerifier verifier) { this.verifier = verifier; }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        String authHeader = req.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            res.setStatus(401);
            res.getWriter().write("{\"error\":\"Missing Bearer token\"}");
            return;
        }

        String token = authHeader.substring(7);
        try {
            var claims = verifier.verifyServiceToken(token, PAYMENT_SERVICE_AUDIENCE);
            String iss = claims.getIssuer();

            String routeKey = req.getMethod() + " " + req.getServletPath();
            var allowed = ALLOWED_CALLERS.get(routeKey);
            if (allowed != null && !allowed.contains(iss)) {
                res.setStatus(403);
                res.getWriter().write("{\"error\":\"Service not authorized\",\"caller\":\"" + iss + "\"}");
                return;
            }

            req.setAttribute("callerService", iss);
            req.setAttribute("tokenClaims", claims);
            chain.doFilter(req, res);

        } catch (com.nimbusds.jwt.proc.BadJWTException e) {
            res.setStatus(401); res.getWriter().write("{\"error\":\"Service token expired\"}");
        } catch (com.nimbusds.jose.JOSEException e) {
            res.setStatus(401); res.getWriter().write("{\"error\":\"Invalid token signature\"}");
        } catch (Exception e) {
            log.severe("JWT verification error: " + e.getMessage());
            res.setStatus(401); res.getWriter().write("{\"error\":\"Token verification failed\"}");
        }
    }
}
# pip install python-jose[cryptography] httpx fastapi cachetools
import os
import json
import base64
import logging
from typing import Any
from functools import lru_cache

import httpx
from jose import jwt, JWTError, ExpiredSignatureError
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from cachetools import TTLCache

logger = logging.getLogger(__name__)
app = FastAPI()
bearer_scheme = HTTPBearer()

# =============================================================================
# Per-issuer JWKS caches (TTL = 1 hour)
# =============================================================================

_jwks_cache: TTLCache = TTLCache(maxsize=50, ttl=3600)

SERVICE_URLS: dict[str, str] = {
    "order-service":     "https://order-service.production.svc.cluster.local",
    "inventory-service": "https://inventory-service.production.svc.cluster.local",
    "payment-service":   "https://payment-service.production.svc.cluster.local",
}

ALLOWED_ISSUERS = {"order-service", "inventory-service", "notification-service"}

async def get_jwks_for_issuer(issuer: str) -> list[dict]:
    if issuer in _jwks_cache:
        return _jwks_cache[issuer]
    base_url = SERVICE_URLS.get(issuer)
    if not base_url:
        raise ValueError(f"Unknown service: {issuer}")
    async with httpx.AsyncClient() as client:
        resp = await client.get(f"{base_url}/.well-known/jwks.json", timeout=5)
        resp.raise_for_status()
    keys = resp.json()["keys"]
    _jwks_cache[issuer] = keys
    return keys

def extract_issuer_unchecked(token: str) -> str | None:
    try:
        payload_b64 = token.split(".")[1]
        payload = json.loads(base64.urlsafe_b64decode(payload_b64 + "=="))
        return payload.get("iss")
    except Exception:
        return None

# =============================================================================
# JWT Verification
# =============================================================================

PAYMENT_SERVICE_AUDIENCE = "payment-service"
ALLOWED_CALLERS: dict[str, list[str]] = {
    "POST /api/payments/charge": ["order-service", "subscription-service"],
    "GET /api/payments/history":  ["order-service", "admin-service"],
    "POST /api/payments/refund":  ["order-service"],
}

async def verify_service_token(token: str, expected_audience: str) -> dict[str, Any]:
    iss = extract_issuer_unchecked(token)
    if not iss or iss not in ALLOWED_ISSUERS:
        raise HTTPException(status_code=401, detail=f"Unknown or disallowed issuer: {iss}")

    jwks = await get_jwks_for_issuer(iss)

    try:
        claims = jwt.decode(
            token,
            jwks,
            algorithms=["ES256", "RS256"],
            audience=expected_audience,
            issuer=iss,
            options={"leeway": 30},  # 30s clock skew tolerance
        )
    except ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="Service token expired")
    except JWTError as e:
        raise HTTPException(status_code=401, detail="Invalid token signature")

    return claims

# =============================================================================
# Auth dependency
# =============================================================================

async def service_jwt_auth(
    request: Request,
    credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
) -> dict[str, Any]:
    claims = await verify_service_token(credentials.credentials, PAYMENT_SERVICE_AUDIENCE)
    iss = claims["iss"]

    route_key = f"{request.method} {request.url.path}"
    allowed = ALLOWED_CALLERS.get(route_key)
    if allowed is not None and iss not in allowed:
        raise HTTPException(
            status_code=403,
            detail={"error": "Service not authorized for this endpoint", "caller": iss},
        )

    return claims

@app.post("/api/payments/charge")
async def charge_payment(claims: dict = Depends(service_jwt_auth)):
    return {"status": "charged", "caller": claims["iss"]}
// dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

const string PaymentServiceAudience = "payment-service";

static readonly Dictionary<string, string[]> AllowedCallers = new()
{
    ["POST /api/payments/charge"] = ["order-service", "subscription-service"],
    ["GET /api/payments/history"]  = ["order-service", "admin-service"],
    ["POST /api/payments/refund"]  = ["order-service"],
};

static readonly Dictionary<string, string> ServiceUrls = new()
{
    ["order-service"]     = "https://order-service.production.svc.cluster.local",
    ["inventory-service"] = "https://inventory-service.production.svc.cluster.local",
    ["payment-service"]   = "https://payment-service.production.svc.cluster.local",
};

static readonly HashSet<string> AllowedIssuers =
    ["order-service", "inventory-service", "notification-service"];

// =============================================================================
// JWT Bearer authentication with dynamic issuer / JWKS resolution
// =============================================================================

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer            = true,
            ValidIssuers              = AllowedIssuers,
            ValidateAudience          = true,
            ValidAudience             = PaymentServiceAudience,
            ValidateLifetime          = true,
            ClockSkew                 = TimeSpan.FromSeconds(30),
            ValidAlgorithms           = ["ES256", "RS256"],
            // IssuerSigningKeyResolver fetches JWKS from the calling service
            IssuerSigningKeyResolver  = (token, secToken, kid, parameters) =>
            {
                var jwtToken = secToken as JwtSecurityToken;
                string? iss = jwtToken?.Issuer;
                if (iss == null || !ServiceUrls.TryGetValue(iss, out string? baseUrl))
                    return [];

                // Fetch JWKS synchronously (use IConfigurationManager for caching in production)
                using var http = new HttpClient();
                var jwks = http.GetFromJsonAsync<JsonWebKeySet>(
                    $"{baseUrl}/.well-known/jwks.json").GetAwaiter().GetResult();
                return jwks?.Keys ?? [];
            },
        };
    });

builder.Services.AddAuthorization();
builder.Services.AddControllers();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

// =============================================================================
// Caller allowlist middleware
// =============================================================================

app.Use(async (context, next) =>
{
    if (context.User.Identity?.IsAuthenticated == true)
    {
        string? iss = context.User.FindFirst(JwtRegisteredClaimNames.Iss)?.Value;
        string routeKey = $"{context.Request.Method} {context.Request.Path}";

        if (AllowedCallers.TryGetValue(routeKey, out string[]? allowed)
            && (iss == null || !allowed.Contains(iss)))
        {
            context.Response.StatusCode = StatusCodes.Status403Forbidden;
            await context.Response.WriteAsJsonAsync(new
            {
                error = "Service not authorized for this endpoint",
                caller = iss
            });
            return;
        }
    }
    await next();
});

app.MapControllers();
app.Run();

Part 4: Token Propagation Patterns

In a multi-hop call chain, there are three common propagation patterns:

Pattern 1: New Token Per Hop

Each service signs a fresh token for its downstream calls. The downstream service knows the immediate caller, not the original:

// Order Service → Payment Service → Vault Service
// Order Service calls Payment Service with its own JWT
// Payment Service calls Vault Service with a JWT signed as "payment-service"

async function callVaultService(paymentId: string): Promise<void> {
  const token = await paymentServiceIdentity.signToken('vault-service', {
    // Add context about what triggered this call
    triggered_by_payment: paymentId,
  });

  await vaultClient.post('/api/vault/store', {
    paymentId,
  }, {
    headers: { Authorization: `Bearer ${token}` },
  });
}
// Payment Service → Vault Service: new JWT signed as "payment-service"
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import java.util.Map;

@Service
public class VaultServiceClient {

    private final ServiceIdentity paymentServiceIdentity;
    private final WebClient webClient;

    public VaultServiceClient(ServiceIdentity paymentServiceIdentity, WebClient.Builder builder) {
        this.paymentServiceIdentity = paymentServiceIdentity;
        this.webClient = builder.baseUrl("https://vault-service.production.svc.cluster.local").build();
    }

    // Payment Service signs its own token targeting vault-service
    public void storePayment(String paymentId) throws Exception {
        String token = paymentServiceIdentity.signToken(
            "vault-service",
            Map.of("triggered_by_payment", paymentId),
            300
        );

        webClient.post()
            .uri("/api/vault/store")
            .header("Authorization", "Bearer " + token)
            .bodyValue(Map.of("paymentId", paymentId))
            .retrieve()
            .toBodilessEntity()
            .block();
    }
}
# Payment Service → Vault Service: new token signed as "payment-service"
import httpx
from fastapi import Depends

async def call_vault_service(payment_id: str) -> None:
    # Payment service signs its own token targeting vault-service
    token = payment_service_identity.sign_token(
        audience="vault-service",
        custom_claims={"triggered_by_payment": payment_id},
        ttl_seconds=300,
    )

    async with httpx.AsyncClient() as client:
        resp = await client.post(
            "https://vault-service.production.svc.cluster.local/api/vault/store",
            json={"paymentId": payment_id},
            headers={"Authorization": f"Bearer {token}"},
        )
        resp.raise_for_status()
// Payment Service → Vault Service: new JWT signed as "payment-service"
using System.Net.Http;
using System.Net.Http.Json;

public class VaultServiceClient
{
    private readonly ServiceIdentity _identity;
    private readonly HttpClient _httpClient;

    public VaultServiceClient(ServiceIdentity identity, IHttpClientFactory factory)
    {
        _identity   = identity;
        _httpClient = factory.CreateClient("vault-service");
    }

    // Payment Service signs its own token targeting vault-service
    public async Task StorePaymentAsync(string paymentId)
    {
        string token = _identity.SignToken(
            audience: "vault-service",
            customClaims: new() { ["triggered_by_payment"] = paymentId },
            ttlSeconds: 300
        );

        using var request = new HttpRequestMessage(HttpMethod.Post, "/api/vault/store");
        request.Headers.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
        request.Content = JsonContent.Create(new { paymentId });

        var response = await _httpClient.SendAsync(request);
        response.EnsureSuccessStatusCode();
    }
}

Use when: Each service acts on its own authority; downstream services don’t need to know the ultimate source.

Pattern 2: Token Forwarding

The original token is forwarded through the chain. Downstream services see the original caller:

// Payment Service forwards the Order Service's JWT to Vault Service
// Vault Service sees iss=order-service as the principal

async function callVaultServiceWithForwarding(
  originalToken: string,
  paymentId: string
): Promise<void> {
  // Forward the original token — Vault Service sees the original caller
  await vaultClient.post('/api/vault/store', { paymentId }, {
    headers: { Authorization: `Bearer ${originalToken}` },
  });
}
// Payment Service forwards Order Service's JWT to Vault Service
// Vault Service sees iss=order-service as the principal

@Service
public class VaultServiceForwardingClient {

    private final WebClient webClient;

    public VaultServiceForwardingClient(WebClient.Builder builder) {
        this.webClient = builder.baseUrl("https://vault-service.production.svc.cluster.local").build();
    }

    public void storePaymentForwarding(String originalToken, String paymentId) {
        // Forward the original token — Vault Service sees the original caller
        webClient.post()
            .uri("/api/vault/store")
            .header("Authorization", "Bearer " + originalToken)
            .bodyValue(Map.of("paymentId", paymentId))
            .retrieve()
            .toBodilessEntity()
            .block();
    }
}
# Payment Service forwards Order Service's JWT to Vault Service
# Vault Service sees iss=order-service as the principal

async def call_vault_service_with_forwarding(
    original_token: str,
    payment_id: str,
) -> None:
    # Forward the original token — Vault Service sees the original caller
    async with httpx.AsyncClient() as client:
        resp = await client.post(
            "https://vault-service.production.svc.cluster.local/api/vault/store",
            json={"paymentId": payment_id},
            headers={"Authorization": f"Bearer {original_token}"},
        )
        resp.raise_for_status()
// Payment Service forwards Order Service's JWT to Vault Service
// Vault Service sees iss=order-service as the principal

public async Task StorePaymentForwardingAsync(string originalToken, string paymentId)
{
    // Forward the original token — Vault Service sees the original caller
    using var request = new HttpRequestMessage(HttpMethod.Post, "/api/vault/store");
    request.Headers.Authorization =
        new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", originalToken);
    request.Content = JsonContent.Create(new { paymentId });

    var response = await _httpClient.SendAsync(request);
    response.EnsureSuccessStatusCode();
}

Use when: Downstream services need to make access decisions based on the original caller’s identity. Risk: a compromised intermediate service can forward any token it receives.

Pattern 3: Nested Token / Token Exchange

The calling service creates a new token that includes the original caller’s identity as an additional claim. The RFC 8693 Token Exchange standard formalizes this:

// Payment Service creates a "nested" token that includes the original
// Order Service identity as a context claim

async function createDelegatedToken(
  originalToken: string,
  targetAudience: string
): Promise<string> {
  // Verify the original token first
  const originalClaims = await verifyServiceToken(originalToken, 'payment-service');

  // Sign a new token that includes the original caller's identity
  return paymentServiceIdentity.signToken(targetAudience, {
    // The actual caller (payment-service is the new issuer/sub)
    // but we include the original caller as context
    act: {
      iss: originalClaims.iss,
      sub: originalClaims.sub,
    },
    // Propagate request correlation ID
    request_id: originalClaims.request_id,
    // Optional: propagate user context if present
    ...(originalClaims.user_id ? {
      user_id: originalClaims.user_id,
      user_role: originalClaims.user_role,
    } : {}),
  });
}
// Payment Service creates a delegated token (RFC 8693 Token Exchange pattern)
// includes the original Order Service identity in the "act" claim

@Service
public class TokenDelegationService {

    private final ServiceIdentity paymentIdentity;
    private final ServiceJwtVerifier verifier;

    public TokenDelegationService(ServiceIdentity paymentIdentity, ServiceJwtVerifier verifier) {
        this.paymentIdentity = paymentIdentity;
        this.verifier = verifier;
    }

    public String createDelegatedToken(String originalToken, String targetAudience)
            throws Exception {
        // Verify the original token first
        JWTClaimsSet originalClaims = verifier.verifyServiceToken(originalToken, "payment-service");

        // Build "act" claim (actor — the original caller)
        Map<String, Object> act = Map.of(
            "iss", originalClaims.getIssuer(),
            "sub", originalClaims.getSubject()
        );

        Map<String, Object> customClaims = new java.util.HashMap<>();
        customClaims.put("act", act);
        if (originalClaims.getStringClaim("request_id") != null)
            customClaims.put("request_id", originalClaims.getStringClaim("request_id"));
        if (originalClaims.getStringClaim("user_id") != null) {
            customClaims.put("user_id", originalClaims.getStringClaim("user_id"));
            customClaims.put("user_role", originalClaims.getStringClaim("user_role"));
        }

        return paymentIdentity.signToken(targetAudience, customClaims, 300);
    }
}
# Payment Service creates a delegated token (RFC 8693 Token Exchange pattern)
# includes the original Order Service identity in the "act" claim

async def create_delegated_token(original_token: str, target_audience: str) -> str:
    # Verify the original token first
    original_claims = await verify_service_token(original_token, "payment-service")

    custom_claims: dict = {
        "act": {
            "iss": original_claims["iss"],
            "sub": original_claims["sub"],
        },
    }

    if "request_id" in original_claims:
        custom_claims["request_id"] = original_claims["request_id"]

    if "user_id" in original_claims:
        custom_claims["user_id"] = original_claims["user_id"]
        custom_claims["user_role"] = original_claims.get("user_role")

    return payment_service_identity.sign_token(
        audience=target_audience,
        custom_claims=custom_claims,
        ttl_seconds=300,
    )
// Payment Service creates a delegated token (RFC 8693 Token Exchange pattern)
// includes the original Order Service identity in the "act" claim

public async Task<string> CreateDelegatedTokenAsync(string originalToken, string targetAudience)
{
    // Verify the original token first (reuse the JWT validation logic)
    var handler = new JwtSecurityTokenHandler();
    // In production: call your verifyServiceToken equivalent here
    var jwt = handler.ReadJwtToken(originalToken);

    string origIss = jwt.Claims.First(c => c.Type == JwtRegisteredClaimNames.Iss).Value;
    string origSub = jwt.Claims.First(c => c.Type == JwtRegisteredClaimNames.Sub).Value;

    var customClaims = new Dictionary<string, object>
    {
        ["act"] = new Dictionary<string, string> { ["iss"] = origIss, ["sub"] = origSub }
    };

    string? requestId = jwt.Claims.FirstOrDefault(c => c.Type == "request_id")?.Value;
    if (requestId != null) customClaims["request_id"] = requestId;

    string? userId = jwt.Claims.FirstOrDefault(c => c.Type == "user_id")?.Value;
    if (userId != null)
    {
        customClaims["user_id"] = userId;
        string? userRole = jwt.Claims.FirstOrDefault(c => c.Type == "user_role")?.Value;
        if (userRole != null) customClaims["user_role"] = userRole;
    }

    return _identity.SignToken(targetAudience, customClaims, ttlSeconds: 300);
}

Use when: You need full audit trail of the call chain while allowing each service to verify its immediate caller. Most secure — each hop issues a narrowly-scoped token.

Part 5: Key Rotation

Service key rotation is simpler than CA certificate rotation but still requires care:

// ============================================================================
// Zero-Downtime Key Rotation
// ============================================================================

// Phase 1: Generate new key pair, publish to JWKS (but don't sign with it yet)
// Phase 2: Switch signing to new key (old tokens still valid — verified with old key)
// Phase 3: Wait for all old tokens to expire (max TTL = 5-15 minutes)
// Phase 4: Remove old key from JWKS

class RotatableServiceIdentity {
  private currentIdentity: ServiceIdentity;
  private previousIdentity?: ServiceIdentity;
  private previousKeyRetiredAt?: Date;
  private tokenTtlMs: number;

  constructor(current: ServiceIdentity, tokenTtlMs = 300_000) {
    this.currentIdentity = current;
    this.tokenTtlMs = tokenTtlMs;
  }

  async rotateKey(newConfig: ServiceKeyConfig): Promise<void> {
    const newIdentity = await ServiceIdentity.create(newConfig);

    // Keep old identity for verification until its tokens expire
    this.previousIdentity = this.currentIdentity;
    this.previousKeyRetiredAt = new Date();

    // Switch signing to new key
    this.currentIdentity = newIdentity;

    // Publish new key in JWKS immediately
    jwksStore.keys.set(newConfig.keyId, newIdentity.getJwk());

    console.info('Key rotation started', {
      oldKeyId: this.previousIdentity.serviceName,
      newKeyId: newConfig.keyId,
      oldKeyExpiresAt: new Date(Date.now() + this.tokenTtlMs),
    });

    // Schedule removal of old key from JWKS after token TTL
    setTimeout(() => {
      if (this.previousIdentity) {
        // Remove old key from JWKS — any tokens signed with it are now expired
        jwksStore.keys.delete(process.env.OLD_SERVICE_KEY_ID!);
        this.previousIdentity = undefined;
        console.info('Old key removed from JWKS');
      }
    }, this.tokenTtlMs + 30_000); // Extra 30s buffer
  }

  async signToken(audience: string, claims?: Record<string, unknown>): Promise<string> {
    return this.currentIdentity.signToken(audience, claims);
  }

  getAllJwks(): object[] {
    const keys: object[] = [this.currentIdentity.getJwk()];
    if (this.previousIdentity) {
      keys.push(this.previousIdentity.getJwk());
    }
    return keys;
  }
}
// Zero-Downtime Key Rotation
// Phase 1: Generate new key pair, publish to JWKS (don't sign yet)
// Phase 2: Switch signing to new key (old tokens still valid — verified with old key)
// Phase 3: Wait for all old tokens to expire (max TTL = 5-15 minutes)
// Phase 4: Remove old key from JWKS

import org.springframework.stereotype.Component;
import java.util.*;
import java.util.concurrent.*;
import java.util.logging.Logger;

@Component
public class RotatableServiceIdentity {

    private static final Logger log = Logger.getLogger(RotatableServiceIdentity.class.getName());

    private volatile ServiceIdentity currentIdentity;
    private volatile ServiceIdentity previousIdentity;
    private final JwksStore jwksStore;
    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
    private final long tokenTtlMs;

    public RotatableServiceIdentity(ServiceIdentity initial, JwksStore jwksStore) {
        this.currentIdentity = initial;
        this.jwksStore       = jwksStore;
        this.tokenTtlMs      = 300_000L; // 5 minutes default
    }

    public void rotateKey(ServiceKeyConfig newConfig) throws Exception {
        ServiceIdentity newIdentity = ServiceIdentity.Create(newConfig,
            java.util.logging.Logger.getLogger("ServiceIdentity"));

        previousIdentity = currentIdentity;
        currentIdentity  = newIdentity;

        // Publish new key in JWKS immediately
        jwksStore.putKey(newConfig.getKeyId(), newIdentity.getJwk());

        log.info("Key rotation started oldKey=" + previousIdentity.getServiceName()
            + " newKeyId=" + newConfig.getKeyId());

        // Schedule removal of old key from JWKS after token TTL + 30s buffer
        scheduler.schedule(() -> {
            if (previousIdentity != null) {
                jwksStore.removeKey(System.getenv("OLD_SERVICE_KEY_ID"));
                previousIdentity = null;
                log.info("Old key removed from JWKS");
            }
        }, tokenTtlMs + 30_000, TimeUnit.MILLISECONDS);
    }

    public String signToken(String audience, Map<String, Object> claims) throws Exception {
        return currentIdentity.signToken(audience, claims, 300);
    }

    public List<Map<String, Object>> getAllJwks() {
        List<Map<String, Object>> keys = new ArrayList<>();
        keys.add(currentIdentity.getJwk());
        if (previousIdentity != null) keys.add(previousIdentity.getJwk());
        return keys;
    }
}
# Zero-Downtime Key Rotation
# Phase 1: Generate new key pair, publish to JWKS (don't sign yet)
# Phase 2: Switch signing to new key (old tokens still valid — verified with old key)
# Phase 3: Wait for all old tokens to expire (max TTL = 5-15 minutes)
# Phase 4: Remove old key from JWKS

import asyncio
import logging
from typing import Optional

logger = logging.getLogger(__name__)

class RotatableServiceIdentity:
    def __init__(self, current: ServiceIdentity, token_ttl_seconds: int = 300):
        self._current = current
        self._previous: Optional[ServiceIdentity] = None
        self._token_ttl_seconds = token_ttl_seconds

    async def rotate_key(self, new_config: ServiceKeyConfig) -> None:
        new_identity = ServiceIdentity(new_config)

        # Keep old identity for verification until its tokens expire
        self._previous = self._current
        self._current  = new_identity

        # Publish new key in JWKS immediately
        register_key(new_config.key_id, new_identity.get_jwk())

        logger.info(
            "Key rotation started oldKey=%s newKeyId=%s",
            self._previous.service_name,
            new_config.key_id,
        )

        # Schedule removal of old key after token TTL + 30s buffer
        asyncio.get_event_loop().call_later(
            self._token_ttl_seconds + 30,
            self._retire_old_key,
            os.getenv("OLD_SERVICE_KEY_ID", ""),
        )

    def _retire_old_key(self, old_key_id: str) -> None:
        if self._previous is not None:
            remove_key(old_key_id)
            self._previous = None
            logger.info("Old key removed from JWKS")

    def sign_token(self, audience: str, claims: dict | None = None) -> str:
        return self._current.sign_token(audience, custom_claims=claims)

    def get_all_jwks(self) -> list[dict]:
        keys = [self._current.get_jwk()]
        if self._previous is not None:
            keys.append(self._previous.get_jwk())
        return keys
// Zero-Downtime Key Rotation
// Phase 1: Generate new key pair, publish to JWKS (don't sign yet)
// Phase 2: Switch signing to new key (old tokens still valid — verified with old key)
// Phase 3: Wait for all old tokens to expire (max TTL = 5-15 minutes)
// Phase 4: Remove old key from JWKS

using System.Threading;
using Microsoft.Extensions.Logging;

public sealed class RotatableServiceIdentity
{
    private volatile ServiceIdentity _current;
    private volatile ServiceIdentity? _previous;
    private readonly JwksStore _jwksStore;
    private readonly TimeSpan _tokenTtl;
    private readonly ILogger _logger;

    public RotatableServiceIdentity(
        ServiceIdentity initial, JwksStore jwksStore, ILogger logger,
        TimeSpan? tokenTtl = null)
    {
        _current   = initial;
        _jwksStore = jwksStore;
        _logger    = logger;
        _tokenTtl  = tokenTtl ?? TimeSpan.FromMinutes(5);
    }

    public async Task RotateKeyAsync(ServiceKeyConfig newConfig, ILogger<ServiceIdentity> idLogger)
    {
        var newIdentity = ServiceIdentity.Create(newConfig, idLogger);

        _previous = _current;
        _current  = newIdentity;

        // Publish new key in JWKS immediately
        _jwksStore.PutKey(newConfig.KeyId, newIdentity.GetJwk());

        _logger.LogInformation("Key rotation started oldKey={OldKey} newKeyId={NewKeyId}",
            _previous.ServiceName, newConfig.KeyId);

        // Schedule removal of old key after token TTL + 30s buffer
        _ = Task.Delay(_tokenTtl + TimeSpan.FromSeconds(30)).ContinueWith(_ =>
        {
            string? oldKeyId = Environment.GetEnvironmentVariable("OLD_SERVICE_KEY_ID");
            if (_previous != null && oldKeyId != null)
            {
                _jwksStore.RemoveKey(oldKeyId);
                _previous = null;
                _logger.LogInformation("Old key removed from JWKS");
            }
        });

        await Task.CompletedTask;
    }

    public string SignToken(string audience, Dictionary<string, object>? claims = null)
        => _current.SignToken(audience, claims);

    public IEnumerable<Dictionary<string, object>> GetAllJwks()
    {
        yield return _current.GetJwk();
        if (_previous != null) yield return _previous.GetJwk();
    }
}

Part 6: Replay Prevention

Short token TTLs limit replay windows, but for high-security endpoints, add nonce tracking:

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);

// ============================================================================
// JTI (JWT ID) Replay Prevention
// ============================================================================

async function checkAndConsumeJti(
  jti: string,
  ttlSeconds: number
): Promise<boolean> {
  // Set in Redis with NX (only set if not exists) and TTL
  const result = await redis.set(
    `jwt:jti:${jti}`,
    '1',
    'EX', ttlSeconds,
    'NX' // Only set if key doesn't exist
  );

  // result is 'OK' if set (first use), null if already exists (replay)
  return result === 'OK';
}

// Enhanced verification with replay prevention
async function verifyServiceTokenWithReplayProtection(
  token: string,
  expectedAudience: string
): Promise<ServiceTokenClaims> {
  const claims = await verifyServiceToken(token, expectedAudience);

  if (!claims.jti) {
    throw new Error('Token missing JTI claim — replay protection unavailable');
  }

  // Calculate remaining token lifetime for Redis TTL
  const remainingTtl = claims.exp - Math.floor(Date.now() / 1000);
  if (remainingTtl <= 0) {
    throw new Error('Token expired');
  }

  // Check and consume the JTI
  const isFirstUse = await checkAndConsumeJti(claims.jti, remainingTtl + 60);

  if (!isFirstUse) {
    console.error('JWT replay detected', {
      jti: claims.jti,
      iss: claims.iss,
      aud: claims.aud,
    });
    throw new Error('Token replay detected — JTI already used');
  }

  return claims;
}
// Maven: io.lettuce:lettuce-core or org.springframework.boot:spring-boot-starter-data-redis
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.Instant;
import java.util.logging.Logger;

@Component
public class JtiReplayGuard {

    private static final Logger log = Logger.getLogger(JtiReplayGuard.class.getName());
    private final StringRedisTemplate redis;

    public JtiReplayGuard(StringRedisTemplate redis) { this.redis = redis; }

    // ==========================================================================
    // JTI (JWT ID) Replay Prevention
    // ==========================================================================

    /**
     * Returns true on first use of this JTI; false if already seen (replay).
     * Uses Redis SET NX EX for atomic check-and-set.
     */
    public boolean checkAndConsumeJti(String jti, long ttlSeconds) {
        Boolean set = redis.opsForValue().setIfAbsent(
            "jwt:jti:" + jti, "1", Duration.ofSeconds(ttlSeconds)
        );
        return Boolean.TRUE.equals(set);
    }

    // Enhanced verification with replay prevention
    public com.nimbusds.jwt.JWTClaimsSet verifyWithReplayProtection(
            String token, String expectedAudience, ServiceJwtVerifier verifier) throws Exception {

        var claims = verifier.verifyServiceToken(token, expectedAudience);

        String jti = claims.getJWTID();
        if (jti == null || jti.isBlank())
            throw new IllegalArgumentException("Token missing JTI claim — replay protection unavailable");

        long remainingTtl = claims.getExpirationTime().toInstant().getEpochSecond()
            - Instant.now().getEpochSecond();

        if (remainingTtl <= 0)
            throw new IllegalArgumentException("Token expired");

        boolean firstUse = checkAndConsumeJti(jti, remainingTtl + 60);
        if (!firstUse) {
            log.severe("JWT replay detected jti=" + jti
                + " iss=" + claims.getIssuer() + " aud=" + claims.getAudience());
            throw new SecurityException("Token replay detected — JTI already used");
        }

        return claims;
    }
}
# pip install redis[asyncio]
import time
import logging
from redis.asyncio import Redis

logger = logging.getLogger(__name__)

redis_client: Redis | None = None

def get_redis() -> Redis:
    global redis_client
    if redis_client is None:
        redis_client = Redis.from_url(os.environ["REDIS_URL"], decode_responses=True)
    return redis_client

# =============================================================================
# JTI (JWT ID) Replay Prevention
# =============================================================================

async def check_and_consume_jti(jti: str, ttl_seconds: int) -> bool:
    """Returns True on first use; False if already seen (replay)."""
    redis = get_redis()
    # SET NX EX — atomic check-and-set
    result = await redis.set(f"jwt:jti:{jti}", "1", ex=ttl_seconds, nx=True)
    return result is True  # True = set (first use), None = already exists (replay)

async def verify_service_token_with_replay_protection(
    token: str, expected_audience: str
) -> dict:
    claims = await verify_service_token(token, expected_audience)

    jti = claims.get("jti")
    if not jti:
        raise ValueError("Token missing JTI claim — replay protection unavailable")

    remaining_ttl = int(claims["exp"]) - int(time.time())
    if remaining_ttl <= 0:
        raise ValueError("Token expired")

    first_use = await check_and_consume_jti(jti, remaining_ttl + 60)

    if not first_use:
        logger.error("JWT replay detected jti=%s iss=%s aud=%s",
                     jti, claims.get("iss"), claims.get("aud"))
        raise ValueError("Token replay detected — JTI already used")

    return claims
// dotnet add package StackExchange.Redis
using StackExchange.Redis;
using Microsoft.Extensions.Logging;

public class JtiReplayGuard
{
    private readonly IDatabase _redis;
    private readonly ILogger<JtiReplayGuard> _logger;

    public JtiReplayGuard(IConnectionMultiplexer redis, ILogger<JtiReplayGuard> logger)
    {
        _redis  = redis.GetDatabase();
        _logger = logger;
    }

    // =========================================================================
    // JTI (JWT ID) Replay Prevention
    // =========================================================================

    /// <summary>Returns true on first use; false if JTI was already seen (replay).</summary>
    public async Task<bool> CheckAndConsumeJtiAsync(string jti, long ttlSeconds)
    {
        // SET NX EX — atomic check-and-set
        bool set = await _redis.StringSetAsync(
            $"jwt:jti:{jti}", "1",
            expiry: TimeSpan.FromSeconds(ttlSeconds),
            when: When.NotExists
        );
        return set;
    }

    // Enhanced verification with replay prevention
    public async Task<System.IdentityModel.Tokens.Jwt.JwtSecurityToken>
        VerifyWithReplayProtectionAsync(string token, string expectedAudience)
    {
        // In production: call your verifyServiceToken equivalent here
        var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
        var jwtToken = handler.ReadJwtToken(token);

        string? jti = jwtToken.Claims
            .FirstOrDefault(c => c.Type == System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Jti)
            ?.Value;

        if (string.IsNullOrWhiteSpace(jti))
            throw new InvalidOperationException(
                "Token missing JTI claim — replay protection unavailable");

        long expUnix = long.Parse(
            jwtToken.Claims.First(c => c.Type == "exp").Value);
        long remainingTtl = expUnix - DateTimeOffset.UtcNow.ToUnixTimeSeconds();

        if (remainingTtl <= 0)
            throw new SecurityTokenExpiredException("Token expired");

        bool firstUse = await CheckAndConsumeJtiAsync(jti, remainingTtl + 60);
        if (!firstUse)
        {
            _logger.LogError("JWT replay detected jti={Jti} iss={Iss}",
                jti, jwtToken.Issuer);
            throw new SecurityTokenException("Token replay detected — JTI already used");
        }

        return jwtToken;
    }
}

Part 7: Production Checklist

Key Management

  • Each service has its own unique private/public key pair
  • Private keys stored in secrets manager (Vault, AWS Secrets Manager), not env vars
  • Key pairs use ES256 (P-256) or RS256 (2048-bit minimum)
  • Key ID (kid) in JWT header matches corresponding key in JWKS
  • Key rotation documented and tested (zero-downtime procedure)
  • New key published to JWKS before used for signing

Token Issuance

  • iss = issuing service name (consistent identifier)
  • sub = issuing service name (for service-to-service)
  • aud = specific target service identifier
  • exp = issued at + TTL (5-15 minutes for service tokens)
  • iat = issued at (for clock skew debugging)
  • jti = UUID (for replay prevention if needed)
  • Custom claims are minimal and non-sensitive

Token Verification

  • Validate iss against allowlist of known services
  • Validate aud matches the receiving service’s own identifier
  • Validate exp — jose does this automatically
  • Use clockTolerance: 30 (seconds) for clock skew between services
  • Whitelist allowed algorithms: ['ES256', 'RS256']
  • JWKS are cached (jose handles this); ensure cache has reasonable TTL (1h)

Propagation

  • Choose a single propagation pattern and document it
  • Request ID is propagated across all hops for distributed tracing correlation
  • User context propagation (if any) uses the act claim, not overriding iss

Operational

  • All services expose /.well-known/jwks.json
  • Monitor token issuance rates per service
  • Monitor verification failure rates per endpoint
  • Alert on unexpected new issuers attempting verification
  • Log JTIs of rejected tokens for security analysis
  • Test clock skew handling (NTP misconfiguration is a common failure mode)

Conclusion

JWT service-to-service authentication hits the sweet spot between simplicity and security for teams that want more than API keys but don’t need the full infrastructure weight of mTLS or SPIFFE. The key advantages are offline verification (no auth server in the hot path), rich claim sets for policy decisions, and a familiar programming model if you already use JWTs for users.

The critical implementation details are the ones that make the difference between secure and merely functional: asymmetric keys (private for signing, public for verification), short TTLs, audience restriction, and clock skew tolerance. Get those right and you have a robust foundation.

For the propagation pattern, default to new-token-per-hop. It gives each service control over its downstream claims, limits the blast radius if a service is compromised, and makes audit trails clean. Token forwarding is tempting for simplicity but creates hard-to-analyze security chains.

Use JWT service auth as your default internal auth mechanism and graduate to mTLS or SPIFFE when scale, compliance, or zero-trust requirements demand it.

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

Written by Palakorn Voramongkol

Software Engineer Specialist with 20+ years of experience. Writing about architecture, performance, and building production systems.

More about me

Continue Reading