← Blog

Claude skill TLS certificate validation bypass

TLS certificate validation is the foundational security control that ensures a Claude skill is talking to the real Stripe, the real GitHub, or the real database — and not an attacker positioned on the network path. Four patterns disable this control in Node.js skills: passing rejectUnauthorized: false to the https agent, setting the NODE_TLS_REJECT_UNAUTHORIZED=0 environment variable, loading a custom CA bundle without validating it against the system trust store, and omitting certificate pinning for high-value endpoints that transmit API secrets. Together these form the SA-SKL-TLS family.

By SkillAudit  |  31 Oct 2026

A Claude skill communicates with external APIs over HTTPS using Node.js’s https module or fetch. Every credential the skill holds — Stripe secret keys, GitHub tokens, database passwords — travels over that channel. When certificate validation is disabled, an attacker positioned on any network path between the MCP host and the remote API can intercept the connection, present a forged certificate, and read or modify every request and response in plaintext. The attack requires only local network access: a shared Wi-Fi network, a VPN endpoint, a compromised router, or a misconfigured corporate proxy is sufficient. The SA-SKL-TLS family covers four distinct bypass paths developers introduce, usually while debugging connectivity issues, and then forget to revert.

SA-SKL-TLS-001: rejectUnauthorized:false disables certificate validation for specific connections

The most direct bypass is passing rejectUnauthorized: false in the options object to https.request, https.get, or in the httpsAgent option passed to axios, node-fetch, or got. This option tells Node.js’s TLS stack to accept any certificate presented by the server, including self-signed, expired, revoked, or certificates for entirely different domains. Skills commonly introduce this setting when connecting to internal services with self-signed certificates during development, then ship to production with the flag still set. Some use it to work around corporate proxy interception certificates. The flag is scoped to the specific request or agent, so the remainder of the code may be fine — the affected API call silently accepts fraudulent certificates forever.

// VULNERABLE: rejectUnauthorized:false on https.request — ignores invalid certificates
import https from 'https';

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'fetch_private_data') {
    return new Promise((resolve, reject) => {
      const options = {
        hostname: 'api.internal.company.com',
        path: '/data',
        method: 'GET',
        headers: { Authorization: `Bearer ${process.env.INTERNAL_API_KEY}` },
        rejectUnauthorized: false   // ← any cert accepted; MITM intercepts key
      };
      https.get(options, (res) => {
        let data = '';
        res.on('data', d => data += d);
        res.on('end', () => resolve({ content: [{ type: 'text', text: data }] }));
      }).on('error', reject);
    });
  }
});

// ALSO VULNERABLE: axios with httpsAgent that disables validation
import axios from 'axios';
import https from 'https';

const insecureAgent = new https.Agent({ rejectUnauthorized: false });

async function callAPI(key, body) {
  // Any call through insecureAgent ignores certificate errors
  return axios.post('https://api.payments.io/charge', body, {
    headers: { Authorization: `Bearer ${key}` },
    httpsAgent: insecureAgent   // ← API key readable to any MITM
  });
}

// ALSO VULNERABLE: node-fetch with agent option
import nodeFetch from 'node-fetch';
const resp = await nodeFetch(url, {
  agent: new https.Agent({ rejectUnauthorized: false })  // ← same issue
});

// SECURE: use system trust store; supply custom CA only when truly needed
import https from 'https';
import { readFileSync } from 'fs';

// Option A: default — rely on Node.js built-in Mozilla trust store
const resp = await fetch(url, {
  headers: { Authorization: `Bearer ${process.env.API_KEY}` }
  // No httpsAgent — Node.js validates certs against system trust store
});

// Option B: add a specific internal CA cert (not disable validation entirely)
const internalCA = readFileSync('/etc/ssl/certs/internal-ca.pem');
const secureAgent = new https.Agent({ ca: internalCA });  // validates against THIS CA
const resp2 = await nodeFetch(url, { agent: secureAgent });  // still validates — just with custom CA

// DETECTION (SkillAudit):
// Static search for rejectUnauthorized: false in any options object
const rejectFalse = searchAST('Property[key.name="rejectUnauthorized"][value.value=false]');
if (rejectFalse.length > 0) → CRITICAL  // SA-SKL-TLS-001

Self-signed internal certs are not a reason to disable validation: the correct fix is to add the internal CA to the ca option (Node.js will then validate against that CA instead of Mozilla’s list). Setting rejectUnauthorized: false does not narrow trust to the internal CA — it eliminates certificate validation entirely, making every subsequent request through that agent vulnerable to any attacker who can present any certificate.

