Security reference · TLS · Certificate pinning

MCP server TLS certificate pinning security

Standard TLS validation verifies that a server's certificate was signed by a CA in the trust store — but there are hundreds of trusted CAs, any of which could be compromised or coerced. An MCP server calling a payment API or credentials service trusts whatever certificate a MITM attacker presents if they have a valid CA signature. Certificate pinning checks the specific public key of the expected endpoint rather than trusting the entire CA hierarchy. This reference covers when to pin, how to implement SPKI pinning in Node.js, and how to rotate pins without downtime.

When certificate pinning matters for MCP servers

Certificate pinning is not necessary for every outbound connection. It adds operational complexity (pin rotation when the remote server rotates its key) and should be applied selectively to connections where MITM would be catastrophic:

Connection typePin?Reason
Payment processing API (Stripe, Adyen)YesMITM = credential theft + financial fraud
Anthropic API / Claude APIYesMITM = prompt content exposure, credential theft
Internal secrets service (Vault, AWS Secrets Manager)YesMITM = credential exfiltration
OAuth token endpointYesMITM = auth code/token theft
General web fetch tools (arbitrary URLs)NoCannot pin arbitrary endpoints
Third-party APIs (analytics, CRM)ConsiderDepends on sensitivity of data sent

Corporate proxy environments: Many enterprise deployments run a TLS-intercepting proxy that issues its own CA-signed certificates for all HTTPS traffic. Certificate pinning will break in these environments unless the proxy's CA is added to the pin set. This is an acceptable trade-off for the highest-sensitivity connections — the deployment team must explicitly configure the proxy CA as a backup pin.

SPKI fingerprint pinning in Node.js

The recommended approach is Subject Public Key Info (SPKI) pinning — pinning the hash of the public key rather than the full certificate. Public keys remain stable across certificate renewals (assuming the same key pair is reused), while the certificate itself changes on every renewal. SPKI pins survive certificate rotations as long as the underlying key pair is unchanged.

import https from 'node:https';
import crypto from 'node:crypto';

// Extract SPKI fingerprint from a certificate's public key
// Run this against the target server to get the expected pin:
// openssl s_client -connect api.anthropic.com:443 | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64

const PINS = {
  'api.anthropic.com': [
    'sha256//aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcde=', // primary
    'sha256//backup-pin-for-key-rotation-scenario-xyz123=', // backup (next key)
  ],
  'api.stripe.com': [
    'sha256//stripes-primary-spki-pin-here=',
    'sha256//stripes-backup-spki-pin-here=',
  ],
};

function createPinnedAgent(hostname) {
  const expectedPins = PINS[hostname];
  if (!expectedPins) return new https.Agent(); // no pinning for unpinned hosts

  return new https.Agent({
    checkServerIdentity(host, cert) {
      // First, run the default hostname verification
      const err = https.checkServerIdentity(host, cert);
      if (err) return err;

      // Then check the SPKI pin
      const publicKey = cert.pubkey; // DER-encoded public key
      const fingerprint = 'sha256//' + crypto
        .createHash('sha256')
        .update(publicKey)
        .digest('base64');

      const pinMatches = expectedPins.some(pin => pin === fingerprint);
      if (!pinMatches) {
        const err = new Error('CERT_PIN_MISMATCH: ' + host);
        err.code = 'CERT_PIN_MISMATCH';
        return err;
      }
      // Returning undefined = certificate accepted
    },
  });
}

// Use in fetch calls via a custom dispatcher (Node 18+ undici)
import { Agent as UndiciAgent } from 'undici';

function createPinnedDispatcher(hostname, expectedPins) {
  return new UndiciAgent({
    connect: {
      checkServerIdentity(host, cert) {
        const err = https.checkServerIdentity(host, cert);
        if (err) throw err;

        const fingerprint = 'sha256//' + crypto
          .createHash('sha256')
          .update(cert.pubkey)
          .digest('base64');

        if (!expectedPins.includes(fingerprint)) {
          const pinErr = new Error('CERT_PIN_MISMATCH: expected pin not found for ' + host);
          pinErr.code = 'CERT_PIN_MISMATCH';
          throw pinErr;
        }
      },
    },
  });
}

Extracting pins from live endpoints

#!/bin/bash
# Extract SPKI SHA-256 pin from a live endpoint
# Usage: ./get-pin.sh api.anthropic.com 443

HOST=$1
PORT=${2:-443}

openssl s_client -connect "$HOST:$PORT" -servername "$HOST" 2>/dev/null \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary \
  | base64

Run this against the production endpoint and the backup/recovery endpoint (if the service publishes one). Store both pins in your configuration. When the service rotates its certificate, if they reuse the same key pair, your pin remains valid. If they rotate the key pair, you must update the pin during the overlap window when both the old and new key pairs are active.

Pin rotation without downtime

The highest risk in certificate pinning is pin expiry: if the remote server rotates their key pair and your code only knows the old pin, all connections fail until you deploy new code. The mitigation is the backup pin pattern:

  1. Always store at least two pins — the current pin and the next expected pin (if the service publishes it) or an intermediate CA pin as a fallback.
  2. Pin at the intermediate CA level for services that rotate keys frequently. The intermediate CA changes less often than leaf certificates.
  3. Monitor for pin mismatches in production — log CERT_PIN_MISMATCH errors before they become outages. If you see mismatch logs, the remote server has rotated their key and you need to update the pin in the next deploy.
// Configuration-driven pins — allows pin updates without code changes
const PIN_CONFIG_PATH = process.env.TLS_PIN_CONFIG || './config/tls-pins.json';

async function loadPins() {
  const config = JSON.parse(await fs.readFile(PIN_CONFIG_PATH, 'utf-8'));
  return config.pins; // { 'api.anthropic.com': ['sha256//...', 'sha256//...'] }
}

// In production: store pin config in your secrets manager
// Rotate by updating the secret, no code redeploy required

SkillAudit findings: TLS certificate pinning

High Credentials service or payment API called without certificate pinning — any compromised CA enables MITM credential theft. Score penalty: −10 points.
High rejectUnauthorized: false in Node HTTPS agent — TLS certificate validation completely disabled. Score penalty: −20 points (separate finding).
Medium Certificate pin hardcoded without backup pin — single pin rotation by remote server causes outage. Score penalty: −5 points.
Medium Pin mismatch errors not logged — silent failure leaves no signal before outage. Score penalty: −4 points.
Low Certificate pinned at leaf level rather than SPKI — pins expire with every certificate renewal. Score penalty: −3 points.

Run a SkillAudit scan to detect TLS misconfigurations, disabled certificate validation, and missing pins for high-value API connections in your MCP server.