Secure Code Review Checklist for Node.js Applications | Lorikeet Security Skip to main content
Back to Blog

Secure Code Review Checklist for Node.js Applications

Lorikeet Security Team February 26, 2026 11 min read

Node.js powers a massive portion of the modern web. From Express APIs to serverless Lambda functions to full-stack Next.js applications, JavaScript on the server is everywhere. And with that ubiquity comes a specific set of security vulnerabilities that generic code scanners consistently miss.

When we conduct secure code reviews for Node.js applications, we're not just running a linter. We're manually tracing data flows, examining trust boundaries, and looking for patterns that lead to remote code execution, data exfiltration, and privilege escalation. Here's our checklist.


Prototype Pollution

Prototype pollution is one of the most dangerous and most misunderstood vulnerabilities in the JavaScript ecosystem. It occurs when an attacker can modify Object.prototype through user-controlled input, which then affects every object in the application.

The attack surface is any function that recursively merges or clones objects from user input. Libraries like lodash.merge, deepmerge, and hand-rolled deep merge functions are common vectors.

Vulnerable: Deep merge without prototype check
function deepMerge(target, source) {
    for (const key in source) {
        if (typeof source[key] === 'object') {
            if (!target[key]) target[key] = {};
            deepMerge(target[key], source[key]);
        } else {
            target[key] = source[key];
        }
    }
    return target;
}

// Attacker sends: {"__proto__": {"isAdmin": true}}
deepMerge({}, userInput);
// Now EVERY object has isAdmin === true
Secure: Validate keys before merging
function safeMerge(target, source) {
    for (const key of Object.keys(source)) {
        if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
            continue; // Skip dangerous keys
        }
        if (typeof source[key] === 'object' && source[key] !== null) {
            if (!target[key]) target[key] = {};
            safeMerge(target[key], source[key]);
        } else {
            target[key] = source[key];
        }
    }
    return target;
}

// Or better: use Object.create(null) for untrusted data
const safeObj = Object.create(null);
Object.assign(safeObj, userInput);

In our top code review findings, prototype pollution appears in roughly one out of every four Node.js assessments. The impact ranges from authentication bypass (polluting isAdmin or role properties) to full remote code execution when combined with template engines or child process spawning.


NoSQL Injection

SQL injection gets all the headlines, but Node.js applications frequently use MongoDB, and MongoDB queries are built from JavaScript objects. When user input is passed directly into a query without type validation, attackers can inject query operators.

Vulnerable: Direct user input in MongoDB query
// POST /login with body: {"username": "admin", "password": {"$ne": ""}}
app.post('/login', async (req, res) => {
    const user = await db.collection('users').findOne({
        username: req.body.username,
        password: req.body.password  // This accepts objects!
    });
    if (user) {
        // Attacker is now authenticated as admin
        req.session.user = user;
    }
});
Secure: Type-check inputs and use sanitization
const mongo = require('mongo-sanitize');

app.post('/login', async (req, res) => {
    // Ensure inputs are strings, not objects
    const username = String(req.body.username || '');
    const password = String(req.body.password || '');

    // Or use mongo-sanitize to strip $ operators
    const user = await db.collection('users').findOne({
        username: mongo.sanitize(username),
        password: mongo.sanitize(password)
    });
    // Better yet: hash the password and compare hashes
});

The core issue is that Express (with express.json() middleware) will parse JSON objects, arrays, and nested structures from request bodies. When your code expects a string but receives {"$gt": ""}, MongoDB interprets it as a query operator. This bypasses authentication, exfiltrates data, and in some cases allows full database enumeration.


Command Injection via child_process

The child_process module is the most direct path to remote code execution in Node.js. Any time user input reaches exec(), execSync(), or the shell option of spawn(), the application is vulnerable to command injection.

Vulnerable: User input in exec()
const { exec } = require('child_process');

app.get('/lookup', (req, res) => {
    const domain = req.query.domain;
    // Attacker sends: domain=example.com; cat /etc/passwd
    exec(`nslookup ${domain}`, (error, stdout) => {
        res.send(stdout);
    });
});
Secure: Use execFile() with argument arrays
const { execFile } = require('child_process');

app.get('/lookup', (req, res) => {
    const domain = req.query.domain;

    // Validate input format
    if (!/^[a-zA-Z0-9.-]+$/.test(domain)) {
        return res.status(400).send('Invalid domain');
    }

    // execFile does NOT use a shell - arguments are passed directly
    execFile('nslookup', [domain], (error, stdout) => {
        res.send(stdout);
    });
});

We flag every instance of exec(), execSync(), spawn(cmd, { shell: true }), and backtick template usage with child_process functions. The fix is always the same: use execFile() or spawn() without the shell option, pass arguments as arrays, and validate input against a strict allowlist.


Insecure Deserialization

Node.js applications that use node-serialize, serialize-javascript, or custom serialization for session data, caching, or inter-service communication can be vulnerable to deserialization attacks that lead to remote code execution.

Vulnerable: Deserializing untrusted data
const serialize = require('node-serialize');

app.get('/profile', (req, res) => {
    // Cookie contains serialized user object
    const cookie = Buffer.from(req.cookies.profile, 'base64').toString();
    // This can execute arbitrary code via IIFE in serialized data
    const user = serialize.unserialize(cookie);
    res.render('profile', { user });
});