CRITICAL
SA-SKL-TLS-001 — rejectUnauthorized: false in https.Agent, https.request, or HTTP client library options: any attacker on the network path can MITM the connection and read or modify all API traffic including credentials. Detection: AST search for rejectUnauthorized: false property → CRITICAL.

SA-SKL-TLS-002: NODE_TLS_REJECT_UNAUTHORIZED=0 disables certificate validation process-wide

A subtler variant is setting the NODE_TLS_REJECT_UNAUTHORIZED environment variable to 0 or false. Unlike rejectUnauthorized: false on a specific agent, this environment variable disables certificate validation for all TLS connections made by the entire Node.js process — every HTTP call, every database connection, every WebSocket. It is commonly found in .env files, Dockerfile ENV lines, startup scripts, or directly set via process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' in code. Skills shipping with this variable set expose every connection they make, including connections added by future contributors who may not know validation is disabled globally.

// VULNERABLE: process.env assignment disables all TLS validation in process
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';  // ← disables for ALL connections

// VULNERABLE: .env file contains:
// NODE_TLS_REJECT_UNAUTHORIZED=0

// VULNERABLE: Dockerfile
// ENV NODE_TLS_REJECT_UNAUTHORIZED=0

// VULNERABLE: shell script that starts the skill
// export NODE_TLS_REJECT_UNAUTHORIZED=0
// node server.js

// ALSO VULNERABLE: conditional but still reachable
if (process.env.NODE_ENV !== 'production') {
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
  // ^ Skills installed from npm/registry: NODE_ENV may not be 'production'
  // ^ Development flag shipped in npm package; executes on any user's machine
}

// SECURE: never set this variable — fix the underlying certificate problem instead
// If connecting to a self-signed internal service, pass the CA to the agent:
import https from 'https';
import { readFileSync } from 'fs';

const agent = new https.Agent({
  ca: readFileSync(process.env.INTERNAL_CA_PATH || '/etc/ssl/certs/internal-ca.pem')
});
// Individual requests use this agent; all other connections use system trust store

// DETECTION (SkillAudit):
// Grep .env files and Dockerfiles for the variable
const inEnvFile = searchFile('.env', 'NODE_TLS_REJECT_UNAUTHORIZED');
const inDockerfile = searchFile('Dockerfile', 'NODE_TLS_REJECT_UNAUTHORIZED');
// Search JavaScript for runtime assignment
const inCode = searchAST('AssignmentExpression where left is process.env.NODE_TLS_REJECT_UNAUTHORIZED');
if (inEnvFile || inDockerfile || inCode) → CRITICAL  // SA-SKL-TLS-002

Conditional disabling is still a critical finding: if (process.env.NODE_ENV !== 'production') guards do not protect users. The skill is installed on developers’ machines and CI runners where NODE_ENV may not be production. The skill may be invoked in staging or local environments with real production credentials. Any path where NODE_TLS_REJECT_UNAUTHORIZED can be set to 0 is a critical vulnerability regardless of the guard condition.

CRITICAL
SA-SKL-TLS-002 — NODE_TLS_REJECT_UNAUTHORIZED=0 in code, .env, or Dockerfile: disables certificate validation for every TLS connection in the process; all API calls, database connections, and WebSocket channels exposed to MITM interception. Detection: file search for NODE_TLS_REJECT_UNAUTHORIZED set to falsy value → CRITICAL.

SA-SKL-TLS-003: custom CA bundle not validated against system trust store

Skills that load a CA bundle from a file path (for internal services) sometimes make the mistake of passing the CA as the only trusted CA rather than adding it to the system trust store. In Node.js, when you pass ca: readFileSync(caPath) to an https.Agent, it replaces the default Mozilla CA bundle rather than extending it. Every public endpoint accessed through that agent now requires a certificate signed by your internal CA — which they don’t have. Developers who discover this break public-API calls by adding the public CA roots to their custom CA bundle, but a more dangerous pattern is loading the CA bundle from a user-supplied or config-file path without verifying that the file contains a legitimate certificate authority rather than an attacker-supplied CA that signs any domain.

// VULNERABLE: custom CA path from config — attacker supplies their own CA
import https from 'https';
import { readFileSync } from 'fs';

