Skip to content

middleware for Node.js implementing client SSL certificate authentication/authorization

License

Notifications You must be signed in to change notification settings

tgies/client-certificate-auth

Repository files navigation

client-certificate-auth

Express/Connect middleware for client SSL certificate authentication (mTLS).

CI npm version codecov stryker mutation testing

100% line/branch/function/statement coverage, plus mutation testing and E2E tests against real nginx/Envoy/Traefik containers. ~3,500 lines of test code for ~600 lines of source (measured by cloc).

Installation

npm install client-certificate-auth

Requirements: Node.js >= 20

Synopsis

This middleware requires clients to present a valid, verifiable SSL certificate (mutual TLS / mTLS). The certificate is validated at the TLS layer, then passed to your callback for additional authorization logic.

Compatible with Express, Connect, and any Node.js HTTP server framework that uses standard req.socket and req.headers.

Usage

Basic Setup

Configure your HTTPS server to request and validate client certificates:

import express from 'express';
import https from 'node:https';
import fs from 'node:fs';
import clientCertificateAuth from 'client-certificate-auth';

const app = express();

// Validate certificate against your authorization rules
const checkAuth = (cert) => {
  return cert.subject.CN === 'trusted-client';
};

// Apply to all routes
app.use(clientCertificateAuth(checkAuth));

app.get('/', (req, res) => {
  res.send('Authorized!');
});

// HTTPS server configuration
const opts = {
  key: fs.readFileSync('server.key'),
  cert: fs.readFileSync('server.pem'),
  ca: fs.readFileSync('ca.pem'),       // CA that signed client certs
  requestCert: true,                    // Request client certificate
  rejectUnauthorized: false             // Let middleware handle errors
};

https.createServer(opts, app).listen(443);

Per-Route Protection

app.get('/public', (req, res) => {
  res.send('Hello world');
});

app.get('/admin', clientCertificateAuth(checkAuth), (req, res) => {
  res.send('Hello admin');
});

Async Authorization

const checkAuth = async (cert) => {
  const user = await db.findByFingerprint(cert.fingerprint);
  return user !== null;
};

app.use(clientCertificateAuth(checkAuth));

Custom Error Messages

Throw errors for granular authorization feedback instead of returning false:

const checkAuth = (cert) => {
  if (isRevoked(cert.serialNumber)) {
    throw new Error('Certificate has been revoked');
  }
  if (!allowlist.includes(cert.fingerprint)) {
    throw new Error('Certificate not in allowlist');
  }
  return true;
};

// Thrown errors are passed to Express error handlers with:
// - error.message = your custom message
// - error.status = 401 (unless you set a different status)

To use a different status code, set it on the error before throwing:

const err = new Error('Access forbidden');
err.status = 403;
throw err;

Audit Logging Hooks

Use onAuthenticated and onRejected hooks to log authentication decisions without affecting request processing:

app.use(clientCertificateAuth(checkAuth, {
  onAuthenticated: (cert, req) => {
    logger.info('mTLS auth success', {
      cn: cert.subject.CN,
      fingerprint: cert.fingerprint,
      path: req.url,
      ip: req.ip
    });
  },
  onRejected: (cert, req, reason) => {
    logger.warn('mTLS auth failed', {
      cn: cert?.subject?.CN,
      reason,
      path: req.url,
      ip: req.ip
    });
  }
}));

Hook characteristics:

  • Fire-and-forget: Hooks don't block request processing. Async hooks run in the background.
  • Error-safe: Hook errors are caught and logged to console.error, never affecting the request.
  • Cert may be null: In onRejected, cert is null when certificate extraction failed (socket not authorized, header missing, etc.)

Rejection reasons:

Reason Description
socket_not_authorized TLS socket authorization failed
certificate_not_retrievable Socket authorized but cert couldn't be read
header_missing_or_malformed Certificate header absent or unparseable
verification_header_mismatch Proxy verify header didn't match expected value
callback_returned_false Your callback returned false
(error message) Your callback threw an error

