Language
日本語
English

Caution

JavaScript is disabled in your browser.
This site uses JavaScript for features such as search.
For the best experience, please enable JavaScript before browsing this site.

  1. Home
  2. Node.js Dictionary
  3. security

security

Node.js applications face multiple security risks including input validation, SQL injection, XSS, and vulnerabilities in dependent packages. This page explains representative attack methods and countermeasures with implementation examples.

Overview of Major Security Measures

MeasureDescription
Input validationVerify on the server side that user input is in the expected format and within acceptable ranges. Client-side validation alone cannot be trusted.
SQL injection preventionUse parameterized queries (placeholders) to separate SQL statements from data. Building queries through string concatenation is dangerous.
XSS preventionEscape user input before outputting it as HTML. Neutralize HTML tags such as <script>.
helmet middlewareAutomatically sets security-related HTTP headers. Prevents clickjacking, MIME sniffing, and more.
Rate limitingRestricts excessive requests from the same IP to mitigate brute force attacks and DoS.
npm auditDetects known vulnerabilities in installed packages. Regular execution is important.

Input Validation

Data submitted by users must always be verified on the server side for type, length, and format. The express-validator package is widely used for validation.

npm install express-validator
validation.js
var express = require('express');
var { body, validationResult } = require('express-validator');
var app = express();
app.use(express.json());

// Define validation rules
var validateFighter = [
    body('name')
        .isString().withMessage('Name must be a string')
        .trim()
        .notEmpty().withMessage('Name is required')
        .isLength({ max: 50 }).withMessage('Name must be 50 characters or fewer'),
    body('power')
        .isInt({ min: 1, max: 9000 }).withMessage('Power must be an integer between 1 and 9000'),
    body('email')
        .isEmail().withMessage('A valid email address is required')
        .normalizeEmail(),
];

app.post('/fighters', validateFighter, function(req, res) {
    // Retrieve validation results
    var errors = validationResult(req);
    if (!errors.isEmpty()) {
        // Return 400 if there are validation errors
        return res.status(400).json({ errors: errors.array() });
    }
    // Data is safe at this point
    res.json({
        message: 'Fighter registered',
        name: req.body.name,
        power: req.body.power,
    });
});

app.listen(3000, function() {
    console.log('Server started: http://localhost:3000/');
});
curl -X POST -H "Content-Type: application/json" \
  -d '{"name":"Son Goku","power":"over9000","email":"goku@kame.jp"}' \
  http://localhost:3000/fighters
{"errors":[{"type":"field","msg":"Power must be an integer between 1 and 9000","path":"power","location":"body"}]}
curl -X POST -H "Content-Type: application/json" \
  -d '{"name":"Son Goku","power":9000,"email":"goku@kame.jp"}' \
  http://localhost:3000/fighters
{"message":"Fighter registered","name":"Son Goku","power":9000}

SQL Injection Prevention

Embedding user input directly into SQL statements risks malicious SQL being injected. Use placeholders (parameterized queries) to separate SQL statements from data.

sql_injection.js
var mysql = require('mysql2/promise');

var pool = mysql.createPool({
    host: 'localhost',
    user: 'vegeta',
    password: 's4iy4jin_pr1de',
    database: 'dragon_ball',
});

// NG: Building a query through string concatenation (never do this)
var searchNG = async function(name) {
    // Passing "' OR '1'='1" as name returns all rows
    var sql = "SELECT * FROM fighters WHERE name = '" + name + "'";
    var [rows] = await pool.query(sql);
    return rows;
};

// OK: Separate data using a placeholder
var searchOK = async function(name) {
    // The ? is bound to an escaped value, making it safe
    var sql = 'SELECT * FROM fighters WHERE name = ?';
    var [rows] = await pool.query(sql, [name]);
    return rows;
};

// OK: Example with multiple parameters
var insertFighter = async function(name, power) {
    var sql = 'INSERT INTO fighters (name, power) VALUES (?, ?)';
    var [result] = await pool.query(sql, [name, power]);
    return result.insertId;
};

XSS (Cross-Site Scripting) Prevention

When outputting user input as HTML, escape it so that tags like <script> are not executed.

