Web Security Basics Every Full-Stack Developer Should Know

I walk through the security mindset that underpins every defence — the OWASP Top 10 reframed for developers, threat model thinking, defence in depth, and secure defaults.

Web Security Basics Every Full-Stack Developer Should Know

Most developers pick up security knowledge piecemeal. You learn not to put raw user input into the DOM after reading about XSS. You learn about parameterised queries the first time someone mentions SQL injection. These individual practices stick, but they arrive without a mental model to hold them together.

What’s harder to find is the layer underneath: not “here’s how XSS works” but “here’s how an attacker looks at your whole application.” The OWASP Top 10 exists and is useful, but it reads like an audit checklist — written for security teams running assessments, less useful for a developer deciding how to structure a feature before writing any code.

I’m going to walk through the concepts that actually change how you design code, not just how you patch it: the OWASP categories reframed for developers, the habit of thinking about attack surfaces, defence in depth as a principle rather than a specific technique, and what “secure by default” means in practice.

This is the first post in a three-part series on web security. The second covers the specific defences for XSS, CSRF, and SQL injection. The third covers rate limiting, CORS, and security headers.

On this page

How the OWASP Top 10 is actually useful

The OWASP Top 10 is a list of the ten most common security risks in web applications, updated from data gathered across real vulnerabilities in production systems. The 2021 version is what most developers encounter today; the 2025 update is in progress at the time of writing.

The temptation is to treat it as a checklist: handle each item, consider yourself secure. That misses the point. The Top 10 is better read as a map of how apps fail — and most of those failures aren’t bugs that slipped through. They’re design decisions made without asking what an attacker could do with the result.

Reframed as questions a developer should be asking:

Risk The question it’s really asking
Broken Access Control (A01) Can a user perform actions or access data they shouldn’t be able to?
Cryptographic Failures (A02) Is sensitive data protected in transit and at rest?
Injection (A03) Can attacker-controlled input reach an interpreter — SQL, HTML, a shell?
Insecure Design (A04) Was security considered in the design, or only in the implementation?
Security Misconfiguration (A05) Are default credentials in use, or unnecessary features exposed?
Vulnerable Components (A06) Are any dependencies outdated or known to be compromised?
Authentication Failures (A07) Can attackers exploit weak or missing authentication mechanisms?
Software Integrity Failures (A08) Are updates, CI/CD pipelines, and serialised data verified before being trusted?
Logging Failures (A09) Will you know when something goes wrong?
SSRF (A10) Can an attacker make the server fetch an untrusted resource?

Notice that six of the ten are architectural problems, not implementation bugs. Injection is fixable at the code level — use parameterised queries, encode output, done. But Insecure Design and Broken Access Control aren’t visible in a code review of a single function. They show up when someone asks the harder question: “What happens if a logged-in user makes this API call directly with a different user’s ID in the payload?”

That’s the question security training calls “threat modelling,” and it’s the habit that separates developers who get security right from those who discover it via incident reports.

Thinking like an attacker: the attack surface

An application’s attack surface is every point where it accepts input. Form fields, URL parameters, cookies, request headers, file uploads, webhook payloads, environment variables injected at runtime — anything your app reads and acts on is a potential entry point.

Mapping those entry points, identifying what each one touches, and thinking through what an attacker could do if the input contained exactly what they wanted is the practical version of threat modelling. It doesn’t require a security team. It requires one question asked consistently while you’re building:

What’s the worst thing that could happen if this input was crafted by someone trying to harm the system?

For a search field that queries a database: SQL injection if the query uses string concatenation instead of parameterised values. For a comment field that displays to other users: stored XSS if the output isn’t encoded before rendering. For a file upload: server-side execution if file type validation is missing or bypassable at the content level. For an API that accepts a user-supplied URL: server-side request forgery if the server fetches that URL without validating the host.

Try this

Pick one user-controlled input in your current project — a search parameter, a form field, a webhook payload, or a header value.

Ask three questions: What system does this input reach? What’s the most dangerous operation that system can perform? What would happen if the input was designed to trigger that operation?

Expected result: You’ll either spot a gap that needs a defence, or confirm that a defence is already in place. Both outcomes are useful.

The mental model scales naturally. Once it’s a habit for form fields, it extends to API payloads, query string parameters, third-party callbacks, and anywhere else the app trusts external input.

Understanding how the web works at the request level makes this easier — knowing which layer of the stack each input reaches tells you which class of attack is relevant.

Defence in depth: more than one layer

The most common security shortcut I see is treating one defence as sufficient. “We use parameterised queries, so SQL injection isn’t a problem.” That’s true for SQL injection specifically. But it doesn’t help when a developer adds a new feature three months later and doesn’t use parameterised queries. And it doesn’t limit the damage if the application’s database credentials have write access to tables they only need to read from.