const caPath = process.env.CUSTOM_CA_PATH;  // user-supplied path

const agent = new https.Agent({
  ca: readFileSync(caPath)  // reads attacker-controlled CA certificate
  // Any cert signed by attacker's CA is now trusted for all connections
  // through this agent, including api.stripe.com, api.github.com, etc.
});

// ALSO VULNERABLE: CA bundle replaces system trust, breaking public APIs
const internalCA = readFileSync('./certs/internal-ca.pem');
// Using this agent for calls to external APIs fails — Node uses ONLY internalCA
// Developer "fixes" by appending public CAs to the bundle, creating complex trust management

// ALSO VULNERABLE: CA cert from npm package dependency
import { caCert } from 'some-internal-helper-pkg';  // CA cert bundled in dependency
const agent = new https.Agent({ ca: caCert });
// CA cert can be replaced in next patch version of the dependency (supply chain)

// SECURE: use NODE_EXTRA_CA_CERTS to extend (not replace) the system trust store
// Set in environment, not in code — OS manages the system CA list separately
process.env.NODE_EXTRA_CA_CERTS = '/etc/ssl/certs/internal-ca.pem';
// Now Node.js trusts system CAs PLUS the internal CA
// Only set this once at startup, never from user-supplied paths

// ALSO SECURE: validate CA file is a PEM certificate before using it
import { readFileSync } from 'fs';
import https from 'https';

function loadTrustedCA(caPath) {
  const content = readFileSync(caPath, 'utf8');
  if (!content.includes('-----BEGIN CERTIFICATE-----')) {
    throw new Error('CUSTOM_CA_PATH does not contain a PEM certificate');
  }
  if (!content.includes('-----END CERTIFICATE-----')) {
    throw new Error('CUSTOM_CA_PATH contains incomplete PEM data');
  }
  return content;
}

// DETECTION (SkillAudit):
// Find https.Agent with ca option sourced from a user-configurable path
const caFromEnv = searchAST('new https.Agent where ca is readFileSync(process.env.*)');
if (caFromEnv) → HIGH  // SA-SKL-TLS-003
// Find CA loaded from npm dependency bundle
const caFromDep = findCALoadedFromNodeModules();
if (caFromDep) → HIGH  // SA-SKL-TLS-003

ca replaces, NODE_EXTRA_CA_CERTS extends: the distinction matters because replacing the CA list breaks public API calls silently (connection refused, not a clear certificate error), forcing developers into risky workarounds. Use NODE_EXTRA_CA_CERTS (Node.js 7.3+) to add internal CAs without affecting the system trust store.

HIGH
SA-SKL-TLS-003 — custom CA bundle loaded from user-supplied or dependency-bundled path: attacker-supplied CA certificate trusted for all connections through the agent; supply chain compromise of the CA bundle replaces trust anchors silently. Detection: new https.Agent({ ca: readFileSync(process.env.*) }) → HIGH.

SA-SKL-TLS-004: no certificate pinning for high-value endpoints transmitting credentials

Certificate validation verifies that a certificate is signed by a trusted CA — but a compromised or mis-issuing CA can sign a fraudulent certificate for any domain. Certificate pinning goes further: it verifies that the server presents a specific certificate or public key, regardless of which CA signed it. For skills that transmit API secrets, OAuth tokens, or user data to high-value endpoints (payment processors, identity providers, internal secret stores), the absence of pinning means a compromised CA — an increasingly realistic threat — can issue a valid-looking fraudulent certificate and intercept the connection without triggering any TLS error. Pinning is not needed for all connections, but should be applied to connections where credential interception would be catastrophic.

// VULNERABLE: no pinning — CA compromise enables MITM on payment/auth endpoints
import https from 'https';

// Transmits Stripe secret key to a payment endpoint
// Any CA that issues a cert for api.stripe.com can MITM this
async function chargeCard(amount, token) {
  return new Promise((resolve, reject) => {
    const req = https.request({
      hostname: 'api.stripe.com',
      path: '/v1/charges',
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
        'Content-Type': 'application/x-www-form-urlencoded'
      }
      // No pinning — CA mis-issuance lets attacker MITM this call
    }, (res) => { /* ... */ });
    req.end(`amount=${amount}&source=${token}¤cy=usd`);
  });
}

// SECURE: pin the expected public key fingerprint
import https from 'https';
import crypto from 'crypto';
import tls from 'tls';

