Threat Modeling: Attack Surface, Trust Boundaries, and Secure Defaults

Learn a practical threat-modeling method for PHP applications -- enumerating attack surface, drawing trust boundaries, applying least privilege and defense in depth, and choosing secure-by-default behavior -- and apply it end to end against a small login feature.

Before writing a single security control, it helps to think like an attacker for a few minutes: what can they touch, what do they gain by touching it, and what stands between them and something valuable? That structured exercise is threat modeling. It is not a specific tool or a checklist you run once -- it is a habit of asking 'where does untrusted input enter this system, and what does it cross to get somewhere dangerous?' every time a feature is designed or reviewed. This lesson builds the vocabulary (attack surface, trust boundary, least privilege, defense in depth, secure defaults) that the rest of this Web Security section leans on, and then walks through threat-modeling a small, realistic login feature from scratch -- the same exercise you'd do before writing the XSS, CSRF, and injection defenses covered in the lessons that follow.

Attack Surface: Everywhere Untrusted Input Can Enter

An application's attack surface is the complete set of points where something outside the application's control can influence its behavior. In a typical PHP web app that includes the obvious things -- form fields, query-string parameters, JSON request bodies, uploaded files -- but also less obvious ones: HTTP headers (User-Agent, Referer, X-Forwarded-For, a custom API key header), cookies, the URL path itself, environment variables set by a hosting platform, data read back out of a database that a DIFFERENT part of the app once wrote from user input, and responses from third-party APIs the application calls. Every one of these is a place an attacker can place a value they control and see what the application does with it.

The discipline that matters here is not memorizing a list, it's asking the question by default: for any new feature, 'what NEW input does this introduce, and where does that input flow to?' A feature that reads a filename from a form and later passes it to file_get_contents() has just added a path-traversal-shaped item to the attack surface, whether or not anyone thought of it in those terms at design time. Reducing attack surface is itself a mitigation -- an endpoint, parameter, or feature flag nobody uses is one less place for a bug to live, and removing dead code paths is genuinely a security improvement, not just tidiness.

• Attack surface = every point where data or control from outside the application's trust can enter it. • Obvious sources: form fields, query strings, JSON bodies, uploaded files. • Easy to miss: HTTP headers, cookies, the URL path, env vars, and data that was ONCE user input and is now sitting in your own database. • Shrinking the attack surface (removing unused endpoints/features) is a mitigation in its own right.

Trust Boundaries: Where 'Untrusted' Becomes 'Trusted'

A trust boundary is a line in the system where data crosses from a context with less trust into one with more -- the browser to your server, an untrusted user's account to an admin-only action, your application to the database, your server to a third-party API it calls. The reason this framing matters is that a security check placed on the WRONG side of a trust boundary provides no protection at all: client-side JavaScript validation happens entirely inside the browser, which the attacker fully controls, so it can always be bypassed by sending the request directly (with curl, or a modified request in the browser's own dev tools) -- it's a usability nicety, never a security control. The only validation that counts is validation performed after the data has crossed onto the server, where the attacker no longer has direct control over the code doing the checking.

The same idea applies inside the server, not just at its edge. Data that started as untrusted user input and was stored in the database doesn't become trusted just because it's now sitting in a users table -- if it's later rendered into HTML or built into a shell command, it needs exactly the same output-context handling as if it had arrived fresh from the request. Trust boundaries also exist between privilege levels within one app: the boundary between 'any logged-in user' and 'an admin user' is a trust boundary just as real as the one between 'the internet' and 'your server', and it needs to be enforced on the server for every request that matters, not assumed from a hidden button that a regular user's browser simply doesn't render.

• A trust boundary is any point where data moves from less-trusted context to more-trusted context (browser to server, user to admin, app to DB). • Client-side validation is a UX convenience, never a security control -- it runs entirely inside attacker-controlled code (the browser). • Data doesn't become 'trusted' just because it's now in your database -- it carries its original trust level until it's actually validated/encoded. • Privilege boundaries (user vs. admin) must be enforced server-side on every request, never assumed from what the UI happens to show.

Least Privilege, Defense in Depth, and Secure Defaults

Least privilege means every component -- a database user, an API credential, a file-system permission, a session's role -- should have the minimum access it needs to do its job and nothing more. A web application's database user genuinely never needs DROP TABLE permission for normal request handling; if it only has SELECT/INSERT/UPDATE on the specific tables it uses, a SQL injection bug that does slip through is limited to what that restricted account can do, rather than handing the attacker the whole database. The same logic applies to file permissions (the web server process shouldn't be able to write to its own PHP source files), API keys (a key used only for reading a shipping-rate API shouldn't also be able to issue refunds), and session data (a regular user's session shouldn't carry an is_admin flag the client can influence).