xss.js
// Function to escape HTML special characters
var escapeHtml = function(str) {
    return String(str)
        .replace(/&/g, '&')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, ''');
};

var express = require('express');
var app = express();
app.use(express.urlencoded({ extended: false }));

app.get('/comment', function(req, res) {
    var name = req.query.name || '';

    // NG: Embedding user input directly into HTML
    // var html = '<p>Hello, ' + name + '!</p>';

    // OK: Escape before embedding into HTML
    var safeName = escapeHtml(name);
    var html = '<!DOCTYPE html><html><body><p>Hello, ' + safeName + '!</p></body></html>';

    res.send(html);
});

app.listen(3000, function() {
    console.log('Server started: http://localhost:3000/');
});
node xss.js
Server started: http://localhost:3000/
curl "http://localhost:3000/comment?name=%3Cscript%3Ealert(1)%3C%2Fscript%3E"
<!DOCTYPE html><html><body><p>Hello, &lt;script&gt;alert(1)&lt;/script&gt;!</p></body></html>

Because the input was escaped, <script> is not interpreted as an HTML tag and is displayed as literal text.

helmet Middleware

helmet is middleware that sets a collection of security-related HTTP response headers. It prevents clickjacking, MIME sniffing, information leakage, and more.

npm install helmet
helmet_app.js
var express = require('express');
var helmet = require('helmet');
var app = express();

// Apply helmet with default settings (recommended settings are enabled all at once)
app.use(helmet());

app.get('/', function(req, res) {
    res.send('Security-hardened server — Freeza army repelled!');
});

app.listen(3000, function() {
    console.log('Server started: http://localhost:3000/');
});
node helmet_app.js
Server started: http://localhost:3000/
curl -I http://localhost:3000/
Content-Security-Policy: default-src 'self';...
Cross-Origin-Opener-Policy: same-origin
Referrer-Policy: no-referrer
Strict-Transport-Security: max-age=15552000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 0
HeaderEffect
X-Frame-Options: SAMEORIGINPrevents embedding in iframes on other sites, blocking clickjacking.
X-Content-Type-Options: nosniffDisables MIME type sniffing by the browser.
Strict-Transport-SecurityEnforces HTTPS connections and prevents access over HTTP (HSTS).
Content-Security-PolicyRestricts the origins from which resources may be loaded, reducing XSS risk.
Referrer-Policy: no-referrerPrevents the Referer header from being sent to linked destinations.

Rate Limiting

Rate limiting restricts requests when a large number arrive from the same IP address in a short time. It is effective against brute force attacks and DoS.

npm install express-rate-limit
rate_limit.js
var express = require('express');
var rateLimit = require('express-rate-limit');
var app = express();

// Global rate limit: maximum 100 requests per 15 minutes
var globalLimiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 100,
    message: { error: 'Too many requests. Please wait a moment before trying again.' },
    standardHeaders: true,  // Return RateLimit-* headers
    legacyHeaders: false,
});

// Stricter limit for the login endpoint: maximum 5 requests per 15 minutes
var loginLimiter = rateLimit({
    windowMs: 15 * 60 * 1000,
    max: 5,
    message: { error: 'Too many login attempts. Ask Piccolo for some training.' },
});

app.use(globalLimiter);

app.post('/login', loginLimiter, function(req, res) {
    res.json({ message: 'Processing login...' });
});

app.get('/api/data', function(req, res) {
    res.json({ data: ['Son Goku', 'Vegeta', 'Trunks', 'Piccolo', 'Freeza'] });
});

app.listen(3000, function() {
    console.log('Server started: http://localhost:3000/');
});
node rate_limit.js
Server started: http://localhost:3000/

Vulnerability Checking with npm audit

npm audit checks installed packages against the npm registry database for known vulnerabilities and generates a report.

Command line
npm audit
found 2 vulnerabilities (1 moderate, 1 high)
Run `npm audit fix` to fix them, or `npm audit fix --force` to fix all

npm audit fix
fixed 1 of 2 vulnerabilities in 423 packages

npm audit fix --force
fixed 2 of 2 vulnerabilities (note: 1 breaking change)
CommandDescription
npm auditDetects vulnerabilities and displays a report. Does not apply fixes.
npm audit fixAutomatically fixes vulnerabilities that can be resolved with backward-compatible updates.
npm audit fix --forceAlso applies updates that include breaking changes. Requires verification of behavior.
npm audit --jsonOutputs the report in JSON format. Useful when processing the report with scripts.

Run npm audit regularly and address vulnerabilities with a severity of high or above promptly.

Summary

Node.js application security is protected at multiple layers. Use express-validator for input validation to verify type, length, and format on the server side. For SQL injection prevention, parameterized queries (placeholders) are the only safe approach; building queries through string concatenation is always dangerous.

Escape user input before outputting it as HTML to prevent XSS. Adding the helmet middleware enables clickjacking protection, MIME sniffing prevention, HSTS, and more in a single step. express-rate-limit also strengthens resilience against brute force attacks.

Check dependent package vulnerabilities regularly with npm audit. Integrating it into a CI/CD pipeline for automatic detection is a common approach. Security is not a one-time setup; continuous updates and checks are essential.

Common Mistakes

Common Mistake 1: Implementing escapeHtml manually and missing escape characters

Writing your own HTML escaping function can leave certain special characters unhandled.

escape_ng.js (NG)
// NG: Only & < > are escaped (" and ' are missing)
var escapeHtml = function(str) {
    return String(str)
        .replace(/&/g, '&')
        .replace(//g, '>');
};

// A " in an attribute value closes the attribute prematurely
var name = req.query.name; // input: goku" onmouseover="alert(1)
var html = '<input value="' + escapeHtml(name) + '">';
console.log(html);
node escape_ng.js
<input value="goku" onmouseover="alert(1)">

Because " is not escaped, the attribute value is closed prematurely.

escape_ok.js (OK)
// OK: Escape all HTML special characters (& < > " ')
var escapeHtml = function(str) {
    return String(str)
        .replace(/&/g, '&')
        .replace(//g, '>')
        .replace(/"/g, '"')
        .replace(/'/g, ''');
};

