DEV Community

Cover image for Web Security Is Everyone's Job: A Developer's Field Guide
Olawale Afuye
Olawale Afuye

Posted on

Web Security Is Everyone's Job: A Developer's Field Guide

Security is not a feature you bolt on after launch. It is not the CISO's problem alone. It is not a checklist you run through before a compliance audit.

It is a shared responsibility across every engineer, every team, every layer of the stack.

This guide walks through the three layers where most web vulnerabilities live — Frontend, In Transit, and Backend — using a threat modeling lens: thinking like an attacker so you can build like a defender.


What Is Threat Modeling?

Before writing a single line of defensive code, you need to think systematically about your system's attack surface.

Threat modeling is the process of:

  1. Identifying entry points — Where does untrusted data enter your system? Form inputs, URL parameters, uploaded files, third-party APIs?
  2. Assessing potential impact — If this entry point is exploited, what can an attacker access or do?
  3. Designing defenses proactively — Before the exploit occurs, not after.

It shifts your mindset from "let's hope nothing breaks" to "let's assume something will be tried."


Part 1 — Frontend Security: Stopping XSS

What Is XSS?

Cross-Site Scripting (XSS) happens when untrusted data is rendered as executable code in a browser. An attacker injects a script; your application runs it on behalf of your users.

The consequences are severe: session hijacking, credential theft, defacement, redirects to malicious sites.

There are three flavours:

┌─────────────────────────────────────────────────────────────────┐
│                         XSS TYPES                               │
├──────────────────┬──────────────────────────────────────────────┤
│  Stored XSS      │  Malicious script saved in your DB,          │
│                  │  served to every user who loads that data.   │
│                  │  Most dangerous — persistent and broad.      │
├──────────────────┼──────────────────────────────────────────────┤
│  Reflected XSS   │  Script lives in a URL parameter.            │
│                  │  Requires tricking the user into clicking     │
│                  │  a crafted link. Temporary, per-request.     │
├──────────────────┼──────────────────────────────────────────────┤
│  DOM-Based XSS   │  Entirely client-side. No server involved.   │
│                  │  Script injected via DOM manipulation         │
│                  │  (e.g., reading from location.hash and        │
│                  │  writing to innerHTML).                       │
└──────────────────┴──────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Why is Stored XSS especially dangerous? Because you only need to inject the payload once. Every subsequent user who views that content becomes a victim — without any further action from the attacker.


The Core Defense Pattern

Validate early. Escape late. Sanitize only for rich content.

These three concepts are distinct and are applied at different stages of the data lifecycle.

Validation — At the Entry Point

Validation answers: "Does this data conform to business rules?"

It rejects inputs that don't belong — a non-email string in an email field, a negative number in a quantity field, a string that's too long. Validation does not transform data; it accepts or rejects it.

// ✅ Validation example
function isValidEmail(input) {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(input);
}

if (!isValidEmail(userInput)) {
  return res.status(400).json({ error: "Invalid email format" });
}
Enter fullscreen mode Exit fullscreen mode

Validation is your first gate. It cannot be your only gate.

Sanitization — Only for Rich Content

Sanitization answers: "Can I remove the dangerous parts while keeping the rest?"

It is specifically for situations where you must accept HTML — rich text editors, comment systems with formatting, etc. Use a well-maintained library like DOMPurify.

// ✅ Sanitization with DOMPurify
import DOMPurify from 'dompurify';

const cleanHTML = DOMPurify.sanitize(userProvidedHTML);
element.innerHTML = cleanHTML; // Now reasonably safe to render
Enter fullscreen mode Exit fullscreen mode

Critical rule: Do not sanitize data before storing it in the database. Store the raw input. Sanitize at render time.

Why? Because sanitization is lossy. If your understanding of what's "dangerous" evolves, or if the data needs to be displayed in a different context (email client, mobile app, PDF), you cannot recover what was permanently stripped. Raw data lets you re-process safely as threats evolve.

Escaping — At the Output Point

Escaping converts special characters so the browser treats them as text, not markup or code.

< becomes &lt;
> becomes &gt;
& becomes &amp;
" becomes &quot;
Enter fullscreen mode Exit fullscreen mode

This is the most universal defense. Applied at the point of rendering, not at the point of storage.

// ✅ Using textContent instead of innerHTML
// BAD — executes injected scripts
element.innerHTML = userInput;

// GOOD — treats everything as plain text
element.textContent = userInput;
Enter fullscreen mode Exit fullscreen mode

