Skip to content

CWE-295: Improper Certificate Validation - JavaScript/Node.js

Overview

Improper certificate validation in JavaScript/Node.js applications opens the way to man-in-the-middle (MITM) attacks, where an attacker intercepts and modifies HTTPS communications. It happens when an application disables TLS/SSL certificate verification, fails to validate certificate chains, skips hostname matching, or accepts expired and self-signed certificates in production.

Key Security Issues:

  • Encrypted communications intercepted on the network
  • Login credentials, API keys, and tokens exposed in transit
  • Authentication sessions stolen and replayed
  • Sensitive data extracted from supposedly encrypted channels
  • Internal and external API calls open to interception

Common Node.js/JavaScript Scenarios:

  • Node.js applications using https module with rejectUnauthorized: false
  • Express/Fastify apps making internal API calls with disabled validation
  • axios/node-fetch clients configured to bypass certificate checks
  • WebSocket/Socket.io connections without TLS validation
  • AWS SDK and cloud service clients with custom HTTPS agents
  • Browser applications making fetch() calls (validation handled by browser)
  • Development configurations with disabled validation leaking to production

Why this matters in JavaScript:

  • Node.js provides low-level TLS control allowing dangerous bypasses
  • Many HTTP client libraries default to secure settings, but allow easy overrides
  • Development environments often use self-signed certificates, leading to bad practices
  • Microservices architectures with internal CAs require careful configuration
  • Browser security model differs from Node.js requiring different approaches

Primary Defence: Do not set rejectUnauthorized: false in application code; instead, use Node's default CA configuration or proper CA certificates for internal services. When using the ca option, Node replaces the default CA list for that connection unless you explicitly include the required defaults - NODE_EXTRA_CA_CERTS adds to it rather than replacing it, and is usually what a corporate CA needs. Check every client the process creates, not only the one the finding named: https.Agent covers the https module, axios and node-fetch, but built-in fetch() runs on undici and takes its TLS options somewhere else entirely.

Common Vulnerable Patterns

Node.js https Module with rejectUnauthorized: false

const https = require('https');

// Completely disables certificate validation
const options = {
  hostname: 'api.example.com',
  port: 443,
  path: '/data',
  method: 'GET',
  rejectUnauthorized: false  // DANGEROUS: Allows any certificate
};

https.request(options, (res) => {
  let data = '';
  res.on('data', (chunk) => { data += chunk; });
  res.on('end', () => console.log(data));
}).end();

Why this is vulnerable: Setting rejectUnauthorized: false disables TLS certificate validation, so anyone positioned between the client and the server can present any certificate - self-signed, expired, or attacker-controlled - and read or modify the traffic in both directions.

axios with Custom Agent Disabling Validation

const axios = require('axios');
const https = require('https');

// Create axios client with disabled validation
const api = axios.create({
  httpsAgent: new https.Agent({
    rejectUnauthorized: false  // DANGEROUS: Bypasses all validation
  })
});

// All requests with this client are vulnerable
async function fetchUserData(userId) {
  const response = await api.get(`https://api.example.com/users/${userId}`);
  return response.data;
}

Why this is vulnerable: An httpsAgent with rejectUnauthorized: false applies to every request made through that client instance, and nothing at the individual call site shows that validation is off, so the setting survives into production unnoticed.

node-fetch with Agent Disabling Validation

const fetch = require('node-fetch');
const https = require('https');

const agent = new https.Agent({
  rejectUnauthorized: false  // DANGEROUS: No certificate checks
});

async function getData() {
  const response = await fetch('https://api.example.com/data', {
    agent: agent  // Vulnerable agent used
  });
  return await response.json();
}

Why this is vulnerable: An HTTPS agent with rejectUnauthorized: false lets an interceptor rewrite the JSON the application then acts on, and the agent can be reused across requests, so one bad agent spreads the weakness to every call site that shares it.

tls.connect() Without Proper Validation

const tls = require('tls');

// Direct TLS connection without validation
const socket = tls.connect(443, 'api.example.com', {
  rejectUnauthorized: false,  // DANGEROUS
  checkServerIdentity: () => undefined  // DANGEROUS: Disables hostname check
}, () => {
  socket.write('GET / HTTP/1.1\r\nHost: api.example.com\r\n\r\n');
});

