Back to blog
System Designadvanced

Security in Microservices — mTLS, JWT Propagation & Zero Trust

How authentication and authorization work across microservices. Covers JWT claim propagation, service-to-service auth (API keys vs mTLS vs SPIFFE), mutual TLS with cert-manager, Zero Trust networking, secrets management per service, and inter-service authorization.

LearnixoApril 15, 20269 min read
System DesignMicroservicesSecuritymTLSJWTZero TrustKubernetes
Share:𝕏

How Auth Works in a Monolith vs Microservices

In a monolith, a single JWT middleware validates the token once and the user identity is available everywhere in the process. In a microservices system, you have a choice to make:

Option A: Validate at the gateway only

User → [JWT validated] → Gateway → Service A → Service B
                                       ↑           ↑
                               No JWT validation here

Pros: simple, services don't need JWT middleware.
Cons: if a service is somehow reachable directly (misconfigured network), it has zero auth.

Option B: Validate at every service

User → [JWT validated] → Gateway → [JWT validated] → Service A
                                                        ↓
                                                [JWT validated] → Service B

Pros: defense in depth — each service independently verifies the caller.
Cons: more config, token forwarding required, slight latency overhead.

Recommendation: Validate at the gateway and propagate the token to downstream services. Services should also validate the token (or at minimum the issuer/audience) independently.


JWT Claim Propagation Between Services

When the gateway validates a JWT and forwards the request to a downstream service, it must pass the user context along. The simplest approach: forward the Authorization header as-is.

C#
// In a DelegatingHandler — automatically attached to all HttpClients
public class ForwardAuthorizationHandler(IHttpContextAccessor contextAccessor)
    : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var httpContext = contextAccessor.HttpContext;
        if (httpContext is not null)
        {
            var token = httpContext.Request.Headers.Authorization.FirstOrDefault();
            if (!string.IsNullOrEmpty(token))
            {
                request.Headers.Authorization =
                    AuthenticationHeaderValue.Parse(token);
            }

            // Also propagate correlation ID for distributed tracing
            if (httpContext.Request.Headers.TryGetValue("X-Correlation-Id", out var correlationId))
            {
                request.Headers.TryAddWithoutValidation("X-Correlation-Id", correlationId.ToString());
            }
        }

        return await base.SendAsync(request, cancellationToken);
    }
}
C#
// Register globally for all typed HttpClients
builder.Services.AddTransient<ForwardAuthorizationHandler>();
builder.Services.AddHttpClient<InventoryClient>()
    .AddHttpMessageHandler<ForwardAuthorizationHandler>();

Extracting claims in downstream services

C#
// Any service that receives the forwarded JWT
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"];
        options.Audience = "micromart-internal";   // internal audience
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
        };
    });

// In an endpoint — claims are available from HttpContext
app.MapGet("/api/inventory/{productId}", (
    Guid productId,
    ClaimsPrincipal user) =>
{
    var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
    var roles  = user.FindAll(ClaimTypes.Role).Select(c => c.Value);
    // ...
}).RequireAuthorization();

Service-to-Service Authentication

User-to-service auth uses JWT. But what about service-to-service calls — Order Service calling Inventory Service? These calls don't have a user JWT.

Three options:

| Approach | How it works | Best for | |----------|-------------|----------| | Shared API key | A static secret header passed between services | Simple internal networks, non-critical services | | mTLS | Both sides present X.509 certificates; mutual verification | Production Kubernetes clusters | | SPIFFE/SVID | Cryptographic workload identity (SPIRE issues SVIDs) | Multi-cluster, multi-cloud, zero trust |

Option 1: API key between services (simple)

C#
// Order Service sending to Inventory
builder.Services.AddHttpClient<InventoryClient>(client =>
{
    client.BaseAddress = new Uri(config["Services:Inventory"]!);
    client.DefaultRequestHeaders.Add(
        "X-Service-Api-Key",
        config["ServiceKeys:InventoryApiKey"]!);
});

// Inventory Service validating the key
app.Use(async (context, next) =>
{
    if (context.Request.Path.StartsWithSegments("/api/internal"))
    {
        if (!context.Request.Headers.TryGetValue("X-Service-Api-Key", out var key) ||
            key != config["ServiceKeys:ExpectedKey"])
        {
            context.Response.StatusCode = 401;
            return;
        }
    }
    await next();
});