Never use innerHTML with untrusted data. The browser will interpret it as markup. Use textContent instead.


Content Security Policy (CSP)

Even with validation, sanitization, and escaping in place, CSP is your safety net.

CSP is an HTTP response header that tells the browser: "Only execute scripts, load styles, or fetch resources from these approved sources." Everything else is blocked.

Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted-cdn.com; style-src 'self'; img-src 'self' data:
Enter fullscreen mode Exit fullscreen mode

What this does:

  • default-src 'self' — by default, only load from the same origin
  • script-src 'self' https://trusted-cdn.com — scripts only from your domain and one trusted CDN
  • Inline scripts (<script>alert(1)</script>) are blocked by default

CSP doesn't prevent XSS at the injection point — it prevents the execution of injected scripts. It is your last line of defence.

Third-party scripts are a supply chain risk. Every <script src="..."> from an external CDN is implicit trust. If that CDN is compromised, your users are compromised. Use Subresource Integrity (SRI) hashes:

<script
  src="https://cdn.example.com/library.min.js"
  integrity="sha384-abc123..."
  crossorigin="anonymous">
</script>
Enter fullscreen mode Exit fullscreen mode

Part 2 — Security in Transition: Protecting Data in Motion

Man-in-the-Middle Attacks

When data travels from browser to server over plain HTTP, anyone on the same network can intercept and read it. This is a Man-in-the-Middle (MitM) attack — common on public Wi-Fi, compromised routers, and ISP-level interception.

The solution is HTTPS — encrypting the channel itself.

HTTP (insecure)
Browser ──────────────────────────────────────── Server
         "password=mysecret123"  ← readable by anyone

HTTPS (secure)
Browser ════════════════════════════════════════ Server
         "x7#$kL9...encrypted..."  ← unreadable in transit
Enter fullscreen mode Exit fullscreen mode

HTTPS alone isn't enough if users can still be downgraded to HTTP. HSTS (HTTP Strict Transport Security) solves this:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Enter fullscreen mode Exit fullscreen mode

This header instructs browsers to never connect to your domain over HTTP again for the specified duration. Once a user visits over HTTPS, all future requests — even if typed as http:// — are automatically upgraded before they leave the browser.


CORS — Cross-Origin Resource Sharing

CORS is a browser-side security feature. It controls which external origins can make requests to your API.

Origin A (evil.com) → Request to your API → Browser checks CORS headers
                                              ↓
                        If your API doesn't list evil.com as allowed:
                        Browser blocks the response from reaching evil.com's JS
Enter fullscreen mode Exit fullscreen mode

The critical misconception: CORS does not stop the request from reaching your server. It stops the response from being readable by the requesting origin's JavaScript. The request still executes. A tool like Postman or curl ignores CORS entirely.

CORS is not a security layer for your server — it's a protection for your users' browsers.

Never use wildcard CORS with credentials:

// ❌ DANGEROUS — allows any origin to access credentialed endpoints
app.use(cors({ origin: '*', credentials: true }));

// ✅ Explicit allowlist
const allowedOrigins = ['https://yourdomain.com', 'https://app.yourdomain.com'];
app.use(cors({
  origin: (origin, callback) => {
    if (allowedOrigins.includes(origin)) callback(null, true);
    else callback(new Error('Not allowed by CORS'));
  },
  credentials: true
}));
Enter fullscreen mode Exit fullscreen mode

Simple vs. Complex Requests

Simple requests (GET, POST with basic headers) bypass the preflight check and go directly to your server. If your endpoint has side effects (modifying data), it will execute — even if the browser later blocks the response. This is dangerous.

Complex requests trigger a preflight — an OPTIONS request that asks: "Am I allowed to do this?" Your server responds before the actual request is sent, preventing unauthorized state changes entirely.


CSRF — Cross-Site Request Forgery

Imagine you're logged into your bank. You visit a malicious site that contains:

<img src="https://yourbank.com/transfer?to=attacker&amount=10000" />
Enter fullscreen mode Exit fullscreen mode

Your browser, seeing an <img> tag, dutifully sends the request — and because you're logged in, your session cookie goes with it. The bank sees an authenticated request and executes the transfer.

This is CSRF. The attacker didn't steal your credentials — they borrowed your session without your knowledge.

Defense 1 — CSRF Tokens

A unique, unpredictable token generated per-session (or per-request) and embedded in forms. The server validates this token on every state-changing request. A malicious third-party site cannot read or forge it.