socket.on('data', (data) => {
  console.log(data.toString());
});

Why this is vulnerable: Disabling both rejectUnauthorized and checkServerIdentity in tls.connect() removes all certificate validation including hostname verification, allowing attackers to intercept direct socket connections and present fraudulent certificates without detection.

Environment Variable Controlled Validation

const https = require('https');

// Validation controlled by environment variable
const strictSSL = process.env.VERIFY_SSL !== 'false';

function makeRequest(url) {
  return https.get(url, {
    rejectUnauthorized: strictSSL  // DANGEROUS if env var set
  });
}

Why this is vulnerable:

  • Production systems may have VERIFY_SSL=false set during troubleshooting
  • Environment variables can be modified by attackers with access
  • No code review catches runtime configuration

Node has a built-in version of this same problem: setting NODE_TLS_REJECT_UNAUTHORIZED=0 disables certificate verification for every TLS connection the process makes, including ones inside dependencies you did not write. It requires no code change, so it appears in a Dockerfile, a CI job, or a shell profile rather than in a diff, and Node's only signal is a one-line warning on stderr that scrolls past in normal log output. Treat its presence as a production incident: grep deployment manifests and CI configuration for it, not just application source.

Request Library with strictSSL: false (Deprecated but Common)

const request = require('request');  // Note: request is deprecated

request({
  url: 'https://api.example.com/data',
  strictSSL: false  // DANGEROUS: Legacy syntax but still used
}, (error, response, body) => {
  console.log(body);
});

Why this is vulnerable:

  • The request library is deprecated but still widely deployed in production
  • Migration to modern libraries may preserve the vulnerability

Express App with Disabled Validation for Internal Calls

const express = require('express');
const axios = require('axios');
const https = require('https');

const app = express();

// Internal service client with disabled validation
const internalApi = axios.create({
  baseURL: 'https://internal-service.local',
  httpsAgent: new https.Agent({
    rejectUnauthorized: false  // DANGEROUS even for internal services
  })
});

app.get('/user/:id', async (req, res) => {
  // Vulnerable to MITM on internal network
  const user = await internalApi.get(`/users/${req.params.id}`);
  res.json(user.data);
});

Why this is vulnerable:

  • "Internal only" doesn't mean "trusted network"
  • Insider threats and network compromises still possible
  • Microservices should use mutual TLS, not disabled validation

Global fetch and undici Dispatchers

const { setGlobalDispatcher, Agent } = require('undici');

// VULNERABLE - disables validation for EVERY fetch() in the process
setGlobalDispatcher(new Agent({
  connect: { rejectUnauthorized: false }
}));

const response = await fetch('https://api.example.com/data');

Why this is vulnerable: fetch() has been built into Node since 18, and it does not go through https.Agent - so none of the httpsAgent guidance above reaches it and neither does an audit that inspects axios' agents. Its TLS options live on an undici Agent under connect, and setGlobalDispatcher installs one for the whole process: a single line at the top of a bootstrap file silently disables validation for every fetch() call anywhere in the application, including inside dependencies. Measured on Node 24: fetch() to a self-signed endpoint fails with DEPTH_ZERO_SELF_SIGNED_CERT, and returns 200 after this call. The per-request form, fetch(url, { dispatcher: new Agent({ connect: { rejectUnauthorized: false } }) }), is the same weakness scoped to one call site. Search for setGlobalDispatcher and for rejectUnauthorized under a connect key, not only for https.Agent.

WebSocket/Socket.io with Disabled TLS Validation

const io = require('socket.io-client');

// WebSocket connection without certificate validation
const socket = io('https://realtime.example.com', {
  rejectUnauthorized: false,  // DANGEROUS
  transports: ['websocket']
});

socket.on('data', (data) => {
  console.log('Received:', data);
});

Why this is vulnerable:

  • Long-lived WebSocket connections provide extended attack window
  • Real-time data streams exposed to interception
  • Bidirectional communication allows injection attacks

Secure Patterns