API

clientCertificateAuth(callback, options?)

Returns Express middleware.

Parameters:

Name Type Description
callback (cert, req?) => boolean | Promise<boolean> Receives the client certificate and request, returns true to allow access
options.certificateSource string Use a preset for a known proxy: 'aws-alb', 'envoy', 'cloudflare', 'traefik'
options.certificateHeader string Custom header name to read certificate from
options.headerEncoding string Encoding format: 'url-pem', 'url-pem-aws', 'xfcc', 'base64-der', 'rfc9440'
options.fallbackToSocket boolean If header extraction fails, try socket.getPeerCertificate() (default: false)
options.includeChain boolean If true, include full certificate chain via cert.issuerCertificate (default: false)
options.verifyHeader string Header name containing verification status from proxy (e.g., 'X-SSL-Client-Verify')
options.verifyValue string Expected value indicating successful verification (e.g., 'SUCCESS')
options.onAuthenticated (cert, req) => void Called on successful authentication (fire-and-forget)
options.onRejected (cert, req, reason) => void Called on authentication failure (fire-and-forget)

Certificate Object:

The cert parameter contains fields from tls.PeerCertificate:

  • subject.CN - Common Name
  • subject.O - Organization
  • issuer - Issuer information
  • fingerprint - Certificate fingerprint
  • valid_from, valid_to - Validity period
  • issuerCertificate - Issuer's certificate (only when includeChain: true)

Accessing the Certificate

After authentication, the certificate is attached to req.clientCertificate for downstream handlers:

app.use(clientCertificateAuth(checkAuth));

app.get('/whoami', (req, res) => {
  res.json({
    cn: req.clientCertificate.subject.CN,
    fingerprint: req.clientCertificate.fingerprint
  });
});

The certificate is attached before the authorization callback runs, so it's available even if authorization fails (useful for logging).

Certificate Chain Access

For enterprise PKI scenarios, you may need to inspect intermediate CAs or the root CA:

app.use(clientCertificateAuth((cert) => {
  // Check issuer's organization
  if (cert.issuerCertificate) {
    return cert.issuerCertificate.subject.O === 'Trusted Root CA';
  }
  return false;
}, { includeChain: true }));

When includeChain: true, the certificate object includes issuerCertificate linking to the issuer's certificate (and so on up the chain). This works consistently for both socket-based and header-based extraction.

User Login

Client certificates provide cryptographically-verified identity, making them ideal for user authentication. Map certificate fields to user accounts in your database:

app.use(clientCertificateAuth(async (cert) => {
  // Option 1: Lookup by fingerprint (most secure - immutable per certificate)
  const user = await db.users.findOne({ certFingerprint: cert.fingerprint });
  
  // Option 2: Lookup by email (from subject or SAN)
  // const user = await db.users.findOne({ email: cert.subject.emailAddress });
  
  // Option 3: Lookup by Common Name
  // const user = await db.users.findOne({ certCN: cert.subject.CN });
  
  if (!user) {
    throw new Error('Certificate not registered to any user');
  }
  
  return true;
}));

To make the user available to downstream handlers, attach it to the request:

app.use(clientCertificateAuth(async (cert, req) => {
  const user = await db.users.findOne({ certFingerprint: cert.fingerprint });
  if (!user) throw new Error('Unknown certificate');
  
  req.user = user;  // Attach for downstream routes
  return true;
}));

app.get('/profile', (req, res) => {
  res.json({ 
    name: req.user.name,
    certificateCN: req.clientCertificate.subject.CN 
  });
});

Lookup strategies:

Field Pros Cons
fingerprint Unique, immutable Must register each cert
subject.emailAddress Human-readable Ensure uniqueness
subject.CN Simple to configure May not be unique
serialNumber + issuer Traceable to your CA More complex queries

Reverse Proxy / Load Balancer Support

