System Design Primer · llms.txt
Written for this site, not part of The System Design Primer. The sources it draws on are listed under Further reading.
Security is hard to add to a system after it's built. It decides how every request is authenticated, what each caller is allowed to do, how data is protected on the wire and on disk, and what happens when one of those defenses fails. A fast, scalable system that leaks user data, or falls over under the first denial-of-service attack, has still failed.
The primer's security section is a short list of concepts with links to checklists. This chapter covers the pieces that show up in system design: how users and services prove who they are (sessions, JWTs, OAuth 2.0, OpenID Connect, mTLS), how access is decided (RBAC and ABAC), what the edge and the API gateway defend against, how data is encrypted with keys held in a KMS, and the common application bugs that undo all of it.
It builds on the primer's security, application layer, reverse proxy and communication chapters, and on reverse proxy and API gateway 101.
Authentication answers who is making this request? In a distributed system there are two kinds of caller: people using a browser or mobile app, and services calling other services. They are usually handled differently.
When a user logs in, the system checks their credentials, such as a password plus a second factor. HTTP doesn't remember anything between requests, so the system then gives the client something to send with every later request that proves it already logged in. There are two common designs.
| Server-side session | Self-contained token (JWT) | |
|---|---|---|
| What the client holds | An opaque random session ID, usually in a cookie. | A signed token that carries the user's ID, expiry and other claims. |
| What the server stores | The session record (user ID, expiry, etc.) in Redis or a database. | Nothing per user. It needs only the key to check signatures. |
| Checking a request | Look the session ID up in the store. | Verify the signature with a public key or shared secret, then check the expiry. |
| Logging someone out | Delete the session record. It takes effect on the next request. | Hard. The token stays valid until it expires, unless servers check a revocation list, which brings back a lookup. |
| Size | Small: a random ID of about 32 characters. | Hundreds of bytes or more, since claims are base64url-encoded JSON. |
| Good fit | First-party web apps, where instant logout and account bans matter. | APIs called by many services or across domains, where a shared session store is awkward. |
A JWT (JSON Web Token) has three base64url-encoded parts separated by dots: a header saying which algorithm signed it, a payload of claims (sub for the user ID, exp for expiry, aud for the intended audience, and so on), and the signature. The payload is only encoded, not encrypted, so anyone holding the token can read it. Never put secrets in it.
Most production systems combine the two. They use a short-lived access token (valid 5 to 15 minutes) that APIs check without any lookup, and a long-lived refresh token (days to weeks) that is stored and checked server-side. When the access token expires, the client sends the refresh token to the authorization server. That server checks that the account is still active and the refresh token hasn't been revoked, then issues a new access token. Many servers also rotate the refresh token on each use and treat reuse of an old one as a sign of theft.
So a banned account keeps access for at most one access-token lifetime, a few minutes, while ordinary requests never touch the token store.
Most systems don't want every component handling passwords. They hand that job to a dedicated identity provider, using two standards that are often confused:
iss (who issued it), sub (a stable user ID), aud (which application it's for), exp, and optionally profile claims like email. "Sign in with Google" is OIDC. The ID token is meant for the application that requested it, to learn who logged in. It is not meant to be sent to APIs, which take access tokens.For browser and mobile apps, the recommended flow is the authorization code flow with PKCE (Proof Key for Code Exchange, RFC 7636). The steps:
code_verifier, and computes code_challenge = BASE64URL(SHA-256(code_verifier)).code_challenge.code_verifier. The provider hashes it and checks it matches the challenge from step 2.Someone who intercepts the code in step 3 can't use it, because they don't have the verifier. The current OAuth security best practice (RFC 9700) recommends PKCE for every client and says not to use the old implicit flow, which returned tokens directly in the redirect URL.
Inside a private network or Kubernetes cluster, services also need to know who is calling them. Trusting a request because it comes from an internal IP address is weak: an attacker who takes over one container can then call anything, and IP addresses change constantly anyway.
In ordinary TLS only the server proves its identity. With mutual TLS (mTLS), both sides do, during the TLS handshake:
Now B knows exactly which service is calling, and can apply rules like "only the checkout service may call payments". Managing certificates by hand for hundreds of services doesn't work, so in practice a service mesh does it. Each service's sidecar proxy gets a short-lived certificate encoding the service's identity (often a SPIFFE ID such as spiffe://cluster.local/ns/shop/sa/checkout), renews it automatically, and handles the handshake. The application itself speaks plain HTTP to its local sidecar. See reverse proxy and API gateway 101.
Once you know who the caller is, authorization answers may this caller do this action on this resource?
RBAC grants permissions to named roles and gives roles to users. A user with the editor role gets documents:read and documents:write, for example.
RBAC is simple to build and easy to audit ("who can delete invoices?" is a lookup), but it struggles once rules depend on context:
Encoding each of these as a role leads to role explosion: hundreds of roles like editor_finance_us_readonly_weekend that nobody can keep track of.
ABAC decides using policies over attributes, usually in four groups:
A policy might say: allow users in the Finance department to edit Budget documents whose region matches the user's region, from a managed device. One rule like that replaces dozens of roles. The cost is that "who can access this?" is harder to answer, because the answer depends on attributes at request time.
In practice the two mix: roles are just one attribute among many. For "users can edit documents shared with them" style rules, many systems use relationship-based access control (ReBAC), which checks a graph of who owns or was shared what. Google's Zanzibar paper describes the best-known example.
Larger systems split the work in two (the terms come from the XACML standard):
Keeping policy in one engine means rules are written once, tested, and changed without redeploying every service. PDPs are usually run next to the PEP (as a sidecar or library) so a decision doesn't add a network round trip to every request.
The guiding principle is defense in depth: put independent controls at each layer (the edge, the network, the application, the data), so that one control failing doesn't expose everything. The WAF misses an injection, but the query is parameterized. An attacker gets a database backup, but it's encrypted with a key they can't reach.
Before traffic reaches application servers, it passes through several layers, each stopping a different kind of attack:
The older model was castle and moat: everything outside the firewall is untrusted, everything inside is trusted. Its weakness is that one compromised laptop or container puts the attacker inside, with access to everything.
Zero trust (described in NIST SP 800-207) assumes the attacker may already be inside:
Data needs protection in two states: moving across a network (in transit) and stored on disk (at rest).
TLS gives confidentiality, integrity and server authentication between two endpoints. TLS 1.3 (RFC 8446, 2018) is the current version. Compared with TLS 1.2 it brings:
Terminate TLS at the edge for internet traffic, and use TLS or mTLS again on internal hops. Leaving traffic unencrypted inside the network is what zero trust tells you not to do.
Encrypting data with a key stored on the same disk, or in the app's config file, protects very little: whoever gets the data probably gets the key too. Production systems use envelope encryption, which separates two kinds of key:
Writing data:
GenerateDataKey.Reading it back:
Decrypt). The KMS checks the caller's permissions, unwraps it with the KEK, and returns the plaintext DEK.Why this design:
ReEncrypt call for this), not the data.Database passwords, API keys and private keys committed to source code or baked into container images are a common cause of breaches: they end up in git history, CI logs and image registries. Keep them in a secrets manager instead (HashiCorp Vault, AWS Secrets Manager, Google Secret Manager). Kubernetes Secrets are only base64-encoded unless you turn on encryption at rest, ideally with a KMS provider.
Strong architecture is undone by a single injectable query. These are the bugs that keep appearing in the OWASP Top 10, and the standard defenses.
Injection happens when untrusted input is pasted into a query or command, so the input can change what the query means.
"SELECT * FROM users WHERE email = '" + input + "'". An input of ' OR '1'='1 turns it into a query that matches every user.XSS happens when an app puts untrusted data into a page without escaping it, letting an attacker run JavaScript in other users' browsers.
HttpOnly cookies so scripts can't read them through document.cookie. This limits the damage, though injected script can still act as the user while the page is open.'unsafe-inline', stops most injected scripts from running.CSRF tricks a logged-in user's browser into sending a request to your site from another site, for example a hidden form that posts a money transfer. The browser attaches the user's cookies, so the request looks genuine.
SameSite on session cookies. With SameSite=Lax, the browser doesn't send the cookie on cross-site POSTs or on requests embedded in another site (images, iframes, fetch). It still sends it when the user follows a plain link to your site, so GET requests must never change state. SameSite=Strict doesn't send it on any cross-site request, which is safer but means users arriving from a link appear logged out. Most browsers now treat cookies without a SameSite attribute as Lax.X-CSRF-Token, and checked by the server on every state-changing request. Another site can't read the token, so it can't forge the request.Authorization header aren't exposed to CSRF in the first place, because browsers don't attach that header automatically.SSRF happens when a server fetches a URL supplied by a user, such as a link preview or an image import, and doesn't restrict where it can go.
http://127.0.0.1:8080/admin or the cloud metadata service at http://169.254.169.254/, which on a misconfigured instance hands out temporary cloud credentials. This was the core of the 2019 Capital One breach.10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, loopback 127.0.0.0/8, link-local 169.254.0.0/16, and their IPv6 equivalents (::1, fc00::/7, fe80::/10). Connect to the IP address you checked, not the name, so a DNS answer that changes between your check and your request (DNS rebinding) can't get around the filter. Apply the same check to every redirect.PUT request that returns a session token, and by default allows only one network hop, so a simple GET forwarded through a vulnerable app can't reach it.A JWT is checked by its signature and expiry alone, so it can't be revoked. If one is stolen, or you ban the account, it keeps working until it expires. Make tokens last 7 days and that's a 7-day exposure. Make them last 10 minutes with nothing else, and users have to log in every 10 minutes.
The usual fix pairs a short-lived access token (5–15 minutes) with a refresh token that is stored server-side, kept in an HttpOnly cookie on the web, and often rotated on each use. Normal API calls stay lookup-free. Every few minutes, the refresh gives the authorization server a checkpoint where it can refuse a banned account or a revoked token. The worst case after a ban drops to one access-token lifetime.
OAuth 2.0 is for delegated authorization: it gives an application an access token to call an API on the user's behalf, limited by scopes. It doesn't define how to learn who the user is. OpenID Connect adds authentication on top: an ID token, a signed JWT that tells the application who logged in (sub, iss, aud, exp).
An API should accept access tokens, checking that the token is meant for it (the audience) and carries the needed scopes. ID tokens are meant only for the client application that requested them.
The backup holds only ciphertext and wrapped DEKs. The plaintext DEKs were never written to disk. To unwrap a DEK, you have to call the KMS, which holds the master key in its hardware and never releases it. The KMS allows the call only for identities its policy permits, and logs every request.
So the attacker would also need credentials that let them call Decrypt on that key: for example by compromising the application's own cloud identity. That's still possible, which is why KMS permissions are kept narrow and its audit logs are monitored. But a stolen backup by itself is useless.
In ordinary TLS, only the server presents a certificate, which the client checks. The client is anonymous at the transport level and proves who it is later with a password, cookie or token. In mTLS, the server also asks for a client certificate, and both sides check the other's certificate against a trusted CA and prove they hold the matching private key. All of this happens in the TLS handshake, after the TCP connection is set up and before any HTTP data is sent. If either check fails, the connection is closed.
Between services it gives each call a verified caller identity that doesn't depend on IP addresses, and encrypts internal traffic. Both are central to zero trust. A service mesh usually issues and rotates the certificates, so applications don't handle them.
RBAC is enough when permissions follow job function and don't depend on the specific resource or context, for example "support agents can view orders". Once rules depend on resource ownership, department, region, time or device, expressing them as roles leads to role explosion. ABAC states them directly as policies over attributes of the subject, action, resource and environment.
The PEP/PDP split puts those policies in one engine (the PDP, such as OPA) that every enforcement point (gateways, sidecars, services) asks. Rules are written, tested and changed in one place instead of being copied into each service, and decisions are consistent across the system.