Library
13 min read

Security in system design

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.

Your answer

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.

Figure 1. Four boundaries, each with its own check. The internet meets the edge, the edge meets your services, services meet each other, and services meet the data. A request is checked at every one, not only the first.

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.

Change one number in the URL
GET /v1/orders/
200 OK
{ "order": 1041, "customer": "Asha", "item": "Running shoes", "ship_to": "Flat 4B, Koramangala, Bengaluru" }
Asha's own order. Now change the number in the URL, the way anyone can in a browser.
Logged in as Asha, user 7. Every request carries her valid token. This bug is number one on the OWASP API Security Top 10 (2023), as broken object level authorization.

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.
When your server fetches a URL for someone

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#

Attack surface
normal
normalstuffingleaked keya URL featurerepaired
Healthy. Tokens checked at the gateway, objects checked in each service, mTLS inside, secrets in a store. A boring day.
Read every step as text
  1. normal · Healthy. Tokens checked at the gateway, objects checked in each service, mTLS inside, secrets in a store. A boring day.
  2. 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.
  3. 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.
  4. 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.
  5. 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#

ChoiceWhat you gainWhat you payPick it when
Authenticate at the gateway, authorise in each serviceOne 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 servicesA 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 storeA 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 objectsCounting through ids reveals nothing.Debugging a genuine permission problem is a little harder.Any id a caller could guess.

Interview replay#

Interviewer
How is this design protected?
The open question that sorts answers into lists and walks.
You
Let me walk the boundaries. At the edge, a CDN absorbs floods and terminates TLS. The gateway checks the session token, rate limits per account and per IP, and passes a verified user id inward, stripping any identity header that came from outside. Each service checks ownership when it loads an object, by querying through the user id. Services call each other over mutual TLS with the least permissions they need. Secrets come from a store and expire. Data is encrypted at rest, and passwords are hashed with Argon2.
A walk across the diagram, one check per boundary.
Interviewer
What is the bug you would worry about most?
Checking for real world awareness.
You
Broken object level authorization: an endpoint that checks the token but not whether this user may see this object, so changing an id in the URL returns someone else's data. It is the top item on OWASP's API list because it only takes one forgotten check. I would load objects through the owner in the query itself, return 404 for other people's objects, and add a test per endpoint that tries another user's id.
Names it, says why it is common, and gives a structural fix plus a test.
Interviewer
We are adding "import a profile picture from a URL".
A trap feature.
You
That is a server fetching an address the user chose, so it can be pointed inward, at the cloud metadata service or an internal admin page. I would fetch through a proxy that only allows public addresses, refuse redirects to private ranges, cap size and time, and require the token based metadata service so a plain request cannot read credentials.
Recognises the shape and fixes it at the boundary.

Checkpoint#

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?

Worth memorising
  • 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.
Say this in 60 seconds

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.