When your application runs behind a TLS-terminating reverse proxy, the client certificate is available via HTTP headers instead of the TLS socket. This middleware supports reading certificates from headers for common proxies.

Using Presets

For common proxies, use the certificateSource option:

// AWS Application Load Balancer
app.use(clientCertificateAuth(checkAuth, {
  certificateSource: 'aws-alb'
}));

// Envoy / Istio
app.use(clientCertificateAuth(checkAuth, {
  certificateSource: 'envoy'
}));

// Cloudflare
app.use(clientCertificateAuth(checkAuth, {
  certificateSource: 'cloudflare'
}));

// Traefik
app.use(clientCertificateAuth(checkAuth, {
  certificateSource: 'traefik'
}));

Preset Details

Preset Header Encoding
aws-alb X-Amzn-Mtls-Clientcert URL-encoded PEM (AWS variant)
envoy X-Forwarded-Client-Cert XFCC structured format
cloudflare Cf-Client-Cert-Der-Base64 Base64-encoded DER
traefik X-Forwarded-Tls-Client-Cert Base64-encoded DER

Custom Headers

For nginx, HAProxy, Google Cloud Load Balancer, or other proxies with configurable headers:

// nginx with $ssl_client_escaped_cert
app.use(clientCertificateAuth(checkAuth, {
  certificateHeader: 'X-SSL-Whatever-You-Use',
  headerEncoding: 'url-pem'
}));

// Google Cloud Load Balancer (RFC 9440)
app.use(clientCertificateAuth(checkAuth, {
  certificateHeader: 'X-SSL-Whatever-You-Use',
  headerEncoding: 'rfc9440'
}));

// HAProxy with base64 DER
app.use(clientCertificateAuth(checkAuth, {
  certificateHeader: 'X-SSL-Whatever-You-Use',
  headerEncoding: 'base64-der'
}));

Encoding Formats

Encoding Description Used By
url-pem URL-encoded PEM certificate nginx, HAProxy
url-pem-aws URL-encoded PEM (AWS variant, + as safe char) AWS ALB
xfcc Envoy's structured Key=Value;... format Envoy, Istio
base64-der Base64-encoded DER certificate Cloudflare, Traefik
rfc9440 RFC 9440 format: :base64-der: Google Cloud LB

Fallback Mode

If your proxy might not always forward certificates (e.g., direct connections bypass the proxy), enable fallback:

app.use(clientCertificateAuth(checkAuth, {
  certificateSource: 'aws-alb',
  fallbackToSocket: true  // Try socket if header missing
}));

Security Considerations

⚠️ Important: When using header-based authentication, your reverse proxy must strip any incoming certificate headers from external requests to prevent spoofing.

Configure your proxy to:

  1. Strip the certificate header from incoming requests
  2. Set the header only for authenticated mTLS connections
  3. Never trust certificate headers from untrusted sources

Verification Header (Defense in Depth)

For additional protection, use verifyHeader and verifyValue to validate that your proxy has actually verified the certificate. This guards against proxy misconfiguration (e.g., ssl_verify_client optional passing unverified certs):

app.use(clientCertificateAuth(checkAuth, {
  certificateHeader: 'X-SSL-Client-Cert',
  headerEncoding: 'url-pem',
  verifyHeader: 'X-SSL-Client-Verify',
  verifyValue: 'SUCCESS'
}));

Example nginx configuration:

# Strip any existing headers from clients
proxy_set_header X-SSL-Client-Cert "";
proxy_set_header X-SSL-Client-Verify "";

# Always send verification status
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;

# Only send cert if verified
if ($ssl_client_verify = SUCCESS) {
    proxy_set_header X-SSL-Client-Cert $ssl_client_escaped_cert;
}

WebSocket Support

WebSocket connections work seamlessly with mTLS because the TLS handshake occurs before the HTTP upgrade request. The middleware authenticates the upgrade request just like any other HTTP request.