Defence in depth means having multiple independent layers so that the failure of any single one doesn’t immediately result in a serious breach. For a feature that accepts and stores user-submitted content, a layered approach looks like this:

  1. Validate at the boundary — reject input that doesn’t match the expected format before it travels further into the system
  2. Sanitise or encode before storage and output — prevent the content from being interpreted as code by the database or browser
  3. Use parameterised queries — make it structurally impossible to inject SQL regardless of what the input contains
  4. Apply least privilege to the database connection — the application user should not have permissions beyond what the application actually needs
  5. Set a Content Security Policy — so that even if executable content reaches the browser, the browser has additional constraints on what it runs

These layers aren’t redundant — they defend against different failure modes. Validation stops malformed input early. Sanitisation and encoding handle valid-format content that could still be dangerous. Parameterised queries remove an entire class of database attacks. Least privilege caps the blast radius if something does go wrong. CSP provides a browser-level backstop.

When one layer is missing, the remaining layers carry more responsibility than they were designed for. A parameterised query doesn’t help if the database account has permission to drop tables. A CSP doesn’t help if every endpoint trusts unescaped user content.

Secure by default: making safety the path of least resistance

Secure defaults mean the safe option is what you get when you don’t think about it. The alternative — opt-in security — requires every developer on every feature to actively choose the secure path, every time. That’s a fragile system, especially as a team grows or a codebase ages.

A practical example is cookie configuration. Setting a session cookie with default framework options typically leaves it readable by JavaScript, transmittable over plain HTTP, and included in cross-origin requests. Each of those defaults creates a separate attack surface:

// These options must be set explicitly — the defaults leave three vectors open
res.cookie("session", token, {
  httpOnly: true,      // JavaScript cannot read this cookie — blocks most XSS-based session theft
  secure: true,        // Only transmitted over HTTPS — prevents interception on plain HTTP
  sameSite: "strict",  // Not sent in cross-origin requests — mitigates CSRF
  maxAge: 60 * 60 * 24 * 1000, // Expires after 24 hours
});

None of those options are complex. But if you don’t know they exist, the framework defaults leave three separate attack paths open by doing nothing.

The cookie only carries the session identifier — the JWT authentication post covers how to generate and validate the token value that goes into it, including signing, expiry, and rotation strategies.

The same principle applies more broadly. HTTPS should be active for every page, not just login and payment flows — a session cookie marked secure is meaningless if any page on the site can be loaded over HTTP and the cookie sent in that request. A Content Security Policy is much harder to add to an existing codebase than to set from the start — adding one later means auditing every inline script, every eval call, and every external resource reference that accumulated over time.

The question to ask when designing a new feature isn’t “do we need to add security to this?” but “what are the insecure defaults I need to override?”

The authentication and authorization post covers how this principle applies to access control — specifically, why server-side permission checks matter even when the UI already hides the controls.

Where the mindset breaks down

Three patterns produce insecure apps even when developers know the individual rules:

Treating security as a phase. Security review before launch, penetration test before go-live — then done. New features, updated dependencies, and changing configurations introduce risk continuously. A codebase that was secure at launch isn’t automatically secure six months later. Security needs the same kind of maintenance as any other quality property.

Security through obscurity. “Attackers won’t find this endpoint because it’s not documented.” An undocumented API route is still reachable. An obfuscated error message still reveals implementation details if an attacker can observe responses systematically. Obscurity can raise the cost of an attack, but it isn’t a defence — it’s a delay.

Misplacing the trust boundary. The trust boundary is the line where the application’s control ends and external input begins. Everything inside the boundary is trusted. Everything outside is not. The most common mistake here is treating something external as safe because it arrived through an authenticated endpoint.

Authentication establishes who is making the request. It doesn’t make the request safe. A logged-in user can still send malformed data, supply another user’s ID in a resource URL, or inject content that the server will later serve to other users. Authentication narrows the attack surface; it doesn’t close it.

Check this before moving on

  • Every user-controlled input in your app has a documented defence — validation, parameterisation, sanitisation, or encoding
  • Cookie attributes (httpOnly, secure, sameSite) are set explicitly, not left at framework defaults
  • Database connections use least-privilege credentials — no admin-level access in the application layer
  • HTTPS is active across the whole app, not only on sensitive pages

Security is a design decision, not a phase

Developers who find security easiest to maintain aren’t necessarily the ones who know the most attack techniques. They’re the ones who ask “what could go wrong here?” at the point where it’s cheapest to change — when the feature is still being designed.

Retrofitting security onto an existing architecture is real work. Adding HTTPS to a site already serving mixed content requires careful audit. Replacing string-concatenated queries across an existing codebase is tedious and risky if tests are sparse. Restructuring an app that accumulated admin database access across every route requires careful privilege separation.

The OWASP Top 10 as a design checklist rather than an audit checklist, thinking about attack surfaces when you add a new input, and applying defence in depth as a starting pattern rather than a response to a specific incident — these habits don’t slow development. They remove the kind of rework that does.

The next post in this series applies the mental model to three concrete attacks: XSS, CSRF, and SQL injection — and the specific defences for each. The third covers the HTTP-level controls — rate limiting, CORS, and security headers — that protect the application boundary.

Sources