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:
- Identifying entry points — Where does untrusted data enter your system? Form inputs, URL parameters, uploaded files, third-party APIs?
- Assessing potential impact — If this entry point is exploited, what can an attacker access or do?
- 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). │
└──────────────────┴──────────────────────────────────────────────┘
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" });
}
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
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 <
> becomes >
& becomes &
" becomes "
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;
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:
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>
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
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
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
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
}));
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" />
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>
Defense 2 — SameSite Cookies
Set-Cookie: session=abc123; SameSite=Lax; Secure; HttpOnly
-
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— RequiresSecure. 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 │
└─────────────────────────────────────────────────────────────┘
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.
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]);
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']);
}
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);
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.' });
});
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);
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:
- Rainbow table attacks — Precomputed tables of common password hashes become useless because the salt is unique per user.
- 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] │
└─────────────────────────────────────────────────────────────────────┘
Quick Reference Checklist
Frontend
- [ ] Inputs validated at the point of entry (format, type, length)
- [ ]
innerHTMLreplaced withtextContentfor untrusted content - [ ]
DOMPurifyused for any rich HTML input - [ ] Raw data stored in DB; sanitization applied at render time
- [ ] CSP header configured with explicit
script-srcallowlist - [ ] 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=LaxorStrict
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)