<form action="/transfer" method="POST">
  <input type="hidden" name="_csrf" value="a9f3d2...unique-token..." />
  <!-- form fields -->
</form>
Enter fullscreen mode Exit fullscreen mode

Defense 2 — SameSite Cookies

Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnly
Enter fullscreen mode Exit fullscreen mode
  • SameSite=Strict — Cookie is never sent on cross-origin requests. Maximum protection, can break some OAuth flows.
  • SameSite=Lax — Cookie sent on top-level navigation (clicking a link) but not on embedded requests (images, iframes). Good default.
  • SameSite=None — Requires Secure. Needed for legitimate cross-origin cookie sharing (e.g., embedded widgets).

The XSS + CSRF combination is particularly nasty: if an attacker gets XSS on your domain, they can read the CSRF token from the DOM and forge authenticated requests. This is why XSS mitigation must come first.


Part 3 — Backend Security: The Last Line of Defence

The Foundational Principle: Never Trust the Client

Frontend validation is UX sugar — it gives users fast, friendly feedback. It is not a security control.

Every constraint you enforce in the browser can be bypassed:

  • Opening DevTools and editing JavaScript
  • Sending raw requests via Postman or curl
  • Intercepting and modifying requests with a proxy like Burp Suite

Every input that reaches your server must be validated again, as if the frontend didn't exist.

┌─────────────────────────────────────────────────────────────┐
│                   TRUST BOUNDARY                            │
│                                                             │
│   Browser (Untrusted)    │    Server (Trusted)              │
│   ───────────────────    │    ──────────────────────        │
│   Validation = UX only   │    Validation = Required         │
│   Can be bypassed        │    Source of truth               │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

SQL Injection

SQL injection happens when attacker-controlled input is concatenated directly into a SQL query:

// ❌ VULNERABLE — never do this
const query = `SELECT * FROM users WHERE username = '${username}'`;

// If username = "' OR '1'='1" :
// SELECT * FROM users WHERE username = '' OR '1'='1'
// Returns ALL users. Authentication bypassed.
Enter fullscreen mode Exit fullscreen mode

The defense is parameterized queries (prepared statements). The query structure and the data are sent to the database separately — the database engine never interprets data as SQL instructions:

// ✅ Parameterized query
const query = 'SELECT * FROM users WHERE username = $1 AND password = $2';
const result = await db.query(query, [username, password]);
Enter fullscreen mode Exit fullscreen mode

For dynamic table or column names (which cannot be parameterized), use an explicit allowlist:

// ✅ Allowlist for dynamic table names
const ALLOWED_TABLES = ['orders', 'products', 'customers'];

function safeTableQuery(tableName) {
  if (!ALLOWED_TABLES.includes(tableName)) {
    throw new Error('Invalid table name');
  }
  return db.query(`SELECT * FROM ${tableName} WHERE status = $1`, ['active']);
}
Enter fullscreen mode Exit fullscreen mode

Rate Limiting

Brute force attacks work by trying thousands or millions of credential combinations. Without rate limiting, your login endpoint will happily process all of them.

Rate limiting puts a ceiling on how many requests a client can make in a given time window:

import rateLimit from 'express-rate-limit';

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 10,                   // 10 attempts per window
  message: { error: 'Too many login attempts. Try again later.' },
  standardHeaders: true,
  legacyHeaders: false,
});

app.post('/login', loginLimiter, loginHandler);
Enter fullscreen mode Exit fullscreen mode

For sensitive endpoints (login, password reset, OTP verification), be aggressive: 5–10 attempts per 15-minute window is reasonable.


Error Handling

Detailed error messages are invaluable for debugging. They are also a gift to attackers.

A stack trace can reveal:

  • Your framework and its version
  • File paths and directory structure
  • Database schema and table names
  • Internal service architecture

Always separate internal logging from user-facing responses:

// ❌ Leaking internal details
app.use((err, req, res, next) => {
  res.status(500).json({ error: err.message, stack: err.stack });
});

// ✅ Generic response, detailed internal log
app.use((err, req, res, next) => {
  // Log everything internally
  logger.error({
    message: err.message,
    stack: err.stack,
    url: req.url,
    method: req.method,
    userId: req.user?.id,
  });

  // Return nothing useful to the attacker
  res.status(500).json({ error: 'An unexpected error occurred.' });
});
Enter fullscreen mode Exit fullscreen mode

Password Storage: Hashing, Not Encryption

Encryption is reversible. If your encryption key is compromised, every password in your database is decrypted. Passwords must never be stored encrypted.

