Security in system design
Read firstAPI designAuthn vs authz
Most design rounds never mention security until the interviewer asks "and how is this protected?" with ten minutes left. A good answer is not a list of buzzwords. It is a short walk across the design: where untrusted data comes in, who checks what at each step, and what an attacker would try first. That walk takes two minutes, and it is the subject of this chapter.
The individual ideas each have an article: authentication vs authorization, sessions vs JWT, API keys vs OAuth vs mTLS and hashing vs encryption vs encoding. Here is where they sit in a design.
A logged in user edits the URL from /orders/1041 to /orders/1042. What has to be true of your service for them to see nothing?
Draw the trust boundaries#
A trust boundary is any line where data or a request crosses from something you control less to something you control more. Draw them on your design and the security answer writes itself, because each boundary asks the same three questions: who is calling, what are they allowed to do, and what could they reach if a check were missing.
Who is calling: check identity once, at the edge#
The gateway checks every request's session or token, rejects anything invalid, and passes a verified user id to the services behind it. Services then trust that id because it came from the gateway, so two rules follow:
- Strip identity headers that come from outside. If services read the user from a header
like
X-User-Id, the gateway must delete any copy the caller sent and set its own. Otherwise anyone can claim to be anyone with one header. - Short lived tokens. A stolen token is a stolen session until it expires. Access tokens that last minutes, refreshed from a longer lived one that can be revoked, keep that window small. Access tokens vs refresh tokens covers the pattern.
What may they do: check permission next to the data#
Knowing who someone is says nothing about what they may touch. That check has to happen where the object is loaded, in the service that owns it, because only that service knows whose order 1042 is.
That bug, often called IDOR (insecure direct object reference), is the most common serious flaw in real APIs, and it sits at number one on OWASP's API Security Top 10 for 2023 as broken object level authorization. It is easy to see why: every endpoint that takes an id needs the check, and one missing check leaks everything behind that endpoint. Two habits stop it:
- Load through the owner. Fetch "order 1042 belonging to user 7", not "order 1042" followed by a check that someone might forget. A query that cannot return other people's rows cannot leak them.
- Answer 404, not 403, for something the caller may not see, so counting through ids does not even reveal which ones exist. Random ids help too, but they hide the problem rather than fix it.
Services talking to services#
Inside the network is not the same as trusted. An attacker who gets one foothold, through a vulnerable library say, will try to call everything else from there. So services prove who they are to each other as well:
- Mutual TLS. Both sides of every connection present a certificate, so the payment service knows the caller really is the order service, and the traffic is encrypted even inside the data centre. A service mesh can do this for every service without code changes.
- Least privilege. The order service may call "charge this order" on payments. It may not call "refund anything". Each service gets only the permissions its job needs.
Secrets and data#
- Secrets live in a secret store, never in code, config files in the repo, or container images. Services fetch them at start up, and they are rotated on a schedule, so a leaked key stops working on its own. Better still are credentials that expire in minutes, issued to the service's identity, so there is no long lived key to leak at all.
- TLS on every connection, outside and in.
- Encryption at rest with envelope encryption. Each piece of data is encrypted with its own data key, and the data keys are encrypted by a master key that never leaves a key management service. Rotating the master key does not mean re-encrypting every file.
- Passwords are hashed with a slow function like bcrypt or Argon2, never encrypted and never a fast hash. Slow is the point: it makes guessing billions of passwords from a stolen table impractical.
- Keep less. Data you never stored cannot leak. And keep tokens, passwords and card numbers out of logs, which are copied to more places than any database.
Floods and abuse#
Not every attack wants your data. Some want you down, and some want to use your features at a scale you never planned for.
- Floods of raw traffic are absorbed by the CDN's network before they reach you. On 28 February 2018 GitHub took a 1.35 terabit per second flood, made by bouncing tiny requests off thousands of exposed memcached servers that replied with huge answers. It was unreachable on and off for about ten minutes, until its traffic was routed through a DDoS protection service that absorbed the flood.
- Floods of valid looking requests are harder. In 2023 attackers used a trick in HTTP/2, opening a stream and cancelling it at once, over and over. Google saw a peak above 398 million requests a second, and Cloudflare above 201 million. Defences sit at the edge and in the server software, which is one more reason to have an edge.
- Abuse of features is the most common. Login gets credential stuffing: passwords leaked elsewhere tried against your users. Sign up gets fake accounts, and ticket holds get bots. Rate limits per account and per IP, checks against known leaked passwords, and limits on what one account can hold at once are part of the design of those features, not an add on.
In 2019 an attacker got a misconfigured web application firewall in Capital One's cloud account to send requests to the cloud's instance metadata service, an internal address that hands a server temporary credentials for its own role. Those credentials could read storage holding records on about 100 million people in the US and 6 million in Canada. The general lesson: any feature where your server fetches an address someone else supplied (a webhook, an image preview, an import from a URL) can be pointed inward. Allow only the destinations you expect, block internal and metadata addresses, and on AWS require the newer metadata service, IMDSv2, which needs a session token that a simple forged request cannot get.
Break it#
Read every step as text
- normal · Healthy. Tokens checked at the gateway, objects checked in each service, mTLS inside, secrets in a store. A boring day.
- stuffing · Degraded. A list of passwords leaked from another site is tried against your login, a few per account from thousands of IPs. Each request looks valid, so a limit per IP alone misses it. Limits per account, checks against known leaked passwords, and step up verification on unusual logins catch it.
- leaked key · Down. A database password lands in a public repository in a config file. Bots scan public repositories for keys constantly, so assume it was found within minutes. A key that never expires is now somebody else's.
- a URL feature · Down. A new "import from URL" feature fetches whatever address it is given. Someone gives it an internal address, and your server reads its own cloud credentials back to them.
- repaired · Healthy. Per account limits and leaked password checks on login. Secrets fetched from a store and short lived, with scanning that blocks a key from being committed. URL fetching through a proxy that allows only public addresses. Each fix is at the boundary where the attack crossed.
Trade-offs#
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| Authenticate at the gateway, authorise in each service | One place checks tokens, and the service that owns an object decides who may see it. | Every service must remember the ownership check on every endpoint that takes an id. | Always. Authorisation cannot live at the gateway, because the gateway does not know who owns order 1042. |
| Mutual TLS between services | A foothold in one service cannot impersonate another, and inside traffic is encrypted. | Certificates to issue and rotate, usually through a mesh. | Anything that handles money or personal data, and in practice the whole fleet once a mesh exists. |
| Short lived credentials from a secret store | A leaked key stops working by itself. | Services depend on the store at start up, and every refresh is a moving part. | Databases, cloud APIs and anything that would hurt if it leaked. |
| 404 instead of 403 for other people's objects | Counting through ids reveals nothing. | Debugging a genuine permission problem is a little harder. | Any id a caller could guess. |
Interview replay#
Checkpoint#
1. A request carries a valid token for user 7 and asks for order 1042, which belongs to user 9. Where should it be refused?
2. Why are passwords hashed with a slow function like Argon2 rather than encrypted?
3. Same system, and a new feature fetches a web page from any URL a user pastes. What changes first?
- Four boundaries: internet to edge, edge to services, service to service, service to data.
- Authenticate at the gateway, authorise next to the data. Broken object level authorization is number one on OWASP's 2023 API list.
- Short lived credentials from a secret store, so a leak expires on its own.
- A server that fetches user supplied URLs can be pointed inward. Allow only public destinations.
I answer security by walking the trust boundaries on the design. At the edge, a CDN absorbs floods, which can reach terabits or hundreds of millions of requests a second, and terminates TLS. The gateway authenticates every request with a short lived token, rate limits per account as well as per IP, and passes a verified user id inward after stripping any identity header that came from outside. Authorisation happens in the service that owns each object, by loading it through the owner, with 404 for anything the caller may not see, because broken object level authorization is the most common serious API flaw. Services call each other over mutual TLS with only the permissions they need. Secrets come from a store and expire on their own, data is encrypted at rest with envelope encryption, passwords are hashed with a slow function like Argon2, and secrets never reach the logs. And any feature where my server fetches a URL someone else supplied goes through a proxy that allows only public addresses, because otherwise it can be pointed at my own network.