Node.js https Module with Default Validation

const https = require('https');

// Uses default secure validation - no options needed
const options = {
  hostname: 'api.example.com',
  port: 443,
  path: '/data',
  method: 'GET'
  // rejectUnauthorized defaults to true
  // Node's own bundled Mozilla root store is used - not the OS trust store
};

https.request(options, (res) => {
  let data = '';
  res.on('data', (chunk) => { data += chunk; });
  res.on('end', () => {
    console.log('Secure response received');
    console.log(data);
  });
}).on('error', (err) => {
  console.error('HTTPS request failed:', err.message);
  // Fails fast if certificate validation fails
}).end();

Why this works:

  • Default rejectUnauthorized: true enforces validation
  • Certificate chain verified, hostname matched, expired certificates rejected
  • The default trust store is Node's own bundled copy of the Mozilla root list, not the operating system's. This catches people out on exactly the case this CWE is about: a CA installed into the Windows or macOS trust store, or via update-ca-certificates, is not picked up by default, so an internal endpoint that works in a browser and in curl fails here - and "just disable validation" is the usual next step. Measured on Node 24.3: tls.getCACertificates('default') returns 150 certificates and is identical to tls.getCACertificates('bundled'), while tls.getCACertificates('system') returns a different, host-dependent set - 168 on the Windows machine this was checked on, and a number that moves as the OS trust store is updated. Use NODE_EXTRA_CA_CERTS to add the internal CA. --use-system-ca brings in the OS store as well, merging rather than replacing, so the default grows by the size of the system store - but check it against your own Node version before relying on it: it was added in v23.8.0, gained non-Windows/non-macOS support in v23.9.0, and the NODE_USE_SYSTEM_CA environment variable arrived later still. tls.getCACertificates('default').length answers the question in one line on whatever version you actually run.

axios with Proper Configuration

const axios = require('axios');
const https = require('https');

// Default axios instance uses secure settings
const api = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 5000
  // No httpsAgent needed - defaults are secure
});

async function fetchUserData(userId) {
  try {
    const response = await api.get(`/users/${userId}`);
    return response.data;
  } catch (error) {
    if (error.code === 'CERT_HAS_EXPIRED') {
      console.error('Certificate validation failed:', error.message);
      // Handle certificate errors appropriately
    }
    throw error;
  }
}

Why this works:

  • No custom agent = default secure validation
  • Certificate errors properly caught and logged
  • Fails safely if validation fails

Custom CA Bundle for Internal Services

const https = require('https');
const fs = require('fs');
const path = require('path');

// Load custom CA certificate for internal corporate CA
const ca = fs.readFileSync(path.join(__dirname, 'certs', 'internal-ca.pem'));

const options = {
  hostname: 'internal-api.corp.local',
  port: 443,
  path: '/data',
  method: 'GET',
  ca: ca  // Custom CA bundle
  // rejectUnauthorized still true by default
};

https.request(options, (res) => {
  console.log('Validated with internal CA');
  // Process response
}).end();

Why this works:

  • Validation still enforced with internal CA
  • Certificate chain verified against trusted CA
  • Hostname matching still enforced
  • Better than disabling validation
  • The ca option replaces Node's default CA list for this connection, so use it for dedicated internal clients or include all CAs that client needs

axios with Custom CA for Internal Services

const axios = require('axios');
const https = require('https');
const fs = require('fs');

// Load internal CA certificate
const internalCA = fs.readFileSync('./certs/internal-ca.pem');

const internalApi = axios.create({
  baseURL: 'https://internal-service.corp.local',
  httpsAgent: new https.Agent({
    ca: internalCA
    // rejectUnauthorized defaults to true
  })
});

async function getInternalData() {
  const response = await internalApi.get('/data');
  return response.data;
}

Why this works:

  • Custom CA supports internal infrastructure
  • Validation still fully enforced
  • Certificate chain verified
  • Hostname matching enforced
  • The custom ca should be limited to clients that call those internal services, or combined with required default roots when a client also calls public endpoints

Adding an Internal CA Without Replacing the Defaults

