System Design · Draft

19 — Security

Chapter 17 listed the basic habits. This chapter goes through the security parts of a design in more depth: who is calling, what they may do, how data is protected on the wire and on disk, where keys and secrets live, and how to know afterwards what happened. The JWT, OAuth and Zero Trust essay covers tokens in more detail than fits here.

A design review for security starts with three questions:

QuestionExamples
What is worth protecting?Credentials, personal data, payment data, the ability to move money or change permissions
Who might attack it, and from where?The internet, a logged-in user acting on someone else’s data, a compromised service inside the network, a leaked laptop or backup
Where are the trust boundaries?Browser to edge, edge to services, service to database, service to third parties

Every boundary needs an answer to “how does the receiver know who sent this, and what are they allowed to do?”.

Authentication

Authentication establishes who is calling. For people, that usually means a password, a second factor, or a login delegated to an identity provider. For services, it means a cryptographic identity, covered later.

Storing passwords

Passwords are never stored, encrypted or plain. A server stores a slow, salted hash and compares hashes at login. The salt, unique per password, stops one precomputed table from cracking every account. The slowness limits how fast an attacker who steals the table can test guesses.

The OWASP Password Storage Cheat Sheet, fetched 2026-11-09, recommends, in order:

AlgorithmMinimum settings from OWASPNotes
Argon2id19 MiB memory, 2 iterations, parallelism 1 (or 46 MiB, 1 iteration)First choice. Memory-hard, which hurts GPU attacks
scryptN = 2^17 (128 MiB), r = 8, p = 1When Argon2id isn’t available
bcryptWork factor 10 or moreLegacy systems. Most implementations read only the first 72 bytes
PBKDF2600,000 iterations, HMAC-SHA-256When FIPS-140 compliance is required

A fast hash such as SHA-256 is unsuitable, however it’s salted. The following program measures why, using hashlib.scrypt from the Python standard library at several of OWASP’s equivalent settings.

import hashlib, os, statistics, time

password, salt = b"correct horse battery staple", os.urandom(16)

def timed(fn, runs):
    times = []
    for _ in range(runs):
        t = time.perf_counter(); fn(); times.append(time.perf_counter() - t)
    return statistics.median(times)

t = timed(lambda: hashlib.sha256(salt + password).digest(), 10_000)
print(f"sha256           {t * 1e6:9.2f} us   {1 / t:12,.0f} guesses/s per core")

for log_n, r, p in [(10, 8, 1), (12, 8, 1), (14, 8, 1), (14, 8, 5), (15, 8, 3), (16, 8, 2), (17, 8, 1)]:
    n = 2 ** log_n
    mem = 128 * n * r                                   # bytes scrypt needs for its main buffer
    fn = lambda: hashlib.scrypt(password, salt=salt, n=n, r=r, p=p,
                                maxmem=mem + 2**20, dklen=32)
    t = timed(fn, 9)
    print(f"N=2^{log_n:<2} r={r} p={p}  {t * 1e3:9.1f} ms   {1 / t:12,.1f} guesses/s per core"
          f"   {mem / 2**20:5.0f} MiB")

try:
    hashlib.scrypt(password, salt=salt, n=2**17, r=8, p=1)    # default maxmem
except ValueError as e:
    print("N=2^17 with default maxmem:", e)

Results on an Apple M3 Pro, Python 3.14 with OpenSSL 3.6, median of 9 runs:

SettingTime per hashGuesses a second per coreMemory
SHA-2560.33 µsabout 3,000,000none
scrypt N=2^10, r=8, p=11.8 ms5441 MiB
scrypt N=2^12, r=8, p=17.2 ms1394 MiB
scrypt N=2^14, r=8, p=131.7 ms31.516 MiB
scrypt N=2^14, r=8, p=5166 ms6.016 MiB
scrypt N=2^15, r=8, p=3207 ms4.832 MiB
scrypt N=2^16, r=8, p=2257 ms3.964 MiB
scrypt N=2^17, r=8, p=1255 ms3.9128 MiB

A second run was within about 20% on every row. At OWASP’s first scrypt setting, one core tests about 4 guesses a second instead of about 3 million with SHA-256, a factor of roughly 750,000. Each guess also needs 128 MiB of memory, which limits how many an attacker’s GPU can run at once.