With the ws Library

import https from 'node:https';
import { WebSocketServer } from 'ws';
import clientCertificateAuth from 'client-certificate-auth';

const server = https.createServer({
  key: fs.readFileSync('server.key'),
  cert: fs.readFileSync('server.pem'),
  ca: fs.readFileSync('ca.pem'),
  requestCert: true,
  rejectUnauthorized: false,
});

const wss = new WebSocketServer({ noServer: true });

wss.on('connection', (ws, req) => {
  // Certificate is available on req.clientCertificate
  console.log(`Client connected: ${req.clientCertificate.subject.CN}`);

  ws.on('message', (data) => {
    ws.send(`Echo: ${data}`);
  });
});

// Authenticate upgrade requests
server.on('upgrade', (req, socket, head) => {
  const middleware = clientCertificateAuth((cert) => {
    return cert.subject.CN === 'trusted-client';
  });

  // Minimal response object for middleware compatibility
  const res = { writeHead: () => {}, end: () => {}, redirect: () => {} };

  middleware(req, res, (err) => {
    if (err) {
      socket.write(`HTTP/1.1 ${err.status} ${err.message}\r\n\r\n`);
      socket.destroy();
      return;
    }

    wss.handleUpgrade(req, socket, head, (ws) => {
      wss.emit('connection', ws, req);
    });
  });
});

server.listen(443);

With Socket.IO

import https from 'node:https';
import { Server } from 'socket.io';
import clientCertificateAuth from 'client-certificate-auth';

const server = https.createServer({
  key: fs.readFileSync('server.key'),
  cert: fs.readFileSync('server.pem'),
  ca: fs.readFileSync('ca.pem'),
  requestCert: true,
  rejectUnauthorized: false,
});

const io = new Server(server);

// Socket.IO middleware for mTLS authentication
io.use((socket, next) => {
  const req = socket.request;
  const res = { writeHead: () => {}, end: () => {}, redirect: () => {} };

  const middleware = clientCertificateAuth((cert) => {
    return cert.subject.CN === 'trusted-client';
  });

  middleware(req, res, (err) => {
    if (err) {
      return next(new Error('Authentication failed'));
    }
    // Attach certificate info to socket for later use
    socket.clientCert = req.clientCertificate;
    next();
  });
});

io.on('connection', (socket) => {
  console.log(`Client connected: ${socket.clientCert.subject.CN}`);
});

server.listen(443);

Authorization Helpers

Pre-built validation callbacks for common authorization patterns, available as a separate import:

import clientCertificateAuth from 'client-certificate-auth';
import { allowCN, allowFingerprints, allowIssuer, allOf, anyOf } from 'client-certificate-auth/helpers';

Note: The /helpers and /parsers subpath exports are ESM-only. They are not available via require(). Use the main CJS entry point with require('client-certificate-auth').load() for full features in CommonJS.

Basic Helpers

// Allowlist by Common Name
app.use(clientCertificateAuth(allowCN(['service-a', 'service-b'])));

// Allowlist by fingerprint
app.use(clientCertificateAuth(allowFingerprints([
  'SHA256:AB:CD:EF:...',
  'AB:CD:EF:...'  // SHA256: prefix optional
])));

// Allowlist by Organization
app.use(clientCertificateAuth(allowOrganization(['My Company'])));

// Allowlist by Organizational Unit
app.use(clientCertificateAuth(allowOU(['Engineering', 'DevOps'])));

// Allowlist by email (checks SAN and subject.emailAddress)
app.use(clientCertificateAuth(allowEmail(['admin@example.com'])));

// Allowlist by serial number
app.use(clientCertificateAuth(allowSerial(['01:23:45:67:89:AB:CD:EF'])));

// Allowlist by Subject Alternative Name
app.use(clientCertificateAuth(allowSAN(['DNS:api.example.com', 'email:service@example.com'])));

Field Matching