# Adds the internal CA on top of Node's bundled roots, for the whole process
NODE_EXTRA_CA_CERTS=/etc/ssl/certs/internal-ca.pem node server.js

Why this works: The ca option in the two patterns above replaces Node's root list for that connection rather than adding to it, which is why each of them ends with a caveat about clients that also call public endpoints. NODE_EXTRA_CA_CERTS is the additive form: Node keeps its bundled roots and appends the certificates in the named file. Measured on Node 24 with a self-signed server: with the variable set, fetch() to the internal endpoint returns 200 and a request to a public site still returns 200; the ca option would have broken the second.

This also reaches clients you cannot configure - fetch()/undici, transitive dependencies, and anything that builds its own agent - which is what makes it the right tool for a corporate CA that every service needs. It is not a substitute for the ca option where you want a client to trust only an internal CA; that narrowing is a deliberate property worth keeping for a dedicated internal client. Node reads the variable once at startup, so it belongs in the process definition, not in code, and pointing it at a missing file is silently ignored - confirm it took effect by making one request rather than assuming.

Certificate Pinning for High Security

const https = require('https');
const tls = require('tls');
const crypto = require('crypto');

// Expected SHA-256 hashes of the server's SubjectPublicKeyInfo (SPKI), base64.
// Always carry at least one backup pin for the next key, or rotation locks you out.
// Produce these with:
//   openssl x509 -in server.crt -pubkey -noout |
//     openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64
const PINNED_SPKI_SHA256 = [
  'base64-encoded-spki-sha256-current',
  'base64-encoded-spki-sha256-backup'
];

function spkiPin(rawCert) {
  // Derive the SPKI from the certificate itself. Do NOT hash `cert.pubkey`:
  // for RSA keys it happens to equal the SPKI, but for EC keys it is the raw
  // 65-byte curve point, so the pin silently never matches an openssl-produced
  // value. X509Certificate gives the real SPKI for every key type.
  const spki = new crypto.X509Certificate(rawCert)
    .publicKey.export({ type: 'spki', format: 'der' });
  return crypto.createHash('sha256').update(spki).digest('base64');
}

function makeSecureRequest(hostname, path) {
  return new Promise((resolve, reject) => {
    const options = {
      hostname: hostname,
      port: 443,
      path: path,
      method: 'GET',
      checkServerIdentity: (host, cert) => {
        // Standard validation first: chain, expiry and hostname must all pass.
        const err = tls.checkServerIdentity(host, cert);
        if (err) return err;

        // Then enforce the pin. Returning an Error aborts the handshake.
        if (!PINNED_SPKI_SHA256.includes(spkiPin(cert.raw))) {
          return new Error('Pinned public key mismatch');
        }
      }
    };

    https.request(options, (res) => {
      let data = '';
      res.on('data', (chunk) => { data += chunk; });
      res.on('end', () => resolve(data));
    }).on('error', reject).end();
  });
}

Why this works:

  • tls.checkServerIdentity() runs first, so the pin is an extra control on top of chain, expiry and hostname validation rather than a replacement for it.
  • Returning an Error from checkServerIdentity aborts the handshake, so a mismatch fails closed.
  • The pin is computed over the certificate's SubjectPublicKeyInfo, which is what standard tooling produces and what survives a certificate renewal that reuses the key. Leaf-certificate fingerprints break on every renewal.
  • Pinning a list rather than a single value lets you publish the next key before you switch to it. A single pin turns any key rotation into an outage.

tls.connect() with Proper Validation

const tls = require('tls');

function secureTLSConnect(host, port) {
  return new Promise((resolve, reject) => {
    const options = {
      host: host,
      port: port,
      servername: host,  // Enables SNI
      // rejectUnauthorized defaults to true
      // checkServerIdentity defaults to proper validation
    };

    const socket = tls.connect(options, () => {
      console.log('TLS connection established');
      console.log('Authorized:', socket.authorized);
      console.log('Peer certificate:', socket.getPeerCertificate());
      resolve(socket);
    });

    socket.on('error', (err) => {
      console.error('TLS connection failed:', err.message);
      reject(err);
    });
  });
}

Why this works:

  • All default validations enabled
  • SNI properly configured with servername
  • Certificate information logged for monitoring
  • Errors properly handled

