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,000 lines of tests for ~850 lines of code.
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;Returns Express middleware.
Parameters:
| Name | Type | Description |
|---|---|---|
callback |
(cert) => boolean | Promise<boolean> |
Receives the client certificate, 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') |
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;
}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';// 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(['[email protected]'])));
// 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:[email protected]'])));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'
}));const clientCertificateAuth = require('client-certificate-auth');
app.use(clientCertificateAuth((cert) => cert.subject.CN === 'admin'));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
MIT © Tony Gies