Match certificates by issuer or subject fields (all specified fields must match):

// Match by issuer
app.use(clientCertificateAuth(allowIssuer({ O: 'My Company', CN: 'Internal CA' })));

// Match by subject
app.use(clientCertificateAuth(allowSubject({ O: 'Partner Corp', ST: 'California' })));

Combining Helpers

// AND - all conditions must pass
app.use(clientCertificateAuth(allOf(
  allowIssuer({ O: 'My Company' }),
  allowOU(['Engineering', 'DevOps'])
)));

// OR - at least one condition must pass
app.use(clientCertificateAuth(anyOf(
  allowCN(['admin']),
  allowOU(['Administrators'])
)));

Available Helpers

Helper Description
allowCN(names) Match by Common Name
allowFingerprints(fps) Match by certificate fingerprint
allowIssuer(match) Match by issuer fields (partial)
allowSubject(match) Match by subject fields (partial)
allowOU(ous) Match by Organizational Unit
allowOrganization(orgs) Match by Organization
allowSerial(serials) Match by serial number
allowSAN(values) Match by Subject Alternative Name
allowEmail(emails) Match by email (SAN or subject)
allOf(...callbacks) AND combinator
anyOf(...callbacks) OR combinator

TypeScript

Types are included:

import clientCertificateAuth from 'client-certificate-auth';
import type { ClientCertRequest } from 'client-certificate-auth';
import type { PeerCertificate } from 'tls';

const checkAuth = (cert: PeerCertificate): boolean => {
  return cert.subject.CN === 'admin';
};

app.use(clientCertificateAuth(checkAuth));

// Access certificate in downstream handlers
app.get('/whoami', (req: ClientCertRequest, res) => {
  res.json({ cn: req.clientCertificate?.subject.CN });
});

// With reverse proxy
app.use(clientCertificateAuth(checkAuth, {
  certificateSource: 'aws-alb'
}));

CommonJS

The main entry point works with require() out of the box for socket-based mTLS:

const clientCertificateAuth = require('client-certificate-auth');

app.use(clientCertificateAuth((cert) => cert.subject.CN === 'admin'));

The sync CJS wrapper supports includeChain, onAuthenticated, and onRejected options:

const clientCertificateAuth = require('client-certificate-auth');

app.use(clientCertificateAuth(
  (cert) => cert.subject.CN === 'admin',
  {
    includeChain: true,
    onAuthenticated: (cert, req) => {
      console.log(`Authenticated: ${cert.subject.CN}`);
    }
  }
));

Full Features via load()

Reverse proxy support (header-based certificate extraction) requires async initialization. Use the load() function to get the full-featured ESM module:

const { load } = require('client-certificate-auth');

async function setup() {
  const clientCertificateAuth = await load();

  app.use(clientCertificateAuth(checkAuth, {
    certificateSource: 'aws-alb'  // Now supported
  }));
}

setup();

The load() function dynamically imports the ESM module and caches it. Subsequent calls return the cached module immediately.

CJS Limitations

Feature require() (sync) load() (async)
Socket-based mTLS Yes Yes
includeChain Yes Yes
onAuthenticated / onRejected Yes Yes
certificateSource presets No Yes
certificateHeader / headerEncoding No Yes
verifyHeader / verifyValue No Yes
fallbackToSocket No Yes

The /helpers and /parsers subpath exports are ESM-only and cannot be loaded via require(). If you need helpers in a CJS project, use dynamic import():

async function setup() {
  const { allowCN, allOf, allowIssuer } = await import('client-certificate-auth/helpers');
  // ...
}

Testing

This library has comprehensive test coverage across multiple layers:

Layer Description
Unit tests 100% line/branch/function/statement coverage, enforced in CI
Integration tests Real HTTPS servers with mTLS handshakes
E2E proxy tests Docker containers running nginx, Envoy, and Traefik with actual certificate forwarding
Mutation testing Stryker verifies tests detect code changes