var name = req.query.name; // input: goku" onmouseover="alert(1)
var html = '<input value="' + escapeHtml(name) + '">';
console.log(html);
node escape_ok.js
<input value="goku" onmouseover="alert(1)">

An escape function for security purposes must cover all five: &, <, >, ", and '. Custom implementations are prone to omissions, so using a well-established library is also worth considering.

Common Mistake 2: helmet is configured but CSP is too strict and blocks your own scripts

With helmet's default settings, CSP (Content Security Policy — a security policy that restricts where scripts, images, and other resources may be loaded from) blocks inline scripts and scripts from external domains. This can cause your own site's scripts to stop working.

helmet_csp.js
var express = require('express');
var helmet = require('helmet');
var app = express();

// The default helmet() blocks inline scripts
// Explicitly set scriptSrc to allow scripts from your own site
app.use(helmet({
    contentSecurityPolicy: {
        directives: {
            defaultSrc: ["'self'"],
            scriptSrc: ["'self'"],
            styleSrc: ["'self'", "'unsafe-inline'"],
            imgSrc: ["'self'", 'data:'],
        },
    },
}));

app.get('/', function(req, res) {
    res.send('CSP-configured server — Freeza army repelled!');
});

app.listen(3000, function() {
    console.log('Server started: http://localhost:3000/');
});

scriptSrc: ["'self'"] allows scripts only from the same origin (your own site). If you use scripts from a CDN, add that domain as well. Note that "'unsafe-inline'" allows inline scripts but increases XSS risk, so keep its use to a minimum.

Common Mistake 3: Missing X-Forwarded-For configuration when using rate limiting behind a reverse proxy

In configurations where a reverse proxy such as Nginx sits in front of Express, the client's IP address is passed via the X-Forwarded-For header. Without setting app.set('trust proxy', 1), express-rate-limit only sees the proxy's IP, so all client requests are treated as coming from the same IP.

rate_limit_proxy.js
var express = require('express');
var rateLimit = require('express-rate-limit');
var app = express();

// Trust the reverse proxy (assuming one proxy layer)
// Without this, only the proxy IP is visible and all clients share the same IP
app.set('trust proxy', 1);

var limiter = rateLimit({
    windowMs: 15 * 60 * 1000,
    max: 100,
    message: { error: 'Too many requests.' },
});

app.use(limiter);

app.get('/', function(req, res) {
    res.json({ clientIp: req.ip });
});

app.listen(3000, function() {
    console.log('Server started: http://localhost:3000/');
});

Adding app.set('trust proxy', 1) causes Express to obtain the actual client IP from the X-Forwarded-For header. If there are multiple proxy layers, adjust the number accordingly (for example, 2 for two layers).

If you find any errors or copyright issues, please .