Express with Secure Internal Service Calls

const express = require('express');
const axios = require('axios');
const fs = require('fs');
const https = require('https');

const app = express();

// Load internal CA for corporate services
const internalCA = fs.readFileSync('./certs/internal-ca.pem');

const internalApi = axios.create({
  baseURL: 'https://internal-service.corp.local',
  httpsAgent: new https.Agent({
    ca: internalCA  // Validation enforced with internal CA
  }),
  timeout: 5000
});

app.get('/user/:id', async (req, res) => {
  try {
    const user = await internalApi.get(`/users/${req.params.id}`);
    res.json(user.data);
  } catch (error) {
    if (error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
      console.error('Certificate validation failed for internal service');
      res.status(503).json({ error: 'Service unavailable' });
    } else {
      throw error;
    }
  }
});

Why this works:

  • Internal services use proper CA validation
  • Certificate errors handled gracefully
  • No validation bypasses
  • Secure even on potentially compromised networks

WebSocket/Socket.io with Proper Validation

const io = require('socket.io-client');
const fs = require('fs');

// For custom CA (if needed)
const ca = fs.readFileSync('./certs/ca-bundle.pem');

const socket = io('https://realtime.example.com', {
  // rejectUnauthorized defaults to true
  ca: ca,  // Only if custom CA needed
  transports: ['websocket']
});

socket.on('connect', () => {
  console.log('Secure WebSocket connection established');
});

socket.on('connect_error', (error) => {
  console.error('Connection failed:', error.message);
  // Handle certificate validation failures
});

socket.on('data', (data) => {
  console.log('Received:', data);
});

Why this works:

  • Default validation enforced
  • Custom CA only if required for internal services
  • Connection errors properly handled
  • Long-lived connections protected

Key Security Functions

HTTPS Request Wrapper with Enforced Validation

const https = require('https');
const { URL } = require('url');

/**
 * Wrapper around https.request that enforces validation
 * @param {string} urlString - Full URL to request
 * @param {object} options - Additional options (method, headers, etc.)
 * @returns {Promise} Promise resolving to response data
 */
function secureHttpsRequest(urlString, options = {}) {
  return new Promise((resolve, reject) => {
    const url = new URL(urlString);

    // Ensure we're using HTTPS
    if (url.protocol !== 'https:') {
      return reject(new Error('Only HTTPS URLs allowed'));
    }

    const requestOptions = {
      hostname: url.hostname,
      port: url.port || 443,
      path: url.pathname + url.search,
      method: options.method || 'GET',
      headers: options.headers || {},
      // Explicitly enforce validation (redundant but clear)
      rejectUnauthorized: true
    };

    // If custom CA provided
    if (options.ca) {
      requestOptions.ca = options.ca;
    }

    const req = https.request(requestOptions, (res) => {
      let data = '';
      res.on('data', (chunk) => { data += chunk; });
      res.on('end', () => {
        if (res.statusCode >= 200 && res.statusCode < 300) {
          resolve({ data, statusCode: res.statusCode, headers: res.headers });
        } else {
          reject(new Error(`HTTP ${res.statusCode}: ${data}`));
        }
      });
    });

    req.on('error', (err) => {
      if (err.code === 'CERT_HAS_EXPIRED') {
        reject(new Error('Certificate has expired - validation failed'));
      } else if (err.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
        reject(new Error('Unable to verify certificate - validation failed'));
      } else {
        reject(err);
      }
    });

    if (options.body) {
      req.write(JSON.stringify(options.body));
    }

    req.end();
  });
}

// Usage
secureHttpsRequest('https://api.example.com/data')
  .then(response => console.log(response.data))
  .catch(err => console.error('Request failed:', err.message));

Certificate Information Extractor

const https = require('https');
const { URL } = require('url');

/**
 * Get certificate information from an HTTPS endpoint
 * @param {string} urlString - HTTPS URL to check
 * @returns {Promise} Promise resolving to certificate details
 */