Defense in depth means never relying on exactly one control to stop a given attack -- layering independent mitigations so that one failing doesn't mean total compromise. For SQL injection: prepared statements are the primary defense, but a least-privileged database user is a second, independent layer that limits the blast radius if the first one is somehow bypassed by a bug in an ORM or a raw query someone forgot to parameterize. For XSS: output encoding is the primary defense, and a Content-Security-Policy header is a second layer that can stop an injected script from doing anything useful even if it does get through. Secure defaults is the design habit that makes both of these easier to sustain: a new feature, a new cookie, a new API endpoint should be SECURE by default and require a deliberate, visible decision to loosen it -- a session cookie defaults to HttpOnly and Secure and you'd have to actively remove those flags to weaken it, rather than defaulting to nothing and requiring someone to remember to add them. Systems where the safe path is also the path of least resistance stay secure much longer than systems that depend on every developer remembering every rule, every time.

• Least privilege: every credential, account, and permission gets the minimum access needed -- nothing 'just in case'. • Defense in depth: layer independent controls so one failure doesn't mean total compromise (e.g. prepared statements AND a restricted DB user). • Secure defaults: new features should be secure out of the box, requiring a deliberate action to weaken them -- not secure only if someone remembers to configure it that way. • None of these three replace each other -- a system with only one of them is still fragile.

Worked Example: Threat-Modeling a Small Login Feature

Consider a minimal login feature: an HTML form posting an email and password to /login, which checks the credentials against a users table and, on success, starts a session. Walking through attack surface, trust boundaries, and mitigations for just this one feature already surfaces most of the vocabulary above. Attack surface: the email field, the password field, the CSRF token (once added), the Set-Cookie response header, and -- easy to forget -- the error message shown on failure and the RESPONSE TIME of the endpoint, both of which can leak information.

Trust boundaries crossed: browser to server (the form submission itself), then server to database (the credential check), then 'anonymous visitor' to 'authenticated user' (the moment the session is established -- the highest-value boundary in this whole feature). For each boundary, ask what could go wrong and what stops it: at browser-to-server, an attacker could submit crafted input -- mitigated by treating the email/password as untrusted and validating format server-side, and never trusting a client-side 'this field is required' check alone. At server-to-database, an attacker could try SQL injection via the email field -- mitigated by a PDO prepared statement (never string-concatenated SQL) and a least-privileged DB user that can only read the users table it needs. At the anonymous-to-authenticated boundary, an attacker could try credential stuffing (automated login attempts using leaked password lists) -- mitigated by rate limiting on the endpoint and password_verify() against a password_hash()-stored hash, which is deliberately slow enough to make brute-forcing expensive. Two secure-by-default choices close out the picture: the session cookie defaults to HttpOnly, Secure, and SameSite=Lax the moment it's created (not bolted on later), and the login error message is identical whether the email doesn't exist or the password is wrong ('Invalid email or password') so the response itself never confirms which accounts exist.

Notice what this exercise produced: a short, concrete list of specific mitigations for specific threats, each one attached to a specific trust boundary -- not a vague sense of 'the login should be secure.' That's the entire point of threat modeling: it turns a broad, unhelpful goal into a checklist that can actually be reviewed, tested, and revisited the next time the feature changes (for example, adding 'remember me' or a password-reset flow reopens this exercise, because each introduces new attack surface of its own).

• Step 1 -- enumerate attack surface: every input the feature accepts (form fields, cookies, even timing and error-message content). • Step 2 -- identify trust boundaries: browser-to-server, server-to-database, anonymous-to-authenticated. • Step 3 -- for each boundary, ask 'what could go wrong here?' and attach a specific, named mitigation to each answer. • Step 4 -- bake the mitigations in as defaults (HttpOnly/Secure/SameSite cookies from creation, not added later) rather than optional add-ons. • Re-run the exercise whenever the feature changes -- a new field or flow is new attack surface.

Code Examples

Every line here traces back to one of the threat-modeling steps: input is validated as untrusted at the browser boundary, the database query is parameterized at the server-to-database boundary, rate limiting and constant-shaped error messages guard the anonymous-to-authenticated boundary against credential stuffing and account enumeration, and the session cookie's secure flags are set BEFORE the session starts rather than as an afterthought.

session_set_cookie_params(['httponly' => true, 'secure' => true, 'samesite' => 'Lax']);

<?php
declare(strict_types=1);

// -- Trust boundary: browser -> server. Treat both fields as untrusted. --
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
$password = $_POST['password'] ?? '';

if ($email === false || $email === null || $password === '') {
    http_response_code(400);
    exit('Invalid email or password'); // same message for every failure mode
}