Three practical points come out of the measurement:

  • Python’s default limit blocks the recommended setting. The last line of output was memory limit exceeded, because OpenSSL caps scrypt at 32 MiB unless maxmem is raised.
  • Login is now expensive for the server too. At 255 ms of CPU per login, an 11-core machine can check roughly 40 passwords a second. A burst of login attempts becomes a denial-of-service risk, which is one reason login endpoints need their own rate limit (chapter 22).
  • Parameters age. Store the algorithm and parameters with each hash, as the standard encoded formats do. When a user logs in successfully with an older setting, rehash the password with the current one.

A pepper, a secret added to every password and stored outside the database, protects hashes if only the database leaks. OWASP lists it as defense in depth, not a replacement for a slow hash.

NIST’s SP 800-63B sets the rules for the passwords themselves: at least 15 characters when a password is the only factor, and at least 8 when it’s part of multi-factor login; a maximum of at least 64 should be allowed; no composition rules such as “one digit and one symbol”; no forced periodic changes; and a check against a list of known common or breached passwords.

Sessions and tokens

After login, the server gives the client something to present on later requests.

Server-side sessionSelf-contained token (for example a JWT)
Client holdsA random session ID in a cookieA signed token with claims
Server checksLooks up the ID in a session storeVerifies the signature and expiry
RevocationDelete the session; effective at onceHard; the token is valid until it expires
Cross-service useEvery service needs the session storeAny service with the key can verify
Size on each requestTens of bytesHundreds of bytes or more
Typical useBrowser applicationsAPIs, service-to-service calls, short-lived access

For browser applications, a server-side session in a cookie marked HttpOnly, Secure and SameSite is simple and easy to revoke. HttpOnly keeps scripts from reading it, Secure keeps it off plain HTTP, and SameSite limits it being sent on cross-site requests, which reduces cross-site request forgery. Tokens fit better where many services verify identity without a shared store. A common combination is a short-lived access token, minutes long, with a longer-lived refresh token that the server can revoke.

Multi-factor authentication

A second factor stops most attacks that rely on a stolen or guessed password. The factors are not equally strong.

FactorPhishing-resistant?Notes
SMS or voice codeNoNIST SP 800-63B treats telephone delivery as restricted, because of SIM swaps and number porting
Time-based code (TOTP app)NoA phishing page can relay the code in real time
Push approvalNoUsers can be worn down by repeated prompts
WebAuthn security key or passkeyYesThe browser binds the signature to the site’s origin, so a lookalike site gets nothing usable

NIST requires services at its AAL2 assurance level to offer at least one phishing-resistant option. Recovery flows deserve the same scrutiny as login, because an attacker who can reset the second factor by email has bypassed it. The essay on passwords, second factors and passkeys builds one-time codes from their RFCs, relays a live code through a local phishing page, and runs a real WebAuthn ceremony to show why only the last row of this table resists phishing.

OAuth 2.0 and OpenID Connect

OAuth 2.0 lets a user grant an application limited access to their data at another service, without giving the application their password. OpenID Connect (OIDC) is a layer on top that also tells the application who the user is, through an ID token.

The roles:

  • Resource owner: the user.
  • Client: the application that wants access.
  • Authorization server: issues tokens after the user consents. Often the identity provider.
  • Resource server: the API that accepts access tokens.

Authorization code with PKCE

The recommended flow for nearly every client, from single-page apps and mobile apps to server-side web apps:

Client                       Browser                    Authorization server       API
  | make verifier V, challenge C = SHA256(V)               |                        |
  |-- redirect: client_id, redirect_uri, scope, state, C -->|                        |
  |                           user logs in and consents     |                        |
  |<-- redirect back with one-time code + state ------------|                        |
  |-- POST /token: code, V (plus client auth if any) ------>|                        |
  |                           checks SHA256(V) == C         |                        |
  |<-- access token (+ refresh token, + ID token if OIDC) --|                        |
  |-- request with access token ---------------------------------------------------->|

PKCE, proof key for code exchange (RFC 7636), protects the code. The client makes a random code verifier and sends only its SHA-256 hash, the code challenge, in the browser redirect. When it redeems the code, it sends the verifier itself. An attacker who intercepts the code in the redirect doesn’t have the verifier, so the code is useless to them.

import base64, hashlib, secrets

def pkce_pair():
    verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
    challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
    return verifier, challenge

