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.
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:
Know what you are protecting and from whom. A public blog and a payments service face different attackers and have different stakes.
Give every actor the minimum access needed (least privilege), so a stolen credential opens a small door, not every door.
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).
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:
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.
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:
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
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
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.
Rate Limiting - the main defence against brute force, scraping and noisy clients.
Observability and SLOs - how to see and alert on the signals that security controls produce.
Reliability Basics - the same assume-failure mindset applied to availability.
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.
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.