Express/Connect middleware for client SSL certificate authentication (mTLS).
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).
npm install client-certificate-authRequirements: Node.js >= 20
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.
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);app.get('/public', (req, res) => {
res.send('Hello world');
});
app.get('/admin', clientCertificateAuth(checkAuth), (req, res) => {
res.send('Hello admin');
});const checkAuth = async (cert) => {
const user = await db.findByFingerprint(cert.fingerprint);
return user !== null;
};
app.use(clientCertificateAuth(checkAuth));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;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,certisnullwhen 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 |
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 Namesubject.O- Organizationissuer- Issuer informationfingerprint- Certificate fingerprintvalid_from,valid_to- Validity periodissuerCertificate- Issuer's certificate (only whenincludeChain: true)
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).
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.
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 |
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.
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 | 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 |
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 | 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 |
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
}));
⚠️ 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:
- Strip the certificate header from incoming requests
- Set the header only for authenticated mTLS connections
- Never trust certificate headers from untrusted sources
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 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.
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);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);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
/helpersand/parserssubpath exports are ESM-only. They are not available viarequire(). Use the main CJS entry point withrequire('client-certificate-auth').load()for full features in CommonJS.
// 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'])));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' })));// 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'])
)));| 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 |
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'
}));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}`);
}
}
));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.
| 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');
// ...
}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.
- Set
rejectUnauthorized: falseon 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/verifyValueas defense-in-depth when using header-based authentication
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
cato the self-signed certificate so Node.js can verify the chain.
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.keyWhen using header-based certificate extraction behind a reverse proxy:
-
Verify the proxy is setting the correct header. Check your proxy logs or use a test endpoint to inspect incoming headers.
-
Ensure the
certificateSourceorcertificateHeader/headerEncodingoptions match your proxy's configuration. A mismatch will result in unparseable or missing certificate data. -
Confirm the proxy strips certificate headers from external requests. If external clients can set these headers directly, they can bypass authentication. See Security Considerations.
-
Consider using
verifyHeader/verifyValuefor defense-in-depth, so the middleware validates that the proxy actually verified the certificate.
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.
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.
MIT © Tony Gies