Breaking up the monolith felt like progress, and architecturally it was. Security-wise, you traded one trust boundary for dozens. In the monolith, a function call was just a function call, all inside the same trusted process. Now every one of those calls crosses the network, between services that have to decide whether to trust each other. The instinct that gets teams breached is to answer that question with "it's internal, so yes."
"Internal" is not authentication
That assumption fails the moment any of these is true, and one of them eventually is: an attacker compromises a single service, a misconfigured security group exposes an internal service, or an insider has network access. So authenticate the calls themselves:
- mTLS. Both sides present certificates. A service mesh can handle the plumbing.
- JWT. A signed token on each request.
- Service mesh. Istio or Linkerd can do mTLS transparently, so individual services don't have to implement it.
Zero trust, in practice
The principle is to stop trusting requests based on where they came from on the network. Every request gets authenticated and authorised regardless of origin. Concretely, that's mTLS or JWT between all services, a per-service IAM role, network segmentation, and logging at every boundary so you can see who called what.
Passing user context safely
A common pattern: the edge validates the user's JWT once, then passes the user's identity downstream in a trusted header. That's fine, with one condition. Only trust that internal header when you've verified the identity of the service that set it. Otherwise any service that can reach the network can claim to be any user.
Where serverless differs
Functions bring their own sharp edges:
- Cold-start timing can leak through naive auth comparisons, so use a constant-time comparison.
- Over-permissioned IAM is the big one. A single function with
*:*permissions, once compromised, is the whole account. - Event injection is easy to forget, because the input isn't an HTTP request. Validate your SQS messages and S3 keys like any other untrusted input.
Isolate the data too
Give each service its own database, and let other services reach that data through its API rather than querying the tables directly. Separate database users with scoped schema permissions keep one compromised service from reading everything.
The mindset underneath all of it: assume any single component could already be compromised, and make sure that one breach buys the attacker as little as possible.