// Pin Stripe's public key SHA-256 fingerprint (from Stripe's published certs)
const STRIPE_PIN = 'sha256//YOUR_PINNED_PUBLIC_KEY_HASH=';  // replace with actual pin

function createPinnedAgent(expectedPin) {
  return new https.Agent({
    checkServerIdentity: (hostname, cert) => {
      // Standard hostname verification
      const err = tls.checkServerIdentity(hostname, cert);
      if (err) return err;

      // Pin: compute SHA-256 of SubjectPublicKeyInfo DER
      const pubKeyDer = cert.raw ? cert.pubkey : undefined;
      if (!pubKeyDer) return new Error('Cannot verify public key pin: missing pubkey');
      const hash = 'sha256//' + crypto.createHash('sha256').update(pubKeyDer).digest('base64') + '=';

      if (hash !== expectedPin) {
        return new Error(`Certificate pin mismatch: expected ${expectedPin}, got ${hash}`);
      }
      // undefined = no error = pin verified
    }
  });
}

const stripeAgent = createPinnedAgent(STRIPE_PIN);

async function chargeCardPinned(amount, token) {
  const resp = await fetch('https://api.stripe.com/v1/charges', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}` },
    body: new URLSearchParams({ amount, source: token, currency: 'usd' }),
    // Pass agent via undici dispatcher or node-fetch httpsAgent
  });
  return resp.json();
}

// DETECTION (SkillAudit):
// Identify high-value endpoints (payment, auth, secret store domains)
const highValueCalls = findHTTPSCallsToHighValueDomains([
  'stripe.com', 'braintree.com', 'auth0.com', 'okta.com', 'vault.io', 'api.openai.com'
]);
// Check if any of those calls use an agent with checkServerIdentity pinning
const pinnedCalls = highValueCalls.filter(call => hasPinningCheck(call));
if (highValueCalls.length > 0 && pinnedCalls.length === 0) → HIGH  // SA-SKL-TLS-004

Pin rotation matters: certificate pinning requires a maintenance plan for pin rotation when the endpoint rotates its certificate. Pin the public key (SPKI), not the certificate itself — SPKI pins survive certificate renewals as long as the key pair is reused. Stripe, Auth0, and other major providers publish their SPKI pins and rotation schedules. Set up monitoring to alert when the live cert’s pin stops matching the stored value before the old cert expires.

HIGH
SA-SKL-TLS-004 — HTTPS calls to payment, auth, or secret-store endpoints without certificate pinning: a compromised CA can issue fraudulent certificates for those domains enabling MITM interception of API keys and user credentials. Detection: HTTPS calls to high-value domains without checkServerIdentity pinning → HIGH.

SA-SKL-TLS summary

IDPatternSeverityImpact
SA-SKL-TLS-001rejectUnauthorized: false on https agent or HTTP clientCRITICALAll API traffic readable by any MITM on the network path
SA-SKL-TLS-002NODE_TLS_REJECT_UNAUTHORIZED=0 in code, .env, or DockerfileCRITICALProcess-wide TLS validation disabled; all connections exposed
SA-SKL-TLS-003CA bundle from user-supplied path or dependency without validationHIGHAttacker-controlled CA certificate trusted for all connections
SA-SKL-TLS-004No certificate pinning for payment/auth/secret-store endpointsHIGHCA mis-issuance enables MITM on highest-sensitivity connections

How SkillAudit detects SA-SKL-TLS patterns

SkillAudit’s static analysis phase uses AST traversal (tree-sitter) to locate rejectUnauthorized: false property assignments anywhere in the codebase, including within helper functions and test files that may be loaded at runtime. The file-scan phase searches .env, .env.example, Dockerfile, docker-compose.yml, and shell scripts for NODE_TLS_REJECT_UNAUTHORIZED assignments. CA-bundle loading is detected by identifying new https.Agent({ ca: ... }) patterns where the CA value is derived from process.env, a config file read, or an imported dependency. The high-value-endpoint check identifies domains from a maintained list of payment processors, identity providers, and secret stores, then verifies that any HTTPS calls to those domains pass through an agent with a checkServerIdentity implementation that performs key comparison.

Run a free SkillAudit scan on your Claude skill or MCP server to check all four SA-SKL-TLS patterns, along with SA-SKL-ENV credential exposure, SA-SKL-SBX sandbox escape, SA-SKL-DEP supply chain attacks, and SA-SKL-NET undisclosed egress in a single 60-second report.