// -- Rate limiting: mitigates credential stuffing at the anonymous->authenticated boundary. --
if (tooManyRecentAttempts($email)) {
    http_response_code(429);
    exit('Too many attempts, try again later');
}

// -- Trust boundary: server -> database. Prepared statement, least-privileged DB user. --
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();

recordLoginAttempt($email); // for rate limiting, regardless of outcome

// Same generic message whether the email doesn't exist OR the password is wrong --
// never confirm which accounts exist via the error message.
if ($user === false || !password_verify($password, $user['password_hash'])) {
    http_response_code(401);
    exit('Invalid email or password');

…

Login Feature: Trust Boundaries and Mitigations

Three trust boundaries in one small feature, each with its own specific mitigation.

Browser (untrusted)
     |  email, password, cookies
     v
  ============ trust boundary 1: browser -> server ============
     |  validate server-side, never trust client-side checks alone
     v
  App code (PHP)
     |  SELECT ... WHERE email = :email  (bound parameter)
     v
  ============ trust boundary 2: server -> database ============
     |  least-privileged DB user, prepared statement
     v
  Database
     |  password_verify() against stored hash
     v
  ============ trust boundary 3: anonymous -> authenticated ======
     |  rate limiting, generic error message, session_regenerate_id()
     v
  Authenticated session (HttpOnly + Secure + SameSite cookie)

Important Rules

List attack surface explicitly for any new feature -- every field, header, cookie, and even error-message content.

Draw trust boundaries and place validation/authorization checks strictly on the trusted side of each one.

Never rely on client-side checks as a security control -- they run in attacker-controlled code.

Grant least privilege by default: database users, API keys, and file permissions get only what they need.

Layer defenses (defense in depth) so one control failing doesn't mean total compromise.

Make the secure choice the default -- require a deliberate action to weaken it, not to enable it.

Why This Matters in Practice

Security reviews and bug bounty triage both run on this same vocabulary -- a report that says 'the /transfer endpoint doesn't re-check authorization after the user's role changes' is describing a trust-boundary failure, and a fix that adds a database role with only the minimum grants needed is applying least privilege. Teams that threat-model new features as a five-minute habit before writing code consistently ship fewer of the injection, XSS, and authorization bugs covered in the rest of this section, because the mitigations get designed in at the trust boundary from the start instead of patched on after a report comes in.

Relying on a JavaScript required attribute and client-side regex as the only validation on a login form.

Validate and sanitize every field again on the server, after it has crossed the browser-to-server trust boundary, regardless of what the client already checked.

Client-side checks are a UX convenience that an attacker can bypass entirely by sending the request directly -- they provide zero actual security.

[multiple-choice] Why is client-side JavaScript validation never sufficient as a security control on its own? A. It's too slow to run on every keystroke B. It runs entirely inside the browser, which the attacker fully controls, so it can always be bypassed by sending the request directly C. Browsers disable JavaScript by default D. It only works for text fields, not file uploads

B. It runs entirely inside the browser, which the attacker fully controls, so it can always be bypassed by sending the request directly — Client-side validation executes in code the attacker controls (their own browser); they can always skip it by crafting the request themselves, so only server-side checks -- after the browser-to-server trust boundary -- count as real validation.

[multiple-choice] A SQL injection bug in a login form is discovered, but the database user the app connects with only has SELECT permission on the users table. Which principle limited the damage? A. Defense in depth B. Least privilege C. Secure defaults D. Attack surface reduction

B. Least privilege — Least privilege means the credential in use has the minimum access it needs; even though the injection bug itself wasn't prevented, the restricted DB user limited what that bug could actually do.

[true-false] True or False: once user input has been stored in your own database, it can be treated as trusted for the rest of the application. A. True B. False

B. False — False -- data doesn't become trusted just because it's sitting in your database. It carries its original untrusted status until it's actually validated or context-appropriately encoded wherever it's used next (e.g. rendered as HTML).

A 'forgot password' feature accepts an email address, and if it matches an account, emails a reset link containing a token. Threat-model it: list at least three attack-surface items, one trust boundary, and one secure-default choice that should be baked in from the start.

Attack surface: the email field, the generated token value, the token's expiry, and the response's timing/content when the email doesn't exist. Trust boundary: browser-to-server for the submitted email, and server-to-email-provider for the outgoing message. Secure default: the response message must be identical ('If that email is registered, a reset link has been sent') whether or not the account exists, and the token must default to being cryptographically random (random_bytes()-based, not uniqid() or a predictable sequence) and short-lived, invalidated after first use.

Think about what the RESPONSE reveals when the email doesn't match an account, and about how guessable/how long-lived the reset token is.