Rotate API keys via Azure Key Vault or Kubernetes secrets. Never hardcode them.

Option 2: Mutual TLS (mTLS)

In mTLS, both the client and the server present X.509 certificates. The server verifies the client's certificate (not just the other way around). This proves the identity of both parties at the transport layer — no application-level tokens needed.


Setting Up mTLS with cert-manager in Kubernetes

cert-manager is the standard Kubernetes operator for issuing and renewing X.509 certificates.

Install cert-manager

Bash
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml

Create a private CA for your cluster

YAML
# cluster-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: micromart-ca-issuer
spec:
  selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: micromart-ca
  namespace: cert-manager
spec:
  isCA: true
  commonName: micromart-ca
  secretName: micromart-ca-key-pair
  issuerRef:
    name: micromart-ca-issuer
    kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: micromart-issuer
spec:
  ca:
    secretName: micromart-ca-key-pair

Issue a certificate for each service

YAML
# orders-service-cert.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: orders-service-cert
  namespace: production
spec:
  secretName: orders-service-tls
  issuerRef:
    name: micromart-issuer
    kind: ClusterIssuer
  commonName: orders.production.svc.cluster.local
  dnsNames:
    - orders.production.svc.cluster.local
    - orders
  duration: 24h        # cert-manager auto-renews before expiry
  renewBefore: 1h

Configure .NET service to require client certificates

C#
// Program.cs in Inventory Service
builder.WebHost.ConfigureKestrel(options =>
{
    options.ConfigureHttpsDefaults(https =>
    {
        https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
        https.ClientCertificateValidation = (cert, chain, errors) =>
        {
            // Verify cert was issued by our internal CA
            return chain?.ChainElements
                .Any(e => e.Certificate.Subject.Contains("micromart-ca")) == true;
        };
    });
});

Kubernetes deployment — mount the cert

YAML
# inventory-deployment.yaml
spec:
  template:
    spec:
      containers:
        - name: inventory
          volumeMounts:
            - name: tls-cert
              mountPath: /etc/tls
              readOnly: true
          env:
            - name: KESTREL__CERTIFICATES__DEFAULT__PATH
              value: /etc/tls/tls.crt
            - name: KESTREL__CERTIFICATES__DEFAULT__KEYPATH
              value: /etc/tls/tls.key
      volumes:
        - name: tls-cert
          secret:
            secretName: inventory-service-tls

Service Mesh for Automatic mTLS (Istio)

Manual certificate management is complex. A service mesh (Istio, Linkerd) injects a sidecar proxy into every pod. The sidecar handles mTLS transparently — your application code sees plain HTTP internally, but all traffic between pods is encrypted and authenticated.

YAML
# Enable strict mTLS for the entire production namespace
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: default
  namespace: production
spec:
  mtls:
    mode: STRICT    # reject any non-mTLS traffic
YAML
# AuthorizationPolicy  only allow Order Service to call Inventory
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: inventory-allow-orders
  namespace: production
spec:
  selector:
    matchLabels:
      app: inventory
  rules:
    - from:
        - source:
            principals: ["cluster.local/ns/production/sa/orders-service"]
      to:
        - operation:
            methods: ["POST"]
            paths: ["/api/inventory/reserve"]

This policy is enforced at the network level, in the sidecar — not in your application code. Even if Inventory's code had a bug that skipped auth, unauthorized callers would be rejected by the mesh.


Zero Trust at the Network Layer

Zero Trust means: never trust, always verify. Even traffic inside the cluster is treated as potentially hostile.

Kubernetes NetworkPolicy implements coarse-grained Zero Trust:

YAML
# Default: deny all ingress to production namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: production
spec:
  podSelector: {}
  policyTypes:
    - Ingress
---
# Explicitly allow: gateway  order service
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-gateway-to-orders
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: orders
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: gateway
      ports:
        - port: 8080
---
# Explicitly allow: order service  inventory service
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-orders-to-inventory
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: inventory
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: orders

With these policies:

  • Direct external access to Inventory is blocked
  • Catalog cannot call Orders (not explicitly allowed)
  • Only the gateway can call Orders; only Orders can call Inventory

Secrets Management Per Service

Never store secrets in:

  • Environment variables baked into Docker images
  • appsettings.json committed to Git
  • GitHub Actions secrets passed as plain strings to all services