# Test vector from RFC 7636, Appendix B:
v = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
print(base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).rstrip(b"=").decode())
# E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM   (matches the RFC)

The state parameter ties the response to the request the client started, and the client must check it.

What the current best practice rules out

RFC 9700, the OAuth 2.0 Security Best Current Practice published in January 2025, updates the original OAuth specification:

RuleReason given
Public clients must use PKCE; confidential clients shouldPrevents authorization code injection and misuse
PKCE should use the S256 methodThe plain method exposes the verifier in the request
Clients should not use the implicit grantAccess tokens in the redirect URL can leak and be replayed, and can’t be bound to the client
The resource owner password grant must not be usedThe client sees the user’s password; it doesn’t work with multi-factor login
Access tokens should be sender-constrainedA stolen token is useless without the sender’s key (mutual TLS or DPoP)
Refresh tokens for public clients must be sender-constrained or rotatedA stolen refresh token would otherwise grant access indefinitely
Access tokens should carry the minimum privileges neededLimits the damage of a leaked token

The implicit grant returned the access token directly in the browser redirect. It existed because browsers once couldn’t make cross-origin calls to a token endpoint. Tokens in URLs end up in browser history, logs and Referer headers, and the authorization server can’t detect replay. Authorization code with PKCE replaced it.

ID tokens and access tokens

The two are often confused.

  • An ID token is for the client. It says who logged in. The client validates its issuer, audience (its own client ID), expiry and nonce.
  • An access token is for the API. The API validates it, and checks the audience is itself.

Sending an ID token to an API as if it were an access token is a common mistake. An API that accepts any valid token from the identity provider, without checking the audience, accepts tokens issued for other applications.

For calls between services with no user involved, the client credentials grant gives a service its own access token.

JWT pitfalls

A JSON Web Token is three base64url parts joined by dots: a header, a set of claims, and a signature over the first two. RFC 8725, the JWT Best Current Practices, lists the ways it has gone wrong.

PitfallWhat happensFix
alg: noneSome libraries accepted a token with no signature because the header said none was neededVerify against a fixed allowlist of algorithms; never let the token choose
RS256 to HS256 confusionAn attacker switches the header to HMAC, and a library uses the RSA public key, which is public, as the HMAC secretEach key is used with exactly one algorithm, checked at verification
Weak HMAC keysA human-chosen secret can be brute-forced offline from one tokenRandom keys of at least the hash size, such as 32 bytes for HS256
Missing audience checkA token issued for one service is accepted by anotherValidate aud, and iss
Cross-JWT confusionA token of one kind, such as an ID token, is used as anotherExplicit typing with the typ header, and distinct validation rules per kind
Trusting claims before verifyingCode reads sub or kid and acts on it firstParse the header only to find the key; read claims only after the signature checks out

Two problems are inherent rather than bugs.

Revocation. A signed token is valid until it expires, even after logout or a password change. The options are short expiry, often 5 to 15 minutes; a denylist of revoked token IDs (jti), checked on each request, which reintroduces a lookup; or token introspection, where the API asks the authorization server, which gives up the self-contained property.

Size. Every claim adds bytes to every request. An RSA-2048 signature alone is 256 bytes, or 342 characters in base64url. Tokens that carry long permission lists can grow past what headers and cookies comfortably hold. Keeping authorization data out of the token, and looking it up instead, keeps tokens small.

A signed token with the standard library

The program below issues and verifies HMAC-SHA256 tokens with expiry, audience, explicit type, revocation and key rotation. It isn’t JWT-compatible on purpose: it has no alg header, because there is only one algorithm to accept.

import base64, hashlib, hmac, json, secrets, time

KEYS = {"k2": secrets.token_bytes(32), "k1": secrets.token_bytes(32)}  # kid -> key
CURRENT_KID = "k2"                                                     # sign with the newest
AUDIENCE = "orders-api"

