Filter concepts by levelShowing all levels.

System Design · Section 58

Security Architecture

Level
intermediate
Read
18 min
Concepts
3

Security architecture starts with a design-time mindset — threat modeling each feature to ask who would attack it and how, shrinking the attack surface, granting least privilege to humans and services alike, stacking independent layers (defense in depth) so one control failing does not mean total compromise, and shipping secure defaults so an unconfigured system is still reasonably safe. That mindset is kept true over time by operational controls: secrets live in a dedicated secrets manager rather than source control, keys and credentials rotate on a schedule to limit a leak's useful lifetime, and audit logs record who did what so an incident can be investigated rather than remaining a mystery. Both exist to prevent the same recurring set of risks — SQL injection, XSS, CSRF, SSRF, broken access control, command injection, insecure deserialization, credential leakage and internal endpoint abuse — which split into an injection family (untrusted input executed as code instead of treated as data) and an access family (a request or credential reaching something it should not).

This section

What is true here

  1. Threat modeling is a per-feature habit, not a one-time document — attack surface grows with every new endpoint and dependency shipped after it.
  2. Defense in depth assumes any single control can fail — independent layers contain a breach that one strong control alone would not.
  3. Secrets never live in source control; they live in a secrets manager and rotate on a schedule so a leak has a limited useful lifetime.
  4. Broken access control — checking "logged in" without checking "allowed to touch this specific object" — is the most common real-world finding among the nine common risks.

What you will be able to do

  • Run a lightweight threat model against a new feature before it ships, not just at initial system design
  • Apply least privilege and defense in depth so a single compromised credential or bypassed control has a contained blast radius
  • Store and rotate secrets through a dedicated secrets manager instead of source control or static config
  • Recognize the pattern behind each of the nine common risks well enough to spot it in an unfamiliar codebase

The security mindset and the controls that sustain it

The design-time principles that shape a defensible system, and the operational controls that keep that design true as the system changes.

Security architecture principles: threat modeling to secure defaults

coreintermediate

Threat modeling is the exercise of asking, for a specific system, who would attack it, how, and what they would gain — done before or during design, not after an incident. Attack surface is everything an attacker could touch: every endpoint, input field, dependency and open port, and a smaller surface is easier to defend than a larger one. Least privilege means every user, service and process gets only the access it needs to do its job, nothing more. Defense in depth means stacking multiple independent security controls so one failure does not mean total compromise. Secure defaults means the out-of-the-box configuration is the safe one, so a team that changes nothing is still reasonably protected.

Think of it as

Think of securing a building. Threat modeling is walking the property asking "who wants in, and how would they try" before you buy locks. Attack surface is every door, window and vent — the fewer of them, and the fewer left unlocked, the less a guard has to watch. Least privilege is giving each employee a key only to the rooms their job requires, not a master key "just in case." Defense in depth is a locked door behind a fence behind a guard behind an alarm — a burglar who beats one layer still has to beat the next. Secure defaults is the building shipping with the alarm already armed, not waiting for someone to remember to turn it on.

text
// Least privilege applied to a database credential
// Bad:  one shared "app_user" role, superuser-adjacent
// Good: one role per service, scoped to what it touches

GRANT SELECT, INSERT ON orders TO orders_service;
GRANT SELECT ON orders TO reporting_service;
-- reporting_service can never write, even if
-- its credential leaks

What we're doing: Apply threat modeling to a new "export my data" API endpoint before it ships.

