Monolithic applications have a single security boundary. Microservices have many. Each service needs authentication, authorization, and secure communication. The attack surface is larger, and security patterns are different.
Here’s how to secure microservices architectures.
Authentication Patterns
API Gateway Authentication
Centralize authentication at the gateway:
Client → API Gateway → Microservices
↓
Authenticate
Validate token
Add identity headers
# Kong/nginx configuration
routes:
- path: /api/*
plugins:
- name: jwt
config:
key_claim_name: kid
- name: request-transformer
config:
add:
headers:
- X-User-ID:$(jwt.sub)
- X-User-Role:$(jwt.role)
Pros:
- Consistent authentication
- Simple service implementation
- Single point of token validation
Cons:
- Gateway becomes critical path
- Internal services trust headers (need network isolation)
Token-Based Service Authentication
Services validate tokens independently:
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
if token == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
claims, err := validateJWT(token)
if err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userClaimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Pros:
- No gateway dependency
- Each service enforces authentication
- Works for service-to-service calls
Cons:
- Duplicated validation logic
- Token validation overhead per service
Service-to-Service Authentication
Internal services need identity:
mTLS (Mutual TLS):
# Istio PeerAuthentication
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
spec:
mtls:
mode: STRICT
All traffic encrypted and authenticated. Services have cryptographic identity.
Service Account Tokens:
// Service gets token for calling other services
func getServiceToken() string {
claims := jwt.MapClaims{
"iss": "order-service",
"sub": "order-service",
"aud": "payment-service",
"exp": time.Now().Add(5 * time.Minute).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return token.SignedString(servicePrivateKey)
}
Authorization Patterns
Centralized Authorization Service
Dedicated service makes authorization decisions:
Service → Authorization Service → Decision
↓
Policy Engine (OPA)
↓
Policies
// Check authorization
func isAuthorized(ctx context.Context, action, resource string) bool {
userID := getUserID(ctx)
resp, err := authzClient.Check(ctx, &CheckRequest{
Subject: userID,
Action: action,
Resource: resource,
})
return err == nil && resp.Allowed
}
Pros:
- Consistent policy enforcement
- Centralized policy management
- Audit logging
Cons:
- Latency added to every request
- Authorization service availability is critical
Embedded Authorization
Policies evaluated within services:
// Open Policy Agent embedded
var policy = `
package authz
default allow = false
allow {
input.user.role == "admin"
}
allow {
input.user.id == input.resource.owner_id
input.action == "read"
}
`
func isAuthorized(user User, action string, resource Resource) bool {
input := map[string]interface{}{
"user": user,
"action": action,
"resource": resource,
}
result, _ := rego.New(
rego.Query("data.authz.allow"),
rego.Module("authz.rego", policy),
).Eval(ctx, rego.EvalInput(input))
return result[0].Expressions[0].Value.(bool)
}
Pros:
- No network call
- Low latency
- Works offline
Cons:
- Policy distribution complexity
- Consistency across services
Token-Embedded Permissions
Include permissions in JWT:
{
"sub": "user_123",
"roles": ["user", "premium"],
"permissions": ["read:orders", "write:orders", "read:products"],
"tenant_id": "tenant_456"
}
func hasPermission(ctx context.Context, required string) bool {
claims := getClaims(ctx)
for _, perm := range claims.Permissions {
if perm == required {
return true
}
}
return false
}
Pros:
- No external call
- Fast authorization
- Self-contained
Cons:
- Token size limits
- Revocation lag (token lifetime)
- Can’t handle dynamic permissions
Secure Communication
Service Mesh Encryption
Let infrastructure handle encryption:
# Istio - encrypt all internal traffic
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
namespace: production
spec:
mtls:
mode: STRICT
---
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: order-service-policy
spec:
selector:
matchLabels:
app: order-service
rules:
- from:
- source:
principals: ["cluster.local/ns/production/sa/api-gateway"]
to:
- operation:
methods: ["GET", "POST"]
paths: ["/orders/*"]
Benefits:
- Transparent to applications
- Automatic certificate rotation
- Identity-based policies
Application-Level Encryption
Encrypt sensitive data end-to-end:
// Encrypt before sending
func sendToPaymentService(card CardDetails) error {
encrypted, err := encrypt(card, paymentServicePublicKey)
if err != nil {
return err
}
return paymentClient.ProcessCard(encrypted)
}
// Decrypt in payment service
func ProcessCard(encrypted []byte) error {
card, err := decrypt(encrypted, privateKey)
if err != nil {
return err
}
return processCard(card)
}
Defense in depth: even if network is compromised, data is protected.
Secrets Management
Don’t Hardcode Secrets
Never:
// Never do this
var apiKey = "sk_live_abc123"
var dbPassword = "production_password"
External Secrets Management
Use HashiCorp Vault or similar:
// Vault client
func getSecret(path string) (string, error) {
secret, err := vaultClient.Logical().Read(path)
if err != nil {
return "", err
}
return secret.Data["value"].(string), nil
}
func connectDB() (*sql.DB, error) {
password, err := getSecret("secret/data/db/password")
if err != nil {
return nil, err
}
return sql.Open("postgres", fmt.Sprintf(
"host=db user=app password=%s dbname=app",
password,
))
}
Short-Lived Credentials
Prefer dynamic secrets:
// Vault generates temporary database credentials
creds, err := vaultClient.Logical().Read("database/creds/app-role")
username := creds.Data["username"].(string)
password := creds.Data["password"].(string)
// Credentials auto-expire after TTL
Kubernetes Secrets
Inject secrets via environment or volume:
apiVersion: v1
kind: Pod
spec:
containers:
- name: app
envFrom:
- secretRef:
name: app-secrets
volumeMounts:
- name: secrets
mountPath: /etc/secrets
readOnly: true
volumes:
- name: secrets
secret:
secretName: app-secrets
Defense in Depth
Network Policies
Restrict traffic between services:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: order-service
spec:
podSelector:
matchLabels:
app: order-service
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: api-gateway
ports:
- port: 8080
egress:
- to:
- podSelector:
matchLabels:
app: payment-service
ports:
- port: 8080
- to:
- podSelector:
matchLabels:
app: database
ports:
- port: 5432
Default deny, explicit allow.
Input Validation
Every service validates input:
type OrderRequest struct {
UserID string `json:"user_id" validate:"required,uuid"`
ProductID string `json:"product_id" validate:"required,uuid"`
Quantity int `json:"quantity" validate:"required,min=1,max=100"`
Amount float64 `json:"amount" validate:"required,min=0.01"`
}
func createOrder(r *http.Request) (*Order, error) {
var req OrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, err
}
if err := validate.Struct(req); err != nil {
return nil, err
}
// Trust nothing, validate everything
return processOrder(req)
}
Least Privilege
Services get minimum required permissions:
# Kubernetes ServiceAccount with minimal RBAC
apiVersion: v1
kind: ServiceAccount
metadata:
name: order-service
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: order-service
rules:
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["order-service-config"]
verbs: ["get"]
---
# Database user with minimal permissions
GRANT SELECT, INSERT, UPDATE ON orders TO order_service;
GRANT SELECT ON products TO order_service;
-- No DELETE, no other tables
Audit Logging
Log security-relevant events:
func auditLog(ctx context.Context, event AuditEvent) {
logger.WithFields(log.Fields{
"event_type": event.Type,
"actor": event.Actor,
"resource": event.Resource,
"action": event.Action,
"outcome": event.Outcome,
"timestamp": time.Now(),
"request_id": getRequestID(ctx),
"client_ip": event.ClientIP,
}).Info("Audit event")
}
// Log all authorization decisions
func authorize(ctx context.Context, action, resource string) bool {
allowed := checkPermission(ctx, action, resource)
auditLog(ctx, AuditEvent{
Type: "authorization",
Actor: getUserID(ctx),
Resource: resource,
Action: action,
Outcome: allowed,
})
return allowed
}
Key Takeaways
- Authenticate at gateway for external requests, use mTLS for service-to-service
- Choose authorization pattern based on latency requirements and policy complexity
- Use service mesh for transparent mTLS and network policies
- Never hardcode secrets; use external secrets management
- Prefer short-lived, dynamically generated credentials
- Apply defense in depth: network policies, input validation, least privilege
- Audit log all security-relevant events
- Validate everything; trust nothing—even internal traffic
Microservices security requires more effort than monoliths, but the patterns exist. Apply them consistently across all services.