def b64(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

def unb64(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def issue(sub: str, ttl: int = 300, now: float | None = None) -> str:
    now = int(now if now is not None else time.time())
    header = {"kid": CURRENT_KID, "typ": "session+v1"}   # no "alg": only one is accepted
    claims = {"sub": sub, "aud": AUDIENCE, "iat": now, "exp": now + ttl,
              "jti": secrets.token_hex(8)}
    signing_input = b64(json.dumps(header).encode()) + "." + b64(json.dumps(claims).encode())
    sig = hmac.new(KEYS[CURRENT_KID], signing_input.encode(), hashlib.sha256).digest()
    return signing_input + "." + b64(sig)

REVOKED = set()   # jti values; in production a shared store with a TTL equal to max token life

def verify(token: str, now: float | None = None, leeway: int = 30) -> dict:
    now = now if now is not None else time.time()
    try:
        h64, c64, s64 = token.split(".")
        header = json.loads(unb64(h64))
    except ValueError:
        raise PermissionError("malformed")
    if header.get("typ") != "session+v1":
        raise PermissionError("wrong token type")
    key = KEYS.get(header.get("kid"))
    if key is None:
        raise PermissionError("unknown key")
    expected = hmac.new(key, f"{h64}.{c64}".encode(), hashlib.sha256).digest()
    if not hmac.compare_digest(expected, unb64(s64)):        # constant-time comparison
        raise PermissionError("bad signature")
    claims = json.loads(unb64(c64))                          # only parsed after the check
    if claims["aud"] != AUDIENCE:
        raise PermissionError("wrong audience")
    if now > claims["exp"] + leeway:
        raise PermissionError("expired")
    if claims["jti"] in REVOKED:
        raise PermissionError("revoked")
    return claims

A test driver, in the same file, tampers with a token, strips its signature and claims alg: none, lets it expire, revokes it, and retires the old key. It also times verification:

if __name__ == "__main__":
    t0 = 1_800_000_000
    tok = issue("user-42", ttl=300, now=t0)
    print("token:", tok[:60] + "...", len(tok), "bytes")
    print("valid:", verify(tok, now=t0 + 10)["sub"])

    h, c, s = tok.split(".")
    claims = json.loads(unb64(c)); claims["sub"] = "admin"
    forged = h + "." + b64(json.dumps(claims).encode()) + "." + s
    none_hdr = b64(json.dumps({"kid": "k2", "typ": "session+v1", "alg": "none"}).encode())
    cases = {
        "tampered claims": (forged, t0 + 10),
        "alg none, no signature": (none_hdr + "." + c + ".", t0 + 10),
        "expired": (tok, t0 + 400),
    }
    for name, (t, when) in cases.items():
        try:
            verify(t, now=when); print(name, "-> ACCEPTED")
        except PermissionError as e:
            print(f"{name} -> rejected ({e})")

    REVOKED.add(json.loads(unb64(c))["jti"])
    try:
        verify(tok, now=t0 + 10)
    except PermissionError as e:
        print("after revocation -> rejected", f"({e})")

    # Key rotation: a token signed with the old key still verifies until k1 is removed.
    CURRENT_KID = "k1"; old = issue("user-7", now=t0); CURRENT_KID = "k2"
    print("old-key token:", verify(old, now=t0 + 1)["sub"])
    del KEYS["k1"]
    try:
        verify(old, now=t0 + 1)
    except PermissionError as e:
        print("after k1 retired -> rejected", f"({e})")

    n = 100_000
    tk = issue("u", now=t0)
    t = time.perf_counter()
    for _ in range(n):
        verify(tk, now=t0)
    print(f"verify: {(time.perf_counter() - t) / n * 1e6:.1f} us per token")

Output:

token: eyJraWQiOiAiazIiLCAidHlwIjogInNlc3Npb24rdjEifQ.eyJzdWIiOiAid... 230 bytes
valid: user-42
tampered claims -> rejected (bad signature)
alg none, no signature -> rejected (bad signature)
expired -> rejected (expired)
after revocation -> rejected (revoked)
old-key token: user-7
after k1 retired -> rejected (unknown key)
verify: 5.4 us per token

The design choices that matter:

  • The verifier decides the algorithm. The header names a key, not an algorithm, so there’s nothing to downgrade.
  • hmac.compare_digest compares in constant time. A plain == can return early at the first differing byte, which can leak how much of a forged signature was right.
  • Claims are read only after the signature checks out.
  • Key IDs make rotation possible. New tokens use the new key while old tokens still verify, until the old key is removed after the longest token lifetime has passed.
  • Verification is cheap, about 5 µs on one core here, which is why self-contained tokens scale well. The revocation check is the part that needs shared state.

Authorization

Authorization decides whether an authenticated caller may perform an action on a resource. Missing or wrong object-level checks, where a user changes /invoices/1001 to /invoices/1002 and gets someone else’s invoice, are among the most common serious API flaws.

Models

ModelDecision based onExample ruleGood forWeak point
ACLA list on each resource“Alice can read file 7”Small numbers of shared objectsHard to audit or change in bulk
RBACRoles assigned to users“Editors can publish articles”Most business applicationsRole explosion when rules depend on context
ABACAttributes of user, resource and context“Managers can approve expenses under $5,000 in their own department during business hours”Rules that depend on dataPolicies are hard to test and explain
ReBACRelationships in a graph“Viewers of a folder can view documents in it”Sharing, hierarchies, groups, multi-tenant appsNeeds a graph store and careful consistency

ReBAC stores facts as relationships, such as “Alice is a member of group eng”, “group eng is a viewer of folder plans”, “document roadmap is in folder plans”. A check asks whether there’s a path from the user to the resource through permitted relationships. Google’s Zanzibar paper describes a system of this kind behind Calendar, Cloud, Drive, Maps, Photos and YouTube. Its abstract reports trillions of access control lists, millions of authorization requests a second, 95th-percentile latency under 10 ms, and availability above 99.999% over three years. It also stresses consistency: a check must respect the order of changes, so that removing a user and then adding a sensitive document never lets the removed user see it. Several open-source systems follow the same model.

Most systems combine models: RBAC for coarse permissions such as “admin”, and relationships or attributes for object-level checks.

Where checks run

  • At the edge: the gateway checks that the caller is authenticated and has the broad scope for the endpoint.
  • In the service: the service checks that this caller may act on this object. Only the service knows which invoice belongs to which customer, so the object-level check can’t live only at the gateway.
  • In the database, as a backstop. PostgreSQL’s row-level security filters rows by policy, which protects a multi-tenant table even if a query forgets its WHERE tenant_id = ....
CREATE TABLE invoices (
    id        bigint PRIMARY KEY,
    tenant_id uuid   NOT NULL,
    amount    numeric(12,2) NOT NULL
);
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- The application connects as a non-owner role and sets the tenant per transaction:
BEGIN;
SET LOCAL app.tenant_id = '6f1c9e0a-1b2c-4d5e-8f90-123456789abc';
SELECT * FROM invoices;          -- returns only this tenant's rows
COMMIT;

The PostgreSQL documentation notes the details that decide whether this works. With row security enabled and no policy, the default is to deny every row. A policy’s USING clause also acts as its WITH CHECK clause unless one is given, so rows for another tenant can’t be inserted either. Superusers and roles with BYPASSRLS always bypass it, and table owners do too unless the table uses FORCE ROW LEVEL SECURITY. So the application must not connect as the table’s owner. SET LOCAL limits the setting to the transaction, which matters with connection pools that reuse connections across tenants.

Service-to-service identity

Inside a system, services call each other. Trusting a caller because of its IP address breaks as soon as anything inside the network is compromised, and addresses change constantly in containers.

Mutual TLS gives each side a certificate, and each verifies the other’s. The certificate identifies the workload, and the receiving service authorizes by that identity: “only orders may call payments/charge”.

Workload identity is the machinery that gets a certificate to each workload without anyone copying keys around. SPIFFE is an open standard for it. A workload receives a short-lived identity document, an SVID, as an X.509 certificate or a JWT, from a local Workload API, after the platform attests what the workload is, such as which Kubernetes service account it runs as. Certificates rotate automatically, so there’s no long-lived secret to leak. Cloud providers offer similar mechanisms that let a workload exchange its platform identity for short-lived cloud credentials.

A service mesh (chapter 17) often does the mutual TLS part, so application code sees only the verified peer identity.

Encryption in transit

All traffic should be encrypted, including inside a data center. TLS 1.3, defined in RFC 8446, made several changes from 1.2 that matter for design:

  • Static RSA and static Diffie-Hellman key exchange were removed, so every connection has forward secrecy: a later theft of the server’s private key doesn’t decrypt recorded traffic.
  • A full handshake takes one round trip.
  • All handshake messages after the ServerHello are encrypted.
  • An optional 0-RTT mode lets a returning client send data in its first flight. The RFC warns that 0-RTT data isn’t forward secret and has no protection against replay between connections. It’s only safe for requests that are harmless to repeat, such as idempotent reads.

The TLS guide covers the handshake in detail. The design questions are where TLS terminates, and whether traffic behind that point is encrypted again. Terminating at a load balancer and sending plain HTTP to services leaves everything on the internal network readable by anyone who gets onto it.

Encryption at rest and key management

Encryption at rest comes in layers, and each protects against something different.

LayerProtects againstDoesn’t protect against
Disk or volume encryptionStolen disks, discarded hardwareAnyone who can query the running database
Database or storage-service encryptionStolen files and backupsA compromised application with database access
Application-level field encryptionDatabase administrators, leaked dumps, over-broad queriesA compromised application that holds the keys

Envelope encryption

Encrypting large amounts of data directly with a key held in a key management service (KMS) would mean sending all the data to the KMS. Envelope encryption avoids that:

KMS holds the key-encryption key (KEK); it never leaves the KMS.

To write:  1. ask KMS for a new data key (DEK): get it in plain form and wrapped by the KEK
           2. encrypt the data locally with the plain DEK
           3. store the ciphertext next to the wrapped DEK; discard the plain DEK

To read:   1. send the wrapped DEK to KMS, get the plain DEK back (if permitted)
           2. decrypt locally

[ wrapped DEK | ciphertext ]  stored together

The properties that follow:

  • The KMS sees only small keys, so it isn’t a throughput bottleneck, and each unwrap can be authorized and logged.
  • Rotating the KEK is cheap. Only the wrapped DEKs are re-encrypted, not the data. Many KMS products keep old KEK versions available for decryption, so rotation doesn’t even require re-wrapping at once.
  • Rotating a DEK means re-encrypting the data it protects, so DEKs are scoped small: per object, per tenant, or per time period.
  • Crypto-shredding works by deleting a DEK. With one DEK per user, deleting that key makes the user’s data unreadable everywhere, including backups. Chapter 18 uses this for event-sourced data.
  • Caching plain DEKs in memory for a short time cuts KMS calls, at the cost of keys staying in memory longer.

Use authenticated encryption, such as AES-GCM, from a well-reviewed library, never a hand-written mode. AES-GCM has a hard rule: a nonce must never repeat under the same key, which is one more reason to keep each DEK’s use bounded.

Secrets management

Secrets include database passwords, API keys, signing keys and TLS private keys. The failure modes are predictable: secrets committed to a repository, baked into container images, printed in logs, or shared by every environment.

Practices that prevent them:

  • Keep secrets in a secrets manager, not in code, images or committed configuration files. Applications fetch them at startup or through a sidecar, with access controlled per service.
  • Prefer short-lived, generated credentials. Some secrets managers create a database user per service on request, with an expiry. A leaked credential then stops working on its own.
  • Prefer identity over secrets. With workload identity, a service proves who it is and receives temporary credentials, so there’s no static secret at all.
  • Rotate, and make rotation routine. A secret that has never been rotated usually can’t be rotated quickly during an incident. The key-ID pattern from the token example allows overlap: add the new secret, move users over, remove the old.
  • Scan for leaks in commits and in logs, and treat any leaked secret as compromised, even if the repository was private.
  • Separate environments. Development should never hold production secrets.

Network segmentation and zero trust

Network segmentation limits what a compromised machine can reach. Databases accept connections only from the services that use them; administrative interfaces aren’t reachable from the internet; production is separate from development. Kubernetes network policies and cloud security groups express these rules.

Segmentation alone assumes that whatever is inside a segment can be trusted. Zero trust drops that assumption. NIST’s SP 800-207 defines it as granting no implicit trust to users or assets based on their network location or ownership, and authenticating and authorizing both subject and device before each session to a resource.

In practice that means the pieces earlier in this chapter applied everywhere: every request carries a verifiable identity, user or workload; every service authorizes every request; all traffic is encrypted; and access decisions can use context such as device health. Segmentation remains useful as a second layer, limiting how far an attacker who gets past one control can move.

Injection happens when input is mixed into code: SQL, shell commands, templates. The fix is to keep data and code separate. For PostgreSQL, that means parameterized queries, where the driver sends values separately from the statement text:

# Wrong: the input becomes part of the SQL text.
cur.execute(f"SELECT * FROM users WHERE email = '{email}'")

# Right: the value is sent as a parameter (psycopg placeholder syntax).
cur.execute("SELECT * FROM users WHERE email = %s", (email,))

Identifiers such as table or column names can’t be parameters, so they have to come from an allowlist, never from input.

Server-side request forgery (SSRF) happens when a server fetches a URL that a user supplied, such as an image to import or a webhook target. An attacker supplies an internal address instead, and the server fetches it with its own network access. The OWASP SSRF Prevention Cheat Sheet notes that cloud metadata services, such as AWS’s at 169.254.169.254, are a common target, because they can hand out credentials. Defenses:

  • Allowlist destinations where possible.
  • Otherwise resolve the hostname, reject private, loopback and link-local addresses, and connect to the address that was checked, so a second DNS lookup can’t change it.
  • Don’t follow redirects automatically, because a redirect can point somewhere the check didn’t cover.
  • Run URL fetching from an isolated network segment with no route to internal services.
  • On AWS, require IMDSv2 for the metadata service, which OWASP lists as a defense-in-depth measure.

Rate limiting as a defense

Rate limits protect against abuse as well as overload. Chapter 22 builds one. Security uses include:

  • Login and password reset: limit attempts per account and per source, to slow guessing and credential stuffing, where attackers try passwords leaked from other sites. The scrypt measurement above shows that unlimited login attempts are also a cheap way to exhaust CPU.
  • Expensive endpoints: search, exports, and anything that sends email or SMS, which costs money per request.
  • Enumeration: endpoints that reveal whether an account exists, such as signup or reset, should respond the same way either way and be rate-limited.

For sensitive endpoints, the limiter should fail closed if its store is down.

Audit logging

An audit log records who did what to which resource, when, from where, and whether it was allowed. It answers questions after the fact: who granted this role, who exported this data. It’s separate from application logs, which are for debugging and are sampled and rotated.

What to record:

  • Authentication events: logins, failures, MFA changes, password resets.
  • Authorization changes: roles granted or revoked, sharing changes.
  • Access to sensitive data, such as exports and administrator views of user records.
  • Changes to security configuration and secrets.
  • Denied requests, which often show probing.

What not to record: passwords, tokens, secrets, or full personal data when an ID will do.

An audit log is only useful if it can’t be quietly edited. It belongs in a separate store with append-only access for the services that write to it. A hash chain makes tampering detectable: each entry includes the hash of the previous one.

import hashlib, json

def append(log, entry):
    prev = log[-1]["hash"] if log else "0" * 64
    body = json.dumps(entry, sort_keys=True)
    log.append({"entry": entry, "prev": prev,
                "hash": hashlib.sha256((prev + body).encode()).hexdigest()})

def verify(log):
    prev = "0" * 64
    for i, rec in enumerate(log):
        body = json.dumps(rec["entry"], sort_keys=True)
        if rec["prev"] != prev or rec["hash"] != hashlib.sha256((prev + body).encode()).hexdigest():
            return f"broken at entry {i}"
        prev = rec["hash"]
    return "ok"

log = []
append(log, {"ts": "2026-11-09T10:00:00Z", "actor": "alice", "action": "role.grant",
             "target": "bob", "role": "admin", "result": "allowed"})
append(log, {"ts": "2026-11-09T10:05:00Z", "actor": "bob", "action": "export.customers",
             "result": "allowed", "rows": 120000})
append(log, {"ts": "2026-11-09T10:09:00Z", "actor": "bob", "action": "role.revoke",
             "target": "bob", "role": "admin", "result": "allowed"})
print("intact:", verify(log))
log[1]["entry"]["rows"] = 12                      # someone edits history
print("edited:", verify(log))
intact: ok
edited: broken at entry 1

An attacker who can rewrite the whole chain can recompute every hash, so the latest hash is periodically copied somewhere the writer can’t change, such as a separate account or a write-once storage bucket.

The takeaway

Hash passwords with Argon2id or scrypt at OWASP’s settings, and rate-limit login, because slow hashes cost the server too. Use authorization code with PKCE, never the implicit or password grants, and validate the audience of every token. Pin JWT algorithms, keep tokens short-lived, and plan revocation. Check authorization on every object in the service, with row-level security as a backstop. Give workloads cryptographic identities instead of trusting the network, encrypt everything in transit, use envelope encryption for data at rest, and keep secrets short-lived and out of code. Record security-relevant actions in an audit log that can’t be quietly edited.