Each service should only have access to its own secrets.

Azure Key Vault with workload identity

YAML
# ServiceAccount with Azure workload identity annotation
apiVersion: v1
kind: ServiceAccount
metadata:
  name: orders-service
  namespace: production
  annotations:
    azure.workload.identity/client-id: "<orders-service-managed-identity-client-id>"
C#
// Orders Service — pull secrets from Key Vault at startup
// No connection strings in appsettings.json
builder.Configuration.AddAzureKeyVault(
    new Uri($"https://{builder.Configuration["KeyVault:Name"]}.vault.azure.net/"),
    new DefaultAzureCredential());   // uses workload identity automatically

// Secrets are scoped: orders-service identity can only read orders/* secrets
// Inventory service identity can only read inventory/* secrets

Kubernetes Secrets with External Secrets Operator

YAML
# ExternalSecret  syncs from Azure Key Vault to a Kubernetes Secret
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: orders-db-credentials
  namespace: production
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: azure-keyvault-store
    kind: ClusterSecretStore
  target:
    name: orders-db-secret
  data:
    - secretKey: connection-string
      remoteRef:
        key: orders/database-connection-string

Audit Logging Across Service Boundaries

In a regulated system (healthcare, finance), you need an immutable trail of who did what, across all services.

C#
// Shared audit middleware — add to every service
public class AuditMiddleware(RequestDelegate next, IAuditLogger auditLogger)
{
    public async Task InvokeAsync(HttpContext context)
    {
        await next(context);

        // Log every state-changing operation
        if (context.Request.Method is "POST" or "PUT" or "DELETE" or "PATCH")
        {
            await auditLogger.LogAsync(new AuditEvent
            {
                Timestamp    = DateTimeOffset.UtcNow,
                Service      = Environment.GetEnvironmentVariable("SERVICE_NAME") ?? "unknown",
                UserId       = context.User.FindFirstValue(ClaimTypes.NameIdentifier),
                CallerService = context.Request.Headers["X-Service-Name"].FirstOrDefault(),
                Method       = context.Request.Method,
                Path         = context.Request.Path,
                StatusCode   = context.Response.StatusCode,
                CorrelationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault(),
            });
        }
    }
}

Send audit events to a dedicated, append-only store (Azure Cosmos DB with TTL disabled, or a separate audit database). Never let services delete their own audit records.


Inter-Service Authorization

Just because Order Service can reach Inventory Service (network allowed) doesn't mean every operation should be permitted. Enforce explicit authorization:

C#
// Inventory Service — authorize which callers can do what
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("CanReserveStock", policy =>
        policy.RequireClaim("service", "orders-service"));

    options.AddPolicy("CanAdjustStock", policy =>
        policy.RequireClaim("service", "warehouse-service"));

    options.AddPolicy("CanReadStock", policy =>
        policy.RequireAssertion(ctx =>
            ctx.User.HasClaim("service", "orders-service") ||
            ctx.User.HasClaim("service", "catalog-service") ||
            ctx.User.HasClaim("role", "admin")));
});

app.MapPost("/api/inventory/reserve", ReserveStockHandler)
    .RequireAuthorization("CanReserveStock");

app.MapPatch("/api/inventory/adjust", AdjustStockHandler)
    .RequireAuthorization("CanAdjustStock");

Where does the service claim come from? The calling service includes it in its JWT (issued by your internal auth server):

JSON
{
  "sub": "orders-service",
  "service": "orders-service",
  "aud": "micromart-internal",
  "iss": "https://auth.micromart.internal"
}

Summary

| Concern | Solution | |---------|----------| | User auth across services | JWT validated at gateway + forwarded to services | | Service-to-service identity | mTLS (cert-manager) or service mesh (Istio) | | Automatic mTLS | Istio sidecar with PeerAuthentication: STRICT | | Network isolation | Kubernetes NetworkPolicy — default deny, explicit allow | | Zero Trust enforcement | Istio AuthorizationPolicy per service-to-service path | | Secrets management | Azure Key Vault with workload identity, one identity per service | | Inter-service authorization | Claims-based policies (service claim in service JWT) | | Audit trail | Centralized append-only audit log with correlation ID |

Security in microservices is defense in depth — multiple independent layers, each of which would individually stop an attacker. No single point of trust.

Enjoyed this article?

Explore the System Design learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.