Decomposing a monolith into microservices or serverless functions creates new trust boundaries, new network surface, and new authentication challenges. This lesson covers the security implications of distributed architectures and how to handle them.
New boundaries, new risks
A monolith has one trust boundary: the outer edge. Microservices have many:
- Every service-to-service call crosses a boundary
- Every message queue, event bus, and shared database creates a data flow
- Every new service adds network surface, IAM roles, and secrets
More boundaries means more places to get security wrong.
Service-to-service authentication
The problem with "it's internal"
Many microservice architectures skip authentication between internal services because they rely on network-level isolation. This fails when:
- An attacker compromises one service and pivots to others
- A misconfigured security group exposes an internal service to the internet
- A developer accidentally calls the wrong endpoint
- An insider threat has network access
Options for internal auth
mTLS (Mutual TLS): Both the client and server present certificates. The service mesh handles certificate issuance and rotation automatically. Strong, but operationally complex without a mesh.
JWT tokens: Each request carries a signed JWT that identifies the calling service (and optionally the user). The receiving service validates the signature and claims.
# Service A calls Service B with a signed JWT
headers = {"Authorization": f"Bearer {service_jwt}"}
response = requests.get("https://service-b/api/data", headers=headers)
API keys: Simple shared secrets per service pair. Easy to implement, hard to manage at scale, no user context.
Service mesh (Istio, Linkerd): Handles mTLS, identity, and authorisation policies between services transparently. The application code does not need to manage auth — the sidecar proxy handles it.
| Method | Complexity | User context | Rotation |
|---|---|---|---|
| mTLS | High (without mesh) | No | Automated via mesh |
| JWT | Medium | Yes | Key rotation |
| API keys | Low | No | Manual |
| Service mesh | High (initial setup) | Via headers | Automated |
Recommendation: If you are already using a service mesh, leverage it for mTLS. Otherwise, signed JWTs give the best balance of security and usability.
Zero trust architecture
Zero trust means: never trust a request based solely on its network location. Every request must be authenticated and authorised, regardless of whether it comes from inside or outside the network.
Key principles:
- Verify explicitly. Authenticate every request with credentials, tokens, or certificates.
- Least privilege access. Each service should only have access to the specific resources it needs.
- Assume breach. Design as if the network is already compromised. Limit blast radius.
In practice, zero trust for microservices means:
- mTLS or JWT auth between all services
- Per-service IAM roles (not a shared "backend" role)
- Network segmentation (services can only reach the specific other services they need)
- Logging and monitoring on every service boundary
Request context propagation
When a user makes a request that passes through multiple services, each service needs to know who the user is and what they are allowed to do.
The wrong way
Each service authenticates the user independently:
User → Service A (validates JWT) → Service B (validates JWT) → Service C (validates JWT)
This means every service needs access to the user's JWT, which may have expired by the time it reaches Service C. It also means every service needs the public key and validation logic.
The right way
The edge service (API gateway) validates the user's JWT. Downstream services receive user context in a trusted header or a new internal JWT:
User → API Gateway (validates user JWT) → Service A (trusts X-User-Id header) → Service B
The internal X-User-Id header is trusted because:
- Only the API gateway can reach internal services (network isolation)
- Or internal services verify the header using mTLS/signed tokens
Never trust user context headers from the internet. Only trust them when you can verify the calling service's identity.
Serverless-specific concerns
Cold start timing attacks
Serverless functions that authenticate based on execution time (e.g., password comparison that returns early on mismatch) may leak information through response latency. Use constant-time comparison for sensitive operations.
Over-permissioned IAM roles
Serverless functions are often deployed with overly broad IAM roles because configuring per-function roles is tedious. A single compromised function with *:* permissions can access every resource in the account.
Fix: Define minimal IAM policies per function.
# AWS SAM — minimal permissions
Policies:
- DynamoDBReadPolicy:
TableName: !Ref OrdersTable
Shared state
Serverless functions are stateless by design, but they often share a database, cache, or queue. The shared state is the trust boundary. Each function should only have access to the specific tables, buckets, or queues it needs.
Event injection
Serverless functions triggered by events (queue messages, S3 uploads, API Gateway requests) must validate their input. A malicious message in an SQS queue or a crafted S3 object key can trigger injection vulnerabilities in the handler.
# Lambda handler — validate input even from "internal" sources
def handler(event, context):
order_id = event.get("order_id")
if not isinstance(order_id, str) or not order_id.isalnum():
raise ValueError("Invalid order ID")
Data isolation in multi-service architectures
Database per service
Each service owns its data. Other services access that data through the owning service's API, not by querying the database directly. This enforces access control at the service level and prevents one service's compromise from exposing another service's data.
Shared databases
If multiple services share a database (common in early-stage microservice migrations), use separate database users with schema-level permissions:
-- Service A can read/write the orders schema
GRANT SELECT, INSERT, UPDATE ON orders.* TO service_a;
-- Service B can only read the orders schema
GRANT SELECT ON orders.* TO service_b;
Encryption boundaries
If services handle different classification levels (one processes payment data, another handles public content), data should be encrypted with separate keys. A breach of the content service should not expose payment data.
Common mistakes
- Trusting all internal traffic. Internal network ≠ trusted network.
- One IAM role for all services. Violates least privilege.
- Logging sensitive data in distributed traces. Trace headers and logs often travel to centralised systems. Ensure tokens, PII, and secrets are not included.
- No timeouts on internal calls. A failing downstream service can cascade, causing denial of service across the system.
- Ignoring message queue security. If anyone can publish to a queue, anyone can trigger business logic. Authenticate publishers.
Summary
Microservices and serverless architectures multiply trust boundaries. Authenticate every service-to-service call — do not rely on network isolation alone. Use least-privilege IAM roles per service. Propagate user context securely from the edge. Validate input even from internal event sources. Apply zero trust principles: verify explicitly, grant minimal access, and assume any component could be compromised.