function getCertificateInfo(urlString) {
  return new Promise((resolve, reject) => {
    const url = new URL(urlString);

    // rejectUnauthorized: false is deliberate and safe HERE, and only here: this
    // function inspects a certificate and never sends application data. With
    // validation enforced, an expired or wrong-host certificate aborts the
    // handshake and the callback never runs - so the monitor could not report on
    // the one case it exists to catch. socket.authorized carries the verdict.
    const options = {
      hostname: url.hostname,
      port: url.port || 443,
      method: 'GET',
      rejectUnauthorized: false
    };

    const req = https.request(options, (res) => {
      const cert = res.socket.getPeerCertificate();

      const certInfo = {
        subject: cert.subject,
        issuer: cert.issuer,
        validFrom: cert.valid_from,
        validTo: cert.valid_to,
        serialNumber: cert.serialNumber,
        fingerprint: cert.fingerprint,
        fingerprint256: cert.fingerprint256,
        authorized: res.socket.authorized,
        authorizationError: res.socket.authorizationError,
        subjectAltNames: cert.subjectaltname
      };

      resolve(certInfo);
      res.resume(); // Drain response
    });

    req.on('error', reject);
    req.end();
  });
}

// Usage - check certificate expiration
async function checkCertificateExpiration(url) {
  try {
    const info = await getCertificateInfo(url);
    const expiryDate = new Date(info.validTo);
    const daysUntilExpiry = Math.floor((expiryDate - new Date()) / (1000 * 60 * 60 * 24));

    console.log(`Certificate for ${url}:`);
    console.log(`  Expires: ${info.validTo}`);
    console.log(`  Days until expiry: ${daysUntilExpiry}`);

    if (!info.authorized) {
      console.error(`  Certificate does NOT validate: ${info.authorizationError}`);
    } else if (daysUntilExpiry < 30) {
      console.warn(`  Certificate expiring soon`);
    }

    return info;
  } catch (error) {
    console.error(`Certificate check failed: ${error.message}`);
    throw error;
  }
}

Validation Configuration Auditor

const https = require('https');

/**
 * Audit HTTPS agent configuration for direct validation bypasses.
 * This is a guardrail for known options, not a complete substitute for code review.
 * It sees only what is on the agent: a per-request `rejectUnauthorized: false`,
 * `NODE_TLS_REJECT_UNAUTHORIZED=0`, and any undici dispatcher behind `fetch()`
 * are all invisible to it and need checking separately.
 * @param {https.Agent} agent - HTTPS agent to audit
 * @returns {object} Audit results with warnings
 */
function auditHttpsAgent(agent) {
  const warnings = [];
  const options = agent.options || {};

  // Check for disabled validation
  if (options.rejectUnauthorized === false) {
    warnings.push({
      severity: 'CRITICAL',
      message: 'rejectUnauthorized set to false - certificate validation disabled',
      remediation: 'Remove rejectUnauthorized: false or set to true'
    });
  }

  // Any custom checkServerIdentity needs manual review. It can bypass hostname validation.
  if (options.checkServerIdentity) {
    warnings.push({
      severity: 'REVIEW',
      message: 'custom checkServerIdentity configured - verify it calls tls.checkServerIdentity first',
      remediation: 'Remove custom checkServerIdentity unless hostname validation and any pin checks are both required'
    });
  }

  // Check if custom CA is provided (not a warning, just info)
  if (options.ca) {
    warnings.push({
      severity: 'INFO',
      message: 'Custom CA bundle configured',
      remediation: 'Ensure CA bundle is kept up-to-date and from trusted source'
    });
  }

  return {
    secure: warnings.filter(w => w.severity === 'CRITICAL').length === 0,
    warnings: warnings
  };
}

// Usage - audit axios client
const axios = require('axios');

const client = axios.create({
  httpsAgent: new https.Agent({
    rejectUnauthorized: false  // Will be caught by audit
  })
});

const audit = auditHttpsAgent(client.defaults.httpsAgent);
if (!audit.secure) {
  console.error('WARNING: HTTPS configuration is insecure:');
  audit.warnings.forEach(w => {
    console.error(`  [${w.severity}] ${w.message}`);
    console.error(`    → ${w.remediation}`);
  });
  process.exit(1);
}

Additional Resources