threat-model-export-endpoint.txttext
Feature: GET /users/:id/export (returns a JSON dump
of a user's account data)

1. What are we building?
   An endpoint that reads a user's full record and
   related rows (orders, addresses) and returns them.

2. What can go wrong? (walk each STRIDE category)
   - Spoofing: could a caller claim to be a different
     user_id than their session?
   - Tampering: none -- read-only endpoint.
   - Information disclosure: does the export include
     another user's data via a joined table, or fields
     the owner shouldn't see (internal risk score)?
   - Denial of service: is export expensive enough that
     repeated calls degrade the database for everyone?
   - Elevation of privilege: does an admin-only field
     leak to a non-admin caller of this same endpoint?

3. What will we do about it?
   - Authorize :id against the session's own user_id,
     not merely "is this caller logged in."
   - Exclude internal-only columns explicitly, not by
     accident of what the ORM model happens to expose.
   - Rate-limit the endpoint per user.
   - Log every export call (who, when, whose data) for
     audit purposes.
8
Spoofing here is not about fake credentials — it is about whether the URL's :id is actually checked against the caller's own identity, a broken access control bug if it is not.
14
Information disclosure is the single most common finding a threat model catches before ship: a join or a serializer that returns more than intended.
22
Each answer in step 3 is a concrete control, not a vague intention — this is what turns threat modeling into work that actually happens.

Why this works: Threat modeling before writing the endpoint surfaces the authorization check and the field-leak risk while they are a one-line fix in code review, instead of after the endpoint has shipped and a user notices they can read someone else's export by changing the ID in the URL.

Treating threat modeling as a one-time document instead of a per-feature habit

Wrong

text
# threat-model.md, written once during the
# initial system design review, 18 months ago.
# Never revisited since.

Better

text
# Threat modeling as a lightweight checklist
# run against each new feature's design doc,
# not a single upfront document:
#
# - New endpoint? New attack surface -- model it.
# - New third-party integration? New trust
#   boundary -- model it.
# - New data field? Check if it changes what an
#   existing endpoint's export/serializer exposes.

What you see: A system's original threat model correctly covers the five endpoints that existed at launch, but eleven new endpoints have shipped since with no equivalent review — the actual attack surface has more than doubled while the documented one has not moved, so nobody is asking the STRIDE questions for the eleven endpoints most likely to have a fresh, unreviewed bug.

Why: Attack surface changes with every feature, not just at initial design — a threat model that is never revisited describes a system that no longer exists. Treating it as a recurring, lightweight step per feature keeps it matched to what is actually deployed.

Defense in depth against one threat (a leaked database credential)

Network

database not reachable from the public internet

Identity

least-privilege, per-service credential

Application

parameterized queries, input validation

Data

sensitive columns encrypted at rest

Detection

audit logs alert on anomalous query patterns

  1. Network — database not reachable from the public internet
  2. Identity — least-privilege, per-service credential
  3. Application — parameterized queries, input validation
  4. Data — sensitive columns encrypted at rest
  5. Detection — audit logs alert on anomalous query patterns

Remember: Threat model per feature, not once; shrink the attack surface before defending it; grant least privilege to humans and services alike; stack independent layers (defense in depth) since any one control can fail; ship secure by default so an unconfigured system is still reasonably safe.

See also: secrets audit logs and key rotation · common vulnerability classes

Secrets management, key rotation and audit logs

standardintermediate

Secrets management is storing passwords, API keys and certificates in a dedicated system built for it (a secrets manager or vault), never in source code or plain config files. Encryption protects data in transit and at rest and is one control among several here — it gets a full section of its own next. Key rotation is periodically replacing encryption keys and credentials so a leaked one has a limited useful lifetime. Audit logs record who did what and when, so a security incident can be investigated after the fact instead of remaining a mystery.

Think of it as

Think of a hotel. Secrets management is the key-card system, not a spare key taped under the doormat — a dedicated system built for the one job of controlling access. Key rotation is the hotel reprogramming the lock and issuing a new card at every checkout, so a copied key card from a previous guest stops working. Audit logs are the record of every card swipe: which door, which card, what time — the log that lets security reconstruct exactly what happened after someone reports a theft.

text
// Fetching a secret at runtime instead of
// hardcoding it or reading it from a plain .env
// committed to the repo:

db_password = secrets_manager.get_secret("prod/db/password")
// rotated on a schedule by the secrets manager;
// the application always fetches the current value,
// never stores its own long-lived copy
The lifecycle of one secret

Store it in a secrets manager

Never in source control or a plain config file. A committed secret stays compromised after deletion — it is still in git history, so the fix is rotate first, then remove.

Fetch it at runtime

The application reads the current value when it runs, instead of holding a long-lived copy baked into an image or an environment.

Rotate on a schedule

With an overlap window where the old and new key both validate. An instant hard cutover fails every request signed just before the swap.

Audit every access

Tamper-evident, and stored somewhere other than the systems it observes. Logs an attacker can edit after taking a host are not evidence of anything.

  1. Store it in a secrets manager — Never in source control or a plain config file. A committed secret stays compromised after deletion — it is still in git history, so the fix is rotate first, then remove.
  2. Fetch it at runtime — The application reads the current value when it runs, instead of holding a long-lived copy baked into an image or an environment.
  3. Rotate on a schedule — With an overlap window where the old and new key both validate. An instant hard cutover fails every request signed just before the swap.
  4. Audit every access — Tamper-evident, and stored somewhere other than the systems it observes. Logs an attacker can edit after taking a host are not evidence of anything.

Remember: Secrets live in a dedicated secrets manager, never in source control or plain config. Encryption is one control among several here (full treatment next section). Rotate keys and credentials on a schedule with an overlap window so a leak has a limited lifetime. Audit logs must be tamper-evident and stored separately from what they observe, or they cannot be trusted after a compromise.

See also: security architecture principles · common vulnerability classes

Advertisement

What the mindset defends against

The nine common risk patterns threat modeling, least privilege and defense in depth exist to prevent.

Common vulnerability classes: injection, access control and leakage

coreintermediate

These nine risks account for most real-world application breaches, and every one of them has a well-understood fix. Some are injection risks (SQL injection, XSS, command injection, insecure deserialization), where untrusted input is executed as code instead of treated as data. Others are access risks (broken access control, CSRF, SSRF, internal endpoint abuse), where a request reaches something it should not be able to reach. Credential leakage is the exposure of a secret that then unlocks any of the above. Knowing the pattern behind each one — not memorizing exploit syntax — is what lets you recognize the same shape in a new codebase.

Think of it as

Think of a form that asks for your name, and a system that trusts whatever you write in it completely. SQL injection is writing a name that is actually a database command — the system runs it because it never checked, the same way a printer that types out anything on a fax cover sheet, including "also print 500 extra copies," would follow that instruction literally. XSS is writing a name that is actually a script — the next visitor who views it does not just read your name, their browser runs your code. CSRF is a forged form submitted from another site using your already-logged-in session, like someone mailing a check with your signature traced onto it. Broken access control is a door that checks you have a key card, but not that it is the right key card for this specific door.

sql
-- SQL injection: the vulnerable pattern
-- (string concatenation, not parameters)
-- query = "SELECT * FROM users WHERE name = '" + input + "'"

-- Fixed: parameterized query
SELECT * FROM users WHERE name = $1;
-- input is passed as a bound parameter, never
-- concatenated into the query text

What we're doing: Find and fix a broken access control bug in an existing "view invoice" endpoint.

invoices.jsjavascript
// Vulnerable: checks the caller is logged in,
// never checks the invoice actually belongs to them
app.get('/invoices/:id', requireLogin, (req, res) => {
  const invoice = db.getInvoice(req.params.id);
  res.json(invoice);
});

// Fixed: authorize the specific object, not just
// the session
app.get('/invoices/:id', requireLogin, (req, res) => {
  const invoice = db.getInvoice(req.params.id);
  if (!invoice || invoice.ownerId !== req.user.id) {
    return res.status(404).end();
  }
  res.json(invoice);
});
3
`requireLogin` only proves the caller has a valid session — it says nothing about whether they own this particular invoice.
4
Any logged-in user can pass any :id here and read another user's invoice, since nothing compares the invoice to the caller.
12
The fix adds a per-object check: the invoice's stored owner must match the caller's own ID before the data is returned.
13
Returning 404 rather than 403 for a mismatched owner avoids confirming to an attacker that an invoice with that ID even exists.

Why this works: Authentication (who is the caller) and authorization (what can they touch) are separate checks — an endpoint that only performs the first while skipping the second is broken access control, the single most common real-world finding in this list because the missing check is invisible until someone tries an ID that is not their own.

Assuming an unlisted internal endpoint is a secured one

Wrong

text
// "Internal" admin endpoint, reachable from the
// public internet because no network rule
// actually restricts it -- only omitted from
// public API docs.
app.post('/internal/reset-user-cache', (req, res) => {
  cache.resetFor(req.body.userId);
  res.end();
});

Better

text
// Restricted by both an internal-only network
// path (not reachable from outside the VPC) and
// service-to-service authentication.
app.post('/internal/reset-user-cache',
  requireInternalNetwork,
  requireServiceAuth,
  (req, res) => {
    cache.resetFor(req.body.userId);
    res.end();
});

What you see: An endpoint meant only for other internal services turns out to be reachable directly from the public internet — a misconfigured load balancer route or a missing network policy exposed it, and because "internal" was never enforced by anything other than the absence of documentation, anyone who finds the URL (a leaked log line, a guessed path) can call it with no authentication at all.

Why: Leaving an endpoint out of public documentation is obscurity, not access control — obscurity fails the moment the URL leaks by any means. Internal endpoints need the same two real controls as anything else: network-level restriction (unreachable from outside) and authentication (service identity checked, not assumed).

Two families of risk, one shared cause

Injection-family

  • +SQL injection, command injection, XSS, insecure deserialization
  • +Untrusted input executed as code instead of treated as data

Access-family

  • Broken access control, CSRF, SSRF, internal endpoint abuse, credential leakage
  • A request or credential reaches something it should not
  • Injection-family
    • SQL injection, command injection, XSS, insecure deserialization
    • Untrusted input executed as code instead of treated as data
  • Access-family
    • Broken access control, CSRF, SSRF, internal endpoint abuse, credential leakage
    • A request or credential reaches something it should not

Nine common risks: the pattern and the fix

Nine common risks: the pattern and the fix
RiskPatternPrimary fix
SQL injectionUntrusted input concatenated into a SQL stringParameterized queries / prepared statements
XSSUntrusted input rendered as HTML/JS without escapingOutput encoding + Content-Security-Policy
CSRFForged request riding an existing authenticated sessionAnti-CSRF tokens + SameSite cookies
SSRFServer tricked into requesting an attacker-chosen URLAllowlist outbound destinations; block internal IP ranges
Broken access controlChecks "logged in" but not "allowed to touch this object"Per-object authorization check on every request
Command injectionUntrusted input concatenated into a shell commandArgument arrays, never shell string-building
Insecure deserializationUntrusted data fed to a general-purpose deserializerFixed-schema formats; never deserialize untrusted data generically
Credential leakageSecrets in source control, logs, errors, or client codeSecrets manager + rotation (see prior concept)
Internal endpoint abuseEndpoint restricted only by being undocumentedReal authentication + network-level restriction

Together

one-request-three-risks.txttext
A single vulnerable "delete account" flow can chain
three of the nine risks together:

1. Broken access control: DELETE /accounts/:id
   never checks :id belongs to the caller.
2. CSRF: the same endpoint accepts the request with
   no anti-CSRF token, so a forged form on another
   site can trigger it using the victim's session.
3. Credential leakage: the incident report that
   follows accidentally pastes a session token into
   a public ticket, giving the same access to anyone
   who reads it.

Fixing only one of the three still leaves a working
attack path through the other two.

Remember: Injection-family risks (SQL injection, command injection, XSS, insecure deserialization) run untrusted input as code instead of data — parameterize, encode, use fixed schemas. Access-family risks (broken access control, CSRF, SSRF, internal endpoint abuse, credential leakage) let a request or secret reach something it should not — authorize per object, verify origin, restrict by network and identity, never by obscurity.

See also: security architecture principles · secrets audit logs and key rotation

Advertisement