The E2E tests spin up real reverse proxies, generate fresh certificates, and verify the middleware correctly parses each proxy's header format through a variety of successful and failed authentication attempts.

Security Notes

  • Set rejectUnauthorized: false on your HTTPS server to let this middleware provide helpful error messages, rather than dropping connections silently
  • When using header-based auth, ensure your proxy strips certificate headers from external requests
  • Use verifyHeader/verifyValue as defense-in-depth when using header-based authentication

Troubleshooting

DEPTH_ZERO_SELF_SIGNED_CERT error

This error occurs when the TLS layer rejects a self-signed client certificate. Set rejectUnauthorized: false in your HTTPS server options to let the middleware handle authorization instead of dropping the connection:

const opts = {
  key: fs.readFileSync('server.key'),
  cert: fs.readFileSync('server.pem'),
  ca: fs.readFileSync('ca.pem'),
  requestCert: true,
  rejectUnauthorized: false  // Required for self-signed certs
};

https.createServer(opts, app).listen(443);

Warning: In production, prefer certificates signed by your own CA rather than self-signed certificates. If you must use self-signed certs, ensure you set ca to the self-signed certificate so Node.js can verify the chain.

Certificate not reaching middleware

If the middleware always rejects with "socket not authorized", verify that your HTTPS server has requestCert: true set. Without this option, Node.js will not ask clients for a certificate during the TLS handshake:

const opts = {
  // ...
  requestCert: true,           // Must be true
  rejectUnauthorized: false
};

Also confirm that the client is actually sending a certificate. Tools like openssl s_client can verify this:

openssl s_client -connect localhost:443 -cert client.pem -key client.key

Reverse proxy headers not working

When using header-based certificate extraction behind a reverse proxy:

  1. Verify the proxy is setting the correct header. Check your proxy logs or use a test endpoint to inspect incoming headers.

  2. Ensure the certificateSource or certificateHeader/headerEncoding options match your proxy's configuration. A mismatch will result in unparseable or missing certificate data.

  3. Confirm the proxy strips certificate headers from external requests. If external clients can set these headers directly, they can bypass authentication. See Security Considerations.

  4. Consider using verifyHeader/verifyValue for defense-in-depth, so the middleware validates that the proxy actually verified the certificate.

WebSocket authentication failing

For WebSocket connections using the ws library with noServer: true, you must handle the upgrade event yourself and run the middleware manually. The middleware needs a response-like object and a next callback:

server.on('upgrade', (req, socket, head) => {
  const middleware = clientCertificateAuth(checkAuth);
  const res = { writeHead: () => {}, end: () => {}, redirect: () => {} };

  middleware(req, res, (err) => {
    if (err) {
      socket.write(`HTTP/1.1 ${err.status} ${err.message}\r\n\r\n`);
      socket.destroy();
      return;
    }
    wss.handleUpgrade(req, socket, head, (ws) => {
      wss.emit('connection', ws, req);
    });
  });
});

See the full WebSocket Support section for complete examples with ws and Socket.IO.

ESM vs CJS import differences

This package is an ES module ("type": "module" in package.json) with a CJS compatibility wrapper.

ESM (recommended):

import clientCertificateAuth from 'client-certificate-auth';
import { allowCN } from 'client-certificate-auth/helpers';

CJS (sync, socket-only):

const clientCertificateAuth = require('client-certificate-auth');

CJS (async, full features):

const clientCertificateAuth = await require('client-certificate-auth').load();

The sync CJS wrapper does not support reverse proxy options (certificateSource, certificateHeader, etc.). Passing these options will throw a descriptive error. Use load() to access the full ESM module from CJS code. See the CommonJS section for details.

The /helpers and /parsers subpath exports are ESM-only. In CJS, use dynamic import() to access them.

License

MIT © Tony Gies

Sponsor this project

 

Packages

No packages published

Contributors 2

  •  
  •