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.
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 herePros: 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 BPros: 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.
// 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);
}
}// Register globally for all typed HttpClients
builder.Services.AddTransient<ForwardAuthorizationHandler>();
builder.Services.AddHttpClient<InventoryClient>()
.AddHttpMessageHandler<ForwardAuthorizationHandler>();Extracting claims in downstream services
// 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)
// 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
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yamlCreate a private CA for your cluster
# 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-pairIssue a certificate for each service
# 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: 1hConfigure .NET service to require client certificates
// 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
# 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-tlsService 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.
# 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# 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:
# 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: ordersWith 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.jsoncommitted 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
# 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>"// 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/* secretsKubernetes Secrets with External Secrets Operator
# 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-stringAudit Logging Across Service Boundaries
In a regulated system (healthcare, finance), you need an immutable trail of who did what, across all services.
// 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:
// 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):
{
"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?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.