Hashing is one-way. You store the hash. At login, you hash the provided password and compare. If the database leaks, hashes are useless to an attacker — unless they can reverse them.

Why not SHA-256? It's fast — which is exactly the problem. Modern GPUs can compute billions of SHA-256 hashes per second, making brute-force attacks feasible.

Argon2 and bcrypt are designed to be slow and memory-intensive:

import argon2 from 'argon2';

// Hashing a password
const hash = await argon2.hash(plainPassword, {
  type: argon2.argon2id,
  memoryCost: 65536, // 64 MB RAM required per hash
  timeCost: 3,       // 3 iterations
  parallelism: 4,    // 4 CPU threads
});

// Verifying a password
const isValid = await argon2.verify(storedHash, providedPassword);
Enter fullscreen mode Exit fullscreen mode

Argon2 uses memory deliberately — it requires significant RAM per hash operation. GPUs excel at parallel computation but share a memory pool. Memory-hard algorithms make parallel cracking on GPU clusters prohibitively expensive.

Salting

A salt is a unique random string added to each password before hashing. This solves two problems:

  1. Rainbow table attacks — Precomputed tables of common password hashes become useless because the salt is unique per user.
  2. Identical passwords — Two users with the same password will have different hashes, hiding patterns in your database.

Argon2 and bcrypt handle salting automatically. You do not need to manage salts manually when using these libraries.


Require Re-authentication for Critical Actions

Session tokens can be hijacked. Even with a valid session, certain actions should require the user to prove their identity again:

  • Changing a password or email
  • Updating payment methods
  • Initiating financial transfers
  • Modifying security settings

This adds a critical layer of protection: even if an attacker hijacks a session, they cannot execute high-impact actions without knowing the current password.


The Full Security Stack — Visualized

┌─────────────────────────────────────────────────────────────────────┐
│                         BROWSER (Client)                            │
│                                                                     │
│  Input → [Validate for UX] → [Escape at render] → [CSP blocks bad] │
│  innerHTML? ❌  textContent? ✅  DOMPurify for rich HTML? ✅         │
└─────────────────────────┬───────────────────────────────────────────┘
                          │ HTTPS / HSTS (encrypted channel)
                          │ CORS (browser enforces origin policy)
                          │ CSRF Token / SameSite Cookie (request auth)
┌─────────────────────────▼───────────────────────────────────────────┐
│                          SERVER (Backend)                            │
│                                                                     │
│  [Re-validate ALL inputs] → [Parameterized queries] → [Rate limit]  │
│  [Generic error responses] → [Log everything internally]            │
│  [Hash passwords with Argon2/bcrypt + salt]                         │
│  [Re-authenticate for critical actions]                             │
└─────────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Quick Reference Checklist

Frontend

  • [ ] Inputs validated at the point of entry (format, type, length)
  • [ ] innerHTML replaced with textContent for untrusted content
  • [ ] DOMPurify used for any rich HTML input
  • [ ] Raw data stored in DB; sanitization applied at render time
  • [ ] CSP header configured with explicit script-src allowlist
  • [ ] Third-party scripts protected with SRI hashes

In Transit

  • [ ] HTTPS enforced across all endpoints
  • [ ] HSTS header set with adequate max-age
  • [ ] CORS configured with explicit origin allowlist — no wildcard with credentials
  • [ ] CSRF tokens on all state-changing forms
  • [ ] Session cookies use SameSite=Lax or Strict

Backend

  • [ ] All inputs re-validated server-side regardless of frontend validation
  • [ ] All DB queries use parameterized statements
  • [ ] Dynamic table/column names use an allowlist
  • [ ] Login and sensitive endpoints have rate limiting
  • [ ] Error responses return generic messages; stack traces logged internally
  • [ ] Passwords hashed with Argon2id or bcrypt — never stored encrypted
  • [ ] Re-authentication required for critical account actions

Closing Thought

Security is not a one-time audit. It is a continuous posture.

Every new endpoint is a new entry point. Every third-party library is a potential supply chain risk. Every developer who joins your team is either a security multiplier or a vulnerability waiting to happen.

The developers who build the most secure systems aren't the ones who memorize the OWASP Top 10. They're the ones who have internalized why each vulnerability exists — and that understanding makes them dangerous to attackers and invaluable to their teams.


Enjoyed this? Follow for more deep dives into backend engineering, system design, and the craft of building production-grade software.

Tags: #webdev #security #javascript #backend #devops #nodejs #typescript #programming

Top comments (0)