Microservices Security: JWT, mTLS & OAuth2 Patterns for Production (2026)
Microservices shatter the monolith's security perimeter. Where a monolith had one ingress point to defend, a 50-service architecture has thousands of internal network paths — each one a potential lateral movement vector for an attacker who breaches any single service. The only viable response is zero-trust: every service proves its identity to every other service on every request, and the network assumes breach by default.
The Microservices Security Threat Model
The traditional perimeter security model — a hard outer shell protecting a trusted inner network — fails catastrophically in microservices architectures. When a microservices platform has 50 services communicating over internal Kubernetes networking, a single compromised service (through a dependency vulnerability, a misconfigured secret, or a supply chain attack) gains unrestricted access to every other service on the same cluster network. There is no inner firewall, no service-to-service authentication, and no least-privilege enforcement. The attacker who compromises the recommendation service can now call the payment service, the user data service, and the admin API.
Lateral movement is the primary attack vector in microservices breaches. Verizon's Data Breach Investigation Report consistently finds that attackers dwell for an average of 200+ days between initial compromise and data exfiltration — precisely because internal east-west traffic is unmonitored and internal services trust each other implicitly. The attacker moves horizontally through the service mesh, escalating privileges at each step, until reaching the highest-value target.
Supply chain attacks have become the dominant vector for initial compromise. A malicious dependency injected into your build — either through a typosquatting attack on npm/Maven Central or through a compromised legitimate package — executes with the full privileges of the service that imported it. Dependency scanning, SBOM generation, and signed container images are the defense layer that prevents supply chain compromise from becoming a production breach.
The default-deny principle is the foundation of zero-trust microservices security. Every service-to-service call is denied by default; explicit policy grants are required to allow each communication path. Kubernetes NetworkPolicies, Istio AuthorizationPolicies, and OPA Rego rules implement default-deny at the network, service mesh, and application layers respectively. Defense in depth requires all three layers — a network policy alone can be bypassed by an attacker who compromises a pod that has legitimate network access.
Authentication at the Edge: API Gateway & JWT
JWT (JSON Web Token) is the de facto standard for stateless authentication in distributed systems. Its structure — base64url(header).base64url(payload).signature — allows any service to verify a token's authenticity without a network call to an authentication server, using only the public key from the issuer's JWKS endpoint. This stateless verification is what makes JWTs appropriate for microservices: a token issued by your identity provider can be verified by 50 different services without each of them maintaining a session store.
Always use RS256 (RSA + SHA-256) or ES256 (ECDSA + SHA-256) for production JWT signing rather than HS256 (HMAC-SHA-256). HS256 uses a shared symmetric secret that must be distributed to every service that verifies tokens — a secret that, once leaked from any service, compromises your entire authentication. RS256/ES256 use asymmetric key pairs: the identity provider signs with the private key (kept secret in one place), and every service verifies with the public key (safely distributable). The JWKS endpoint (https://auth.example.com/.well-known/jwks.json) serves the public key in a standard format that allows services to automatically rotate their verification keys.
// Spring Boot JWT filter chain configuration
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
private String jwksUri;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // stateless API — CSRF not applicable
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwkSetUri(jwksUri)
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return converter;
}
}
// application.yml
// spring:
// security:
// oauth2:
// resourceserver:
// jwt:
// jwk-set-uri: https://auth.example.com/.well-known/jwks.json
// issuer-uri: https://auth.example.com
Token expiry configuration is a security-availability tradeoff. Short-lived access tokens (15 minutes) minimize the window of exploitation if a token is stolen — but require refresh token rotation to avoid forcing users to re-authenticate every 15 minutes. Configure your identity provider to issue refresh tokens with a 7-day TTL and single-use semantics (each refresh rotates to a new refresh token). Refresh token rotation means a stolen refresh token can only be used once before it is invalidated by the legitimate user's next refresh.
OAuth2 and OIDC for Service-to-Service Auth
OAuth2's Client Credentials Grant is the correct mechanism for machine-to-machine (M2M) authentication — when a service needs to call another service on its own behalf, not on behalf of a user. Unlike the Authorization Code Grant (which requires user interaction), Client Credentials is a direct exchange: the calling service presents its client_id and client_secret to the token endpoint, receives an access token with a configured scope, and uses that token to call the downstream service.
Scope-based authorization enables least-privilege between services. The order service might have scopes inventory:read payment:write notification:write — it can read inventory, create payments, and send notifications, but cannot write to inventory or read payment records. The inventory service might have only inventory:read inventory:write. If the inventory service is compromised, the attacker cannot call the payment API because the stolen access token lacks the payment:write scope. This containment requires that downstream services enforce scope validation, not just signature verification.
// Spring Security OAuth2 resource server with audience + scope validation
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder
.withJwkSetUri(jwksUri)
.build();
// Validate audience claim — reject tokens not intended for this service
OAuth2TokenValidator<Jwt> audienceValidator = token -> {
List<String> audiences = token.getAudience();
if (audiences.contains("inventory-service")) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_token", "Token audience does not include inventory-service", null));
};
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);
jwtDecoder.setJwtValidator(withAudience);
return jwtDecoder;
}
// Keycloak client credentials config (application.yml)
// spring:
// security:
// oauth2:
// client:
// registration:
// keycloak-m2m:
// client-id: order-service
// client-secret: ${ORDER_SERVICE_CLIENT_SECRET}
// authorization-grant-type: client_credentials
// scope: inventory:read,payment:write
// provider:
// keycloak:
// token-uri: https://keycloak.example.com/realms/prod/protocol/openid-connect/token
mTLS: Zero-Trust Service-to-Service Authentication
Mutual TLS (mTLS) provides cryptographic proof of identity for both parties in a service-to-service connection — not just the server (as in standard TLS), but also the client. In an mTLS handshake, the calling service presents its X.509 certificate (signed by a trusted certificate authority), and the receiving service validates it before allowing the connection. This means a compromised service cannot impersonate another service without possessing its private key — a much stronger guarantee than JWT-based service identity, which only requires possession of the client secret.
Manual mTLS certificate management at scale is operationally intractable — 50 services, each with certificates that must be rotated every 90 days, creates 50 rotation events per quarter. cert-manager in Kubernetes automates this: certificates are defined as Kubernetes Certificate resources with a rotation schedule, and cert-manager automatically renews them before expiry and stores them as Kubernetes Secrets. Services mount the certificate as a volume and re-read it on rotation without restarts.
# cert-manager Certificate resource for order-service
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: order-service-mtls-cert
namespace: production
spec:
secretName: order-service-mtls-tls
duration: 2160h # 90 days
renewBefore: 360h # renew 15 days before expiry
subject:
organizations: ["example.com"]
commonName: order-service.production.svc.cluster.local
isCA: false
privateKey:
algorithm: ECDSA
size: 256
usages:
- digital signature
- key encipherment
- client auth # enables use as mTLS client certificate
- server auth # enables use as mTLS server certificate
dnsNames:
- order-service.production.svc.cluster.local
- order-service.production
issuerRef:
name: internal-ca-issuer
kind: ClusterIssuer
---
# Istio PeerAuthentication: enforce mTLS for all traffic in namespace
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
namespace: production
spec:
mtls:
mode: STRICT # reject non-mTLS connections; never use PERMISSIVE in production
Istio's service mesh implements automatic mTLS transparently: the Envoy sidecar proxy intercepts all inbound and outbound traffic and handles the mTLS handshake without any application code changes. From the application's perspective, it makes plain HTTP calls; Istio upgrades them to mTLS at the proxy layer. This "lift and shift" approach allows you to add mTLS to existing services without modifying their code. Debugging mTLS handshake failures: use istioctl proxy-config listeners pod-name to inspect the Envoy configuration, and check istioctl authn tls-check pod-name service-name to verify mTLS status between specific endpoints.
Secrets Management with HashiCorp Vault
Environment variables and Docker layer secrets are the most common and most dangerous secrets anti-pattern in microservices. Environment variables appear in process listings, crash dumps, application logs, and Kubernetes pod descriptions — all accessible to anyone with cluster read access. Secrets baked into Docker image layers persist in image history and any registry that caches them. HashiCorp Vault eliminates both anti-patterns by providing dynamic, short-lived credentials that are generated on demand and automatically revoked.
Vault's dynamic database credentials feature is transformative: instead of a static database password shared across all instances of a service, Vault generates a unique username/password pair for each service instance, valid for 1 hour, and automatically revokes it when the lease expires or the service terminates. A compromised credential is useless within an hour. The service never stores the credential — it requests a new one at startup and on each renewal cycle. The audit log shows exactly which service instance connected to the database at what time using which credentials.
# Vault Agent Injector annotations for Kubernetes pod
# Add these annotations to your Deployment spec.template.metadata.annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "order-service"
vault.hashicorp.com/agent-inject-secret-db-creds: "database/creds/order-service-role"
vault.hashicorp.com/agent-inject-template-db-creds: |
{{- with secret "database/creds/order-service-role" -}}
spring.datasource.username={{ .Data.username }}
spring.datasource.password={{ .Data.password }}
{{- end }}
# Vault automatically injects the secret as /vault/secrets/db-creds
# Spring Boot reads it as a properties file via:
# spring.config.import=optional:file:/vault/secrets/db-creds
# Vault policy for order-service (HCL)
# path "database/creds/order-service-role" {
# capabilities = ["read"]
# }
# path "secret/data/order-service/*" {
# capabilities = ["read"]
# }
# NO write access — services should never write to the secret store
# Vault database role configuration
# vault write database/roles/order-service-role \
# db_name=postgres-prod \
# creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT, INSERT, UPDATE ON orders TO \"{{name}}\";" \
# default_ttl="1h" \
# max_ttl="4h"
Never put secrets in Dockerfile ENV instructions, Kubernetes ConfigMaps, Git repositories (even private ones), or application properties files checked into version control. The blast radius of a leaked Git repository is enormous — attackers routinely scan GitHub for accidentally committed secrets using tools like TruffleHog and GitLeaks. Enable pre-commit hooks with git-secrets or detect-secrets in every developer's environment to prevent accidental secret commits before they reach the remote.
Authorization Patterns: RBAC vs ABAC
Role-Based Access Control (RBAC) assigns permissions to roles, and roles to users or services. It is simple to implement and reason about, scales well for dozens of roles, and maps naturally to organizational structure (admin, user, read-only). RBAC's limitation is coarseness: when authorization decisions need to consider attributes of the resource (is this the user's own data?), the request context (is this a read or write?), or environmental conditions (is the request during business hours?), RBAC requires an explosion of fine-grained roles that becomes unmanageable.
Attribute-Based Access Control (ABAC) evaluates policies that combine subject attributes (user role, department, clearance level), resource attributes (owner, classification, sensitivity), and environment attributes (time, IP, device) to make authorization decisions. OPA (Open Policy Agent) is the industry standard for implementing ABAC policies as code in microservices architectures. Rego policies are declarative, testable, versionable in Git, and deployable as a sidecar or centralized policy decision point.
# OPA Rego policy: users can only access their own order data
# File: policies/orders.rego
package orders
import future.keywords.in
# Default deny
default allow = false
# Allow if user is admin
allow if {
input.user.role == "admin"
}
# Allow read access if user owns the resource
allow if {
input.method == "GET"
input.resource.owner_id == input.user.id
}
# Allow write access only to own resources with active account
allow if {
input.method in ["POST", "PUT", "PATCH"]
input.resource.owner_id == input.user.id
input.user.account_status == "active"
}
# Deny access from non-production environments to sensitive fields
mask_sensitive if {
input.user.role != "admin"
input.environment != "production"
}
// Spring Method Security with @PreAuthorize (RBAC + SpEL)
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
// Only admin or the order owner can view order details
@GetMapping("/{orderId}")
@PreAuthorize("hasRole('ADMIN') or @orderSecurityService.isOwner(#orderId, authentication.name)")
public ResponseEntity<OrderDto> getOrder(@PathVariable String orderId) {
return ResponseEntity.ok(orderService.findById(orderId));
}
// Only admin can delete orders
@DeleteMapping("/{orderId}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> deleteOrder(@PathVariable String orderId) {
orderService.delete(orderId);
return ResponseEntity.noContent().build();
}
}
API Rate Limiting and DDoS Protection
Rate limiting at the API gateway prevents both unintentional traffic spikes and deliberate abuse from consuming all available resources. The token bucket algorithm is the most appropriate for per-user rate limiting: each user has a bucket that refills at a fixed rate (e.g., 100 tokens/minute) and each request consumes one token. Burst requests up to the bucket capacity are allowed; sustained traffic above the refill rate is throttled with HTTP 429. This is friendlier to legitimate users who may have occasional bursts (e.g., a user syncing data after being offline) than strict fixed-window rate limiting which would reject all requests after the window limit is reached.
# nginx rate limiting configuration (API gateway layer)
http {
# Define rate limit zones in shared memory
limit_req_zone $binary_remote_addr zone=per_ip:10m rate=100r/m;
limit_req_zone $http_x_user_id zone=per_user:50m rate=1000r/m;
limit_req_zone $server_name zone=global:10m rate=50000r/m;
server {
location /api/v1/ {
# Per-IP: 100 req/min with burst of 20
limit_req zone=per_ip burst=20 nodelay;
# Per-user: 1000 req/min (authenticated users get higher limits)
limit_req zone=per_user burst=100 nodelay;
# Return 429 (not 503) for rate-limited requests
limit_req_status 429;
# Rate limit response headers for client-side handling
add_header X-RateLimit-Limit 100;
add_header X-RateLimit-Remaining $limit_req_status;
add_header Retry-After 60;
proxy_pass http://api-gateway-upstream;
}
}
}
# Redis-based distributed rate limiting (for multi-node gateways)
# Using Lua script for atomic token bucket check:
# KEYS[1] = rate_limit:{user_id}
# ARGV[1] = max_tokens (100), ARGV[2] = refill_rate (100/60 per sec), ARGV[3] = now
For DDoS protection, deploy a Web Application Firewall (WAF) in front of your API gateway. AWS WAF, Cloudflare, or ModSecurity can block common attack patterns (SQL injection, XSS, path traversal) before they reach your services, detect and block IP ranges associated with known botnets, and apply geo-blocking policies where required. At Kubernetes level, LimitRange and ResourceQuota prevent a single compromised pod from consuming all cluster resources.
Security Scanning in CI/CD Pipeline
Security must be built into every stage of the CI/CD pipeline — not bolted on as a post-deployment checklist. The "shift left" principle means catching vulnerabilities at PR review time (seconds to remediate) rather than in production (days to remediate plus potential breach impact). A mature security pipeline has four layers: SAST (Static Application Security Testing), dependency scanning, container image scanning, and runtime policy enforcement.
SAST with Semgrep or SpotBugs detects insecure code patterns at build time: SQL injection via string concatenation, unsafe deserialization, missing input validation, and hardcoded credentials. Semgrep's community ruleset has hundreds of language-specific security rules with near-zero false positive rates when properly configured. Integrate as a mandatory CI check that blocks merges on high-severity findings.
Dependency scanning with OWASP Dependency Check or Snyk identifies known CVEs in your pom.xml or package.json dependencies. A transitive dependency vulnerability (a CVE in a library your dependency depends on) is indistinguishable from a direct dependency vulnerability in terms of exploitability. Scan the full dependency tree, not just direct dependencies. Configure severity thresholds: block on CRITICAL (CVSS >9.0), warn on HIGH (7.0–9.0), allow LOW/MEDIUM with ticket creation.
# GitHub Actions security pipeline
name: Security Scan
on: [push, pull_request]
jobs:
sast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Semgrep SAST
uses: returntocorp/semgrep-action@v1
with:
config: >-
p/java
p/spring
p/secrets
auditOn: push
dependency-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: OWASP Dependency Check
uses: dependency-check/Dependency-Check_Action@main
with:
project: 'microservices-app'
path: '.'
format: 'HTML'
args: --failOnCVSS 9 --enableRetired
container-scan:
runs-on: ubuntu-latest
needs: [build] # requires image to be built first
steps:
- name: Trivy container scan
uses: aquasecurity/trivy-action@master
with:
image-ref: 'ghcr.io/${{ github.repository }}:${{ github.sha }}'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1' # fail pipeline on HIGH+ vulnerabilities
sbom-generation:
runs-on: ubuntu-latest
steps:
- name: Generate SBOM with Syft
uses: anchore/sbom-action@v0
with:
image: 'ghcr.io/${{ github.repository }}:${{ github.sha }}'
format: spdx-json
output-file: sbom.spdx.json
- name: Attest SBOM
uses: actions/attest-sbom@v1
with:
subject-path: sbom.spdx.json
FAQs: Microservices Security
Q: Should every service validate JWTs, or just the API gateway?
A: Both. The API gateway validates JWTs for all external traffic — this is the primary validation layer. But internal services should also validate tokens on inbound calls, because internal service-to-service traffic may bypass the gateway (e.g., a Kubernetes CronJob directly calling an internal service). Defense in depth requires each service to independently verify identity claims, not trust that the caller has already been authenticated upstream.
Q: Is mTLS or JWT better for service-to-service authentication?
A: They complement each other and address different concerns. mTLS proves transport-layer identity: this connection came from a service with the correct certificate. JWT proves application-layer identity: this request is authorized to perform this action with these scopes. Use both: mTLS for the connection, JWT (Client Credentials grant) for authorization scopes. This provides both network identity proof and fine-grained authorization.
Q: How do we handle JWT revocation in a stateless system?
A: Short token TTL is the primary mechanism — a 15-minute access token self-revokes on expiry. For immediate revocation (compromised account, logout from all devices), maintain a Redis-based token blocklist containing revoked token JTI (JWT ID) claims. Services check the blocklist on each request. The blocklist entry expires at the token's original exp time, so the blocklist is bounded in size. This is the minimal stateful component required for true immediate revocation.
Q: What is the most common microservices security mistake?
A: Secrets in environment variables and Docker layers, by a wide margin. This is followed closely by using HS256 instead of RS256/ES256 for JWT signing (creating a shared secret that must be distributed everywhere), and missing audience validation in JWT verification (allowing tokens issued for service A to be used to call service B).
Q: How do we handle security in development/staging environments?
A: Use the same security patterns (JWT, mTLS via Istio) in all environments — the difference is the certificate authority (a local dev CA instead of the production CA) and the Vault instance (dev Vault with pre-seeded secrets instead of production Vault). This ensures security configurations are tested in every environment and developers build familiarity with secure patterns. "Security is only for production" is an organizational pattern that guarantees security surprises at production launch.
Key Takeaways
- Default-deny is non-negotiable: Kubernetes NetworkPolicies, Istio AuthorizationPolicies, and application-level authorization must all default to deny. Explicit allow policies are the only source of access.
- Use RS256/ES256 for JWTs, never HS256: Asymmetric signing with JWKS endpoint rotation provides safe key distribution at scale without ever exposing the signing key to verifier services.
- Audience validation prevents token substitution attacks: A token issued for
inventory-servicemust be rejected bypayment-service. Always validate theaudclaim, not just the signature. - mTLS via Istio is zero-code for existing services: The service mesh sidecar handles mutual authentication transparently. Add PeerAuthentication STRICT mode to enforce it cluster-wide.
- Dynamic Vault credentials eliminate the static secret problem: Short-lived, per-instance database credentials mean a leaked credential expires within an hour and is traceable to a specific service instance.
- Shift security left into CI/CD: SAST, dependency scanning, and container image scanning in the PR pipeline catch vulnerabilities at the cheapest point — before they ever reach production.
Related Articles
Discussion / Comments
Join the conversation — your comment goes directly to my inbox.