// Attacker crafts cookie with:
// {"rce":"_$$ND_FUNC$$_function(){require('child_process').exec('...')}()"}
Secure: Use JSON.parse() with validation
app.get('/profile', (req, res) => {
    try {
        const cookie = Buffer.from(req.cookies.profile, 'base64').toString();
        const user = JSON.parse(cookie); // No code execution possible

        // Validate the shape of the data
        if (typeof user.name !== 'string' || typeof user.id !== 'number') {
            throw new Error('Invalid profile data');
        }

        res.render('profile', { user });
    } catch (e) {
        res.status(400).send('Invalid profile');
    }
});

Rule of thumb: Never use a deserialization library that can reconstruct functions or class instances from untrusted input. Stick to JSON.parse() for untrusted data, and validate the shape and types of the resulting object before using it.


Path Traversal

Path traversal vulnerabilities allow attackers to read or write files outside the intended directory. In Node.js, this commonly appears in file upload handlers, static file servers, and template rendering logic where user input is used to construct file paths.

Vulnerable: Unvalidated file path construction
const path = require('path');
const fs = require('fs');

app.get('/download', (req, res) => {
    const filename = req.query.file;
    // Attacker sends: file=../../../etc/passwd
    const filePath = path.join(__dirname, 'uploads', filename);
    res.sendFile(filePath);
});
Secure: Resolve and validate the final path
const path = require('path');

app.get('/download', (req, res) => {
    const filename = req.query.file;
    const uploadsDir = path.resolve(__dirname, 'uploads');
    const filePath = path.resolve(uploadsDir, filename);

    // Ensure the resolved path is still within the uploads directory
    if (!filePath.startsWith(uploadsDir + path.sep)) {
        return res.status(403).send('Access denied');
    }

    res.sendFile(filePath);
});

Note that path.join() does not prevent traversal. It happily resolves ../ sequences. You must use path.resolve() and then verify the resulting absolute path is within the expected directory. This is one of the most common findings in our code review assessments.


Event Loop Blocking and ReDoS

Node.js runs on a single-threaded event loop. Any operation that blocks that loop freezes the entire application. This is a denial-of-service vulnerability that is unique to Node.js and often overlooked in security reviews.

Regular Expression Denial of Service (ReDoS)

Poorly written regular expressions with nested quantifiers can cause catastrophic backtracking when matched against crafted input. A single malicious request can lock the event loop for minutes or hours.

Vulnerable: Regex with catastrophic backtracking
// This regex has nested quantifiers - O(2^n) complexity
const emailRegex = /^([a-zA-Z0-9]+\.)+[a-zA-Z]{2,}$/;

app.post('/subscribe', (req, res) => {
    // Attacker sends: "aaaaaaaaaaaaaaaaaaaaaaaaaaaa!"
    if (emailRegex.test(req.body.email)) {
        // Event loop is now blocked for minutes
    }
});
Secure: Use validated libraries and timeouts
const validator = require('validator');

app.post('/subscribe', (req, res) => {
    // Use a well-tested validation library instead of custom regex
    if (!validator.isEmail(req.body.email)) {
        return res.status(400).json({ error: 'Invalid email' });
    }
    // Process subscription
});

Synchronous Operations

We also flag any use of synchronous file system operations (fs.readFileSync, fs.writeFileSync) in request handlers, large JSON.parse() calls without size limits, and CPU-intensive operations that should be offloaded to worker threads.


Dependency Supply Chain Vulnerabilities

The average Node.js application has hundreds of dependencies. The node_modules folder is not a joke. It's an attack surface. Supply chain attacks targeting npm packages are increasing in both frequency and sophistication.

During a code review, we examine:

Real-world example: In 2024, the xz-utils backdoor demonstrated how a patient attacker can compromise a widely-used package by becoming a trusted maintainer over years. The Node.js ecosystem, with its culture of micro-dependencies, is especially vulnerable to this attack pattern.


Additional Checks: The Full Checklist

Beyond the critical vulnerabilities above, a thorough Node.js code review also examines:

Category What We Check
Authentication JWT implementation (algorithm confusion, none algorithm, key management), session handling, password storage (bcrypt cost factor), OAuth flows
Authorization IDOR via predictable IDs, missing middleware on routes, horizontal/vertical privilege escalation, GraphQL authorization per field
Input Validation Schema validation on all endpoints (Joi, Zod, ajv), Content-Type enforcement, request size limits, file upload validation
Error Handling Stack traces exposed in production, unhandled promise rejections, error messages leaking internal paths or database details
Secrets Management Hardcoded API keys, secrets in environment variables vs. vault, .env files in version control, secrets in client-side bundles
HTTP Security CORS configuration, security headers (helmet.js), cookie flags (httpOnly, secure, sameSite), CSRF protection
Logging Sensitive data in logs (passwords, tokens, PII), log injection, insufficient audit logging for security events

Why Automated Tools Are Not Enough

Static analysis tools like ESLint security plugins, Semgrep, and Snyk Code are valuable for catching known patterns. But they have fundamental limitations when it comes to Node.js security:

A manual secure code review combines automated tooling with human expertise. We run the tools, but we also read the code, understand the business context, and find the vulnerabilities that matter.

Ship Node.js with confidence

Our security engineers specialize in Node.js and JavaScript ecosystem security. Get a thorough code review that finds what automated tools miss.

-- views
Link copied!
Lorikeet Security

Lorikeet Security Team

Penetration Testing & Cybersecurity Consulting

We've completed 170+ security engagements across web apps, APIs, cloud infrastructure, and AI-generated codebases. Everything we publish here comes from patterns we see in real client work.

Lory waving

Hi, I'm Lory! Need help finding the right service? Click to chat!