Skip to content

    System Design

    System Design: Security for Distributed Systems

    Learn to threat-model a system, separate authentication from authorisation, use OAuth and JWTs safely, and layer defences so one mistake is not a breach.

    By Published 17 min read

    Share

    This lesson is part of System Design Building Blocks: Data, Messaging and Scale. Enroll for free, then sign in, to read it in full.

    What you'll learn: By the end of this lesson you will be able to run a lightweight threat model on a design, explain the difference between authentication and authorisation, choose where tokens, TLS and secrets belong, and layer defences so that no single failure exposes your data.

    Think of an office building. The front door has a badge reader, but that is only the start. Visitors sign in at reception. Each floor has its own lock. The server room needs a second key. Cameras record who went where. Nobody relies on one lock, because everybody knows locks fail and badges get lent to friends.

    Software security works the same way. Every service you add is another door, and every network hop is a hallway someone could stand in. Security is not a feature you bolt on at the end. It is a set of decisions about who may do what, how you prove who they are, and what happens when something goes wrong anyway.

    The core idea

    Assume that any single control will eventually fail, and design so that failure is contained.

    That sentence drives everything below. Three habits follow from it:

    1. Know what you are protecting and from whom. A public blog and a payments service face different attackers and have different stakes.
    2. Give every actor the minimum access needed (least privilege), so a stolen credential opens a small door, not every door.
    3. Layer controls (defence in depth), so an attacker who beats the first layer meets a second and a third, and your logs record the attempt.

    Security is also a trade-off. Every control costs latency, engineering time, or user friction. The art is spending that cost where the risk is highest.

    Threat modelling basics

    A threat model is a structured answer to four questions: What are we building? What can go wrong? What will we do about it? Did we do a good enough job?

    You do not need a special tool. Start with the architecture diagram you already have and mark trust boundaries: lines where data crosses from a less trusted zone to a more trusted one (internet to load balancer, service to database, your network to a third-party API).

    Trust boundaries around an order serviceUser browser to API gateway (HTTPS); API gateway to Order service; Order service to Orders database; Order service to Payment provider (HTTPS)UNTRUSTED ZONEPRIVATE NETWORKUser browserUser browserAPI gatewayAPI gatewayOrder serviceOrder serviceOrders databaseOrdersdatabasePayment provider: third partyPaymentproviderthird partyHTTPSHTTPS

    Every arrow that crosses a boundary is a place to ask what an attacker could do there. A common checklist for that question is STRIDE:

    Threat Plain meaning Typical defence
    Spoofing Pretending to be someone else Strong authentication, mTLS between services
    Tampering Changing data in transit or at rest TLS, signatures, checksums, write permissions
    Repudiation Denying an action happened Audit logs, signed events
    Information disclosure Reading data you should not Encryption, access control, minimal responses
    Denial of service Making the system unavailable Rate limiting, quotas, autoscaling, caching
    Elevation of privilege Gaining more access than granted Least privilege, authorisation checks on every request

    Rank what you find by likelihood times impact, then fix the top items first. Ten ranked risks you act on beat a perfect document nobody reads. Repeat the exercise whenever a design adds a trust boundary: a new third-party integration, public endpoint or store of personal data.

    Authentication vs authorisation

    These two words get confused constantly, and mixing them up produces real vulnerabilities.

    • Authentication (AuthN) answers "who are you?" It verifies an identity: a password, a passkey, a one-time code, a certificate.
    • Authorisation (AuthZ) answers "what are you allowed to do?" It decides whether this identity may perform this action on this resource.

    At the office building: the badge reader at the door authenticates you. The fact that your badge opens the fourth floor but not the server room is authorisation.

    Two practical rules:

    1. Authenticate once at the edge, authorise at every service that owns data. The gateway can verify who the caller is. But the service that owns an order must check that this caller may read this order. If it trusts "the gateway already checked", one misrouted request exposes everything.
    2. Check ownership, not just login. The classic bug is an endpoint like GET /orders/1234 that confirms you are logged in but never confirms order 1234 is yours. Attackers simply change the number. This family of flaw is often called broken object-level authorisation.

    Common authorisation models:

    Model How it decides Good for Cost
    Role-based (RBAC) User has roles; roles have permissions Internal tools, simple apps Roles multiply and become coarse
    Attribute-based (ABAC) Rules over user, resource and context attributes Fine-grained or contextual rules Harder to reason about and test
    Relationship-based Access derives from relationships (owner, member, shared-with) Documents, social, sharing features Needs a dedicated store or service
    Ownership checks Resource carries an owner id compared to caller Most per-user data Easy to forget on a new endpoint

    OAuth 2.0 and OpenID Connect at a high level

    Most systems should not store passwords themselves. Instead they delegate login to an identity provider and receive tokens. Two standards cover this, and they solve different problems.

    OAuth 2.0 is about delegated authorisation. It lets an application obtain limited access to a resource on a user's behalf, without ever seeing the user's password. The result is an access token with specific scopes (for example, photos:read).

    OpenID Connect (OIDC) is a thin layer on top of OAuth 2.0 for authentication. It adds an ID token that tells the application who the user is.

    A simplified authorisation-code flow, the standard choice for web and mobile apps:

    sequenceDiagram participant U as User participant App as Client app participant IdP as Identity provider participant API as Resource API U->>App: Click sign in App->>IdP: Redirect with client id, scopes, state IdP->>U: Show login and consent U->>IdP: Authenticate and approve IdP->>App: Redirect back with one-time code App->>IdP: Exchange code for tokens IdP->>App: Access token and ID token App->>API: Request with access token API->>App: Protected data

    Key points to remember:

    • The one-time code travels through the browser, but the tokens are fetched directly between the app (its backend, where it has one) and the identity provider. That keeps tokens out of URLs and browser history.
    • Public clients such as single-page and mobile apps must use the PKCE extension (current guidance recommends it for every client), which protects the code from being stolen and replayed.
    • The state parameter guards against cross-site request forgery during the redirect dance.
    • The client credentials flow is for service-to-service calls with no user involved. A service authenticates as itself and receives a token for its own scopes.
    NOTE

    Some older OAuth 2.0 flows (such as the implicit flow) are now discouraged. Follow current security guidance and your identity provider's documentation, not an old tutorial.

    Tokens and JWT pitfalls

    A JSON Web Token (JWT) is a compact, signed bundle of claims such as user id, issuer, audience and expiry. Services can verify the signature locally, with no database lookup, which makes JWTs popular for access tokens in distributed systems.

    That convenience has sharp edges:

    Pitfall Why it hurts Fix
    Not validating the signature, or accepting the none algorithm Anyone can forge a token Pin the allowed algorithms; use a vetted library
    Skipping exp, iss or aud checks Old tokens or tokens meant for other services are accepted Validate expiry, issuer and audience every time
    Long-lived access tokens A stolen token works for days Short lifetimes (minutes), refresh tokens for renewal
    Putting sensitive data in the payload The payload is encoded, not encrypted; anyone holding the token can read it Keep claims minimal; never include secrets
    No way to revoke A signed token stays valid until it expires Short expiry, a deny-list for emergencies, or opaque tokens with introspection
    Storing tokens where scripts can read them Cross-site scripting steals them Prefer HttpOnly, Secure, SameSite cookies for browser sessions

    There is a real choice between self-contained tokens (JWTs, fast to verify, hard to revoke) and opaque tokens (random strings looked up on the server, instantly revocable, but each check costs a call or cache hit). The flowchart below is how a design review usually settles it.

    flowchart TD Q1{"Must a token be revocable within seconds?"} -->|yes| OP["Opaque token
    with introspection or cache"] Q1 -->|no| Q2{"Many services verify it independently?"} Q2 -->|yes| JW["Short-lived JWT
    5 to 15 minutes"] Q2 -->|no| SE["Server-side session
    in an HttpOnly cookie"] JW --> RF["Refresh token kept server-side
    and revocable"]

    Read it top down: a hard revocation requirement pushes you to opaque tokens, many independent verifiers favour short-lived JWTs, and a single web app with one backend is simplest with a plain session. Many systems combine them: short-lived JWTs at the edge, revocable refresh tokens behind it.

    WARNING

    Never roll your own token format or signature scheme. Use well-maintained libraries, keep them updated, and rotate signing keys on a schedule so a leaked key has a bounded blast radius.

    Encryption: in transit, at rest, and between services

    In transit. TLS encrypts traffic between a client and a server and lets the client verify the server's identity through a certificate. Use it for every external connection, and increasingly for internal ones too, because "the private network is safe" is an assumption attackers love.

    Mutual TLS (mTLS) goes further: both sides present certificates, so the server also verifies the client. Inside a cluster this gives each service a cryptographic identity and encrypts service-to-service traffic. Service meshes often automate certificate issuance and rotation, which is the hard part. See API Gateways and Service Meshes for how that works in practice.

    At rest. Encryption of disks, volumes and buckets protects against stolen hardware and leaked backups. It does not stop an application bug that reads data it should not, because the application sees plaintext. Treat it as one layer, not a cure.

    For especially sensitive fields (payment details, national identifiers) consider field-level or envelope encryption: data is encrypted with a data key, and the data key is itself encrypted by a master key held in a key management service (KMS). Access to the master key can then be tightly controlled and logged.

    Control Protects against Does not protect against Cost
    TLS Eavesdropping, tampering on the wire Compromised endpoints Small CPU cost; certificate management
    mTLS Spoofed internal callers, internal sniffing Compromised legitimate service Certificate issuance and rotation
    Disk or bucket encryption Stolen media, leaked snapshots Application-level bugs Usually minimal
    Field-level encryption Database reads by over-privileged staff or bugs Compromise of the running service itself Key management; harder search and indexing

    Secrets management

    A secret is any credential: database passwords, API keys, signing keys, certificates. Secrets leak most often through mundane routes: committed to a repository, baked into a container image, printed in logs, pasted in chat.

    Principles that prevent most incidents:

    • Keep secrets out of source control and images. Inject them at runtime from a dedicated secret manager or the platform's secret store.
    • Prefer short-lived, dynamically issued credentials (for example, per-service database users that expire) over a shared password that never changes.
    • Rotate on a schedule and on suspicion. If rotation is painful you will not do it, so automate and test it.
    • One secret per purpose and per environment. A leaked staging key must not open production.
    • Never log secrets, and scan for leaks. Scrub authorisation headers from logs, and let secret scanning in CI catch the accidental commit.

    Least privilege, validation, and abuse

    Least privilege applies at every layer: a service's database user can only touch its own tables; a worker's cloud role can read one queue and write one bucket; an engineer's production access is temporary and audited. When a credential leaks, the damage equals that credential's permissions, so keep them small.

    Input validation treats everything from outside a trust boundary as hostile until checked:

    • Validate type, length, range and format on the server. Client-side checks are only a courtesy.
    • Use parameterised queries instead of building SQL strings (dynamic identifiers such as column names still need an allow-list).
    • Encode output for its destination (HTML, URL, shell) to prevent injection and cross-site scripting.
    • Limit request sizes and upload types, and scan or sandbox uploaded files.
    • Be careful with server-side requests built from user-supplied URLs; attackers use them to reach internal services (server-side request forgery).

    Abuse and rate limiting. Credential stuffing, scraping and brute force simply send many requests. Rate limits per user, IP and API key, plus progressive delays on login endpoints, blunt them. See Rate Limiting.

    Audit logs and defence in depth

    An audit log is an append-only record of security-relevant events: logins, permission changes, data exports, administrative actions. Good entries record who did what to which resource, when, and from where. Protect the log itself: ship it off the machine that generated it, restrict who can alter it, and set retention to match your legal and operational needs. They answer "what did the attacker touch?" after an incident.

    Now combine everything into layers:

    Defence in depth: layers from edge to data, with audit logsEdge to Gateway; Gateway to Service; Service to Data; Data to Audit logs, alertingEdge: WAF, DDoS and rate limitsEdgeWAF, DDoS andrate limitsGateway: authentication, coarse authZGatewayauthentication,coarse authZService: per-object authZ, validationServiceper-object authZ,validationData: least privilege and encryptionDataleast privilegeand encryptionAudit logs, alerting: across all layersAudit logs,alertingacross all layers

    An attacker past the firewall still needs a valid token, which only grants its scopes, which still hit per-object checks, behind which a database account can touch one schema. Every step leaves a trace.

    IMPORTANT

    Controls only help if you can see them working. Alert on spikes in authentication failures, unusual export volume, and use of break-glass accounts, then practise the response.

    In practice: hardening a B2B procurement SaaS before its first enterprise audit

    NOTE

    A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.

    The situation

    A procurement and expense platform serves about 400 business customers, each with 20 to 2,000 users who upload invoices and approve spend. Fourteen engineers run it on Kubernetes with an Envoy-based ingress, nine Java and Node services, PostgreSQL, Redis and S3-compatible storage. Login is delegated to an OIDC identity provider. A large prospect has sent a security questionnaire, and an audit is due in six months. Findings from a recent penetration test and an internal review (all illustrative):

    • GET /invoices/{id} checks that the caller is logged in, but not that the invoice belongs to the caller's tenant.
    • Access tokens live 24 hours. There is no revocation path.
    • All nine services share one PostgreSQL password from a Kubernetes secret that has not changed in two years. Rotating it takes a coordinated half-day.
    • Bulk exports leave no record of who ran them.

    Starting architecture

    Starting architecture: Envoy ingress, three services, one shared database loginEnvoy ingress to Invoice service (24h JWT); Envoy ingress to Approval service; Envoy ingress to Export service; Invoice service to PostgreSQL; Approval service to PostgreSQL; Export service to PostgreSQL; Export service to Object storage; Envoy ingress to OIDC provider (login)EDGEAPPLICATION TIERDATA TIEREnvoy ingress: TLSEnvoy ingressTLSOIDC provider: identityOIDC provideridentityInvoice serviceInvoiceserviceApproval serviceApprovalserviceExport serviceExport servicePostgreSQL: one shared loginPostgreSQLone shared loginObject storageObject storage24h JWTlogin

    Trust ends at the ingress, and everything behind it assumes the caller is legitimate.

    Options considered

    Option What it fixes What it costs Risk
    A. Do nothing structural: patch the failing endpoints, add a WAF rule The specific pen-test findings Days of work The same bug class returns with the next endpoint; the audit questions about rotation and logging stay unanswered
    B. Targeted hardening: shared authorisation check, short tokens, per-service database accounts with managed secrets, audit log Cross-tenant access, token theft window, rotation pain, missing evidence About two engineers for a quarter Rollout mistakes can lock out real users
    C. Zero-trust rebuild: mesh with mTLS, a policy engine, per-tenant encryption keys All of B plus lateral movement and key isolation Platform team capacity for two quarters or more High operational load for a 14-person team; audit date at risk

    The decision

    Field Entry
    Context Cross-tenant object access is the highest-impact flaw, and the team cannot staff option C before the audit.
    Decision Adopt B. Every service calls one shared authorisation library that compares the token's tenant with the resource's tenant. Access tokens drop to 10 minutes with server-held refresh tokens. Each service gets its own PostgreSQL role with credentials issued by a secrets manager. Exports and permission changes go to an append-only audit stream. Defer mTLS and per-tenant keys.
    Consequences Good: the bug class is closed centrally, a leaked token is useful for minutes, and rotation becomes a routine job. Bad: more refresh traffic to the identity provider, and a new shared library every service must upgrade.
    Revisit when Services exceed about 20, or a customer contract requires per-tenant key isolation, or an incident shows lateral movement inside the cluster.

    Rollout and verification

    flowchart LR W1["Week 1 to 2
    Authz library in shadow mode"] --> W2["Week 3 to 4
    Enforce on read endpoints"] W2 --> W3["Week 5 to 6
    Enforce on writes and exports"] W3 --> W4["Week 7 to 8
    10 minute tokens, dual DB credentials"] W4 --> W5["Week 9 to 10
    Retire shared DB password"]

    In shadow mode the library logs a would-deny event and still allows the request. The team reviews every would-deny for false positives before flipping a flag per endpoint. Token lifetime falls first for internal users, then a 5 percent canary of customers. Database roles are migrated with both the old and new credentials valid, so a rollback is a config change. Verification: shadow would-deny count trending to zero for legitimate traffic, an automated cross-tenant test in CI that must return 404 for every endpoint, refresh error rate under 0.1 percent, secret age alerts, and exports appearing in the audit stream within a minute.

    The 2 a.m. failure

    The page reads: "API 401 rate above 20 percent for 5 minutes". The identity provider rotated its signing key overnight, and services cached the old key set for hours.

    sequenceDiagram participant U as User participant S as Invoice service participant K as Cached key set participant I as Identity provider U->>S: Request with token signed by new key S->>K: Look up key id K->>S: Unknown key id S->>U: 401 Unauthorized Note over S: Cache refresh not triggered until TTL expires S->>I: Fetch key set (after fix) I->>S: New and old keys

    Detect it with the auth_failures_total{reason="unknown_kid"} counter. First three checks: read one service log for the key id the token carries, fetch the provider's key-set endpoint and confirm that id is present, and compare the age of the service's cached key set. Mitigation: trigger a key-set refresh through the library's refresh hook if it has one, or a rolling restart of the affected services (never all pods at once). Follow-up fix: refetch on an unknown key id with a rate limit, and agree with the identity provider to publish a new key before it signs with it.

    Architect's notes

    • Measure before deciding. Count endpoints that take a resource id and how many have a tenant check. That number, not a guess, sizes the work.
    • Ask in the review: "If this one credential leaks, what exactly does it open, and for how long?" Make every owner answer for their own service.
    • The late cost is rotation. Short tokens and rotating secrets create operational events. Budget alerting and a runbook, or people will quietly lengthen lifetimes again.
    • Own it. A shared authorisation library needs a named owner and a version policy, otherwise nine services drift across five versions.
    • Defer on purpose. mTLS and per-tenant keys are good ideas with a real running cost; write down the trigger instead of arguing about them every quarter.
    • Evidence is a feature. Auditors ask for logs of access and change. Design the audit stream so the answer is a query, not a project.

    Common mistakes

    • Treating the internal network as trusted. Once one machine is compromised, flat networks let the attacker roam. Use mTLS, network segmentation and per-service authorisation.
    • Authenticating but not authorising. Confirming the caller is logged in without checking they own the resource produces object-level access bugs. Check ownership on every data access.
    • Trusting the client. Hidden form fields, client-side validation and "admin" flags sent from the browser can all be edited. Validate and decide on the server.
    • Long-lived, over-scoped tokens and keys. A leaked credential then does maximum damage for maximum time. Use short lifetimes, narrow scopes, and rotation.
    • Secrets in code, images or logs. They spread to every clone, cache and backup. Use a secret manager and scan for leaks.
    • Rolling your own cryptography or session scheme. Subtle errors are invisible until exploited. Use standard protocols and maintained libraries.
    • Logging too little or too much. Without audit logs you cannot investigate; with secrets and personal data in logs you create a new leak. Log events, not credentials.

    Key takeaways

    • Assume failure. Design so that one broken control is contained by the next one.
    • Threat model early. Mark trust boundaries on your diagram, walk the STRIDE categories, and fix the highest-risk items first.
    • AuthN is not AuthZ. Verify identity at the edge, but check permissions and ownership in every service that owns data.
    • OAuth 2.0 delegates access; OIDC proves identity. Use the authorisation-code flow with PKCE for user-facing apps and follow current guidance.
    • Treat JWTs carefully. Validate signature, expiry, issuer and audience; keep them short-lived; remember the payload is readable.
    • Encrypt in transit and at rest, and know the limits. TLS and mTLS protect the wire; disk encryption does not stop application bugs.
    • Manage secrets as a lifecycle. Inject at runtime, scope narrowly, rotate, and never log or commit them.
    • Layer defences and keep evidence. Rate limits, least privilege, validation and audit logs together turn incidents into survivable events.

    Practice exercises

    Exercise 1 — Starter (30 min): Take the order-service diagram in this lesson. Copy it, add a mobile app and an admin dashboard, and mark every trust boundary. For each boundary crossing, write one threat from the STRIDE table and one defence.

    Exercise 2 — Intermediate (2 hours): Design the authentication and authorisation for a photo-sharing API where users have private and shared albums. Specify the login flow, which scopes the access token carries, token lifetimes, where refresh tokens live, and exactly which service checks that a caller may view a given photo. Write the check as pseudocode and list three requests that must be rejected.

    Exercise 3 — Advanced (half day): Produce a one-page security design for a system with a gateway, five microservices, a message queue and a database of personal data. Cover mTLS and certificate rotation, secrets injection and rotation, per-service database accounts, encryption choices with the key hierarchy, an audit-log schema, and the five alerts you would configure first. Finish with a table of what each layer would catch if the previous layer failed.

    Enroll to keep reading

    Lesson 14 of 14 · System Design Building Blocks: Data, Messaging and Scale

    Learn to threat-model a system, separate authentication from authorisation, use OAuth and JWTs safely, and layer defences so one mistake is not a breach.

    In the rest of this lesson

    • Tokens and JWT pitfalls
    • Encryption: in transit, at rest, and between services
    • Secrets management
    • Least privilege, validation, and abuse
    • Audit logs and defence in depth
    • In practice: hardening a B2B procurement SaaS before its first enterprise audit

    Enrolling is free. It unlocks every lesson in System Design Building Blocks: Data, Messaging and Scale, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments