Safeguard
Security

JavaScript Security Best Practices Every Team Should Actually Follow

Most JavaScript security incidents come from a handful of repeated mistakes. These are the best practices that prevent them, from XSS to the npm dependency graph.

Priya Mehta
Security Analyst
5 min read

The most effective JavaScript security best practices target three areas: how you render untrusted data, how you handle the npm dependency graph, and how you configure the browser's own defenses. Get those right and you eliminate the majority of real-world JavaScript incidents. Everything else is refinement. This guide walks through each area with the specific settings and patterns that matter.

Treat all rendered data as hostile

Cross-site scripting remains the most common JavaScript vulnerability, and it comes from one root cause: putting data into the DOM in a way the browser interprets as code. The single most dangerous API is innerHTML. Assigning a user-controlled string to it lets an attacker inject script.

// Dangerous: interprets input as HTML
element.innerHTML = userInput;

// Safe: treats input as text
element.textContent = userInput;

In frameworks, the equivalent escape hatches are React's dangerouslySetInnerHTML, Angular's bypassSecurityTrust* methods, and Vue's v-html. Every one of them turns off the framework's built-in escaping. Grep your codebase for these and justify each occurrence. When you genuinely must render user HTML (a rich-text comment, say), sanitize it first with a maintained library like DOMPurify rather than a homemade regex:

import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(userInput);

Deploy a Content Security Policy

A Content Security Policy is the browser-level backstop that limits the damage when an XSS bug does slip through. It tells the browser which script sources are allowed, so an injected inline script simply does not run. Start restrictive:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'

Avoid unsafe-inline and unsafe-eval in script-src; they defeat most of the point. If you need inline scripts, use nonces or hashes instead. Roll it out in report-only mode first (Content-Security-Policy-Report-Only) so you can see what would break before you enforce it.

Lock down the npm dependency graph

A modern JavaScript app ships thousands of transitive packages, and that graph is the largest part of your attack surface. Several practices keep it under control.

Commit your package-lock.json and install with npm ci in CI, not npm install. npm ci installs exactly the locked versions and fails if the lockfile and package.json disagree, which makes builds reproducible and blocks silent version drift.

Audit regularly, but treat the output critically:

npm audit --audit-level=high

npm audit reports known advisories against your resolved tree. It over-reports for dev-only and unreachable dependencies, so pair it with a tool that understands reachability. A dedicated SCA tool can tell you whether a flagged package is actually called at runtime, which cuts the noise dramatically.

Be deliberate about postinstall scripts. A malicious package can run arbitrary code during install. For CI, consider npm ci --ignore-scripts where the build allows it, and review any dependency that requires install scripts to function.

Guard against prototype pollution

Prototype pollution is a JavaScript-specific class where an attacker sets properties on Object.prototype, affecting every object in the runtime. It typically enters through a deep-merge or object-assignment function that trusts attacker-controlled keys like __proto__. The defenses:

  • Use Object.create(null) for maps that hold user-controlled keys, so there is no prototype to pollute.
  • Validate and reject keys named __proto__, constructor, and prototype in any recursive merge.
  • Prefer Map over plain objects for key-value stores built from user input.

Several widely used utility libraries have shipped prototype pollution fixes over the years, which is another reason to keep dependencies current.

Handle secrets, tokens, and cookies correctly

Never put API keys or secrets in client-side JavaScript. Anything shipped to the browser is public, no matter how it is minified. Secrets belong on the server.

For session tokens, set cookies with HttpOnly so JavaScript cannot read them (which contains XSS damage), Secure so they only travel over HTTPS, and SameSite=Lax or Strict to blunt CSRF:

Set-Cookie: session=...; HttpOnly; Secure; SameSite=Strict; Path=/

If you store tokens in localStorage, understand the trade-off: it is readable by any script on the page, so a single XSS bug exfiltrates the token. HttpOnly cookies are the safer default for session state.

Validate input on the server, always

Client-side validation is a UX feature, not a security control, because anyone can bypass it by calling your API directly. Every rule you enforce in the browser must be re-enforced on the server. Validate types, lengths, and formats server-side, and use parameterized queries for any database access so input can never be interpreted as a command.

Keep the runtime and tooling patched

Node.js ships security releases regularly; run a supported LTS line and apply patch releases promptly. Pin your Node version in CI and in your engines field so production and development match. An outdated runtime undoes careful application-level work.

FAQ

What is the single most important JavaScript security practice?

Never render untrusted data as HTML. Use textContent instead of innerHTML, rely on your framework's default escaping, and sanitize with a maintained library when you truly must render user HTML. This one habit prevents the majority of cross-site scripting bugs.

Is localStorage safe for storing auth tokens?

It is convenient but risky, because any JavaScript running on the page can read it, so a single XSS bug leaks the token. HttpOnly cookies are the safer default because scripts cannot access them.

Does npm audit catch everything?

No. npm audit reports known advisories against your dependency tree but over-reports issues in dev-only or unreachable code and misses vulnerabilities without a published advisory. Pair it with a reachability-aware software composition analysis tool for a clearer signal.

How do I prevent prototype pollution?

Reject dangerous keys like __proto__ and constructor in any recursive merge, use Object.create(null) or Map for user-controlled key-value data, and keep utility libraries updated since many have shipped fixes for this class.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.