Search docs

Jump between documentation pages.

Browse docs

mTLS / client-certificate auth

As of 0.37.0 DaloyJS ships clientCertAuth() — a middleware that authenticates a request by its TLS client certificate, the standard answer to “prove this internal call came from a trusted peer” in zero-trust / service-to-service deployments. It is dependency-free and runtime-portable, with two certificate sources:

  • Native TLS — when the runtime terminates TLS itself, the Node adapter reads the peer certificate off the socket and attaches it to the request (lazily — plain requests pay nothing).
  • Forwarded by a trusted proxy — when TLS is terminated upstream (Envoy, nginx, HAProxy, Traefik, a cloud load balancer), the middleware parses the verified identity the proxy forwards in request headers (Envoy X-Forwarded-Client-Cert or operator-named structured headers).

Quick start

ts
import { createApp } from "@daloyjs/core";
import { clientCertAuth } from "@daloyjs/core";

const app = createApp();

// Only peers whose certificate was issued by our internal CA and whose
// SPIFFE ID is on the allow-list may reach these routes.
app.use(
  clientCertAuth({
    allowIssuerCNs: ["acme-internal-ca"],
    allowSANs: ["URI:spiffe://acme/svc-a"],
  }),
);

app.route({
  method: "POST",
  path: "/internal/charge",
  responses: { 200: { description: "ok" } },
  handler: (ctx) => {
    const cert = ctx.state.clientCertificate; // the accepted ClientCertificate
    return { status: 200, body: { caller: cert.subjectCN } };
  },
});

On success the accepted ClientCertificate is stamped on ctx.state.clientCertificate (configurable via stateKey) for downstream handlers and audit logging.

Rejection semantics

  • No certificate presented401 application/problem+json with Cache-Control: no-store.
  • Unverified chain, failed allow-list, expired, or custom-rejected 403 (the response never echoes certificate details, to avoid leaking which check failed).

Native TLS (Node)

When the peer socket is a TLS socket presenting a client certificate, the Node adapter normalizes it (subject, issuer, fingerprint, SANs, validity window, and whether the chain was verified) and attaches it to the request. The read is deferred behind a lazy thunk, so only routes actually guarded by clientCertAuth() pay for it — and plain HTTP requests pay nothing. Run your Node server with requestCert: true and a configured CA so the runtime verifies the chain.

ts
app.use(
  clientCertAuth({
    // requireVerified defaults to true — refuse any cert the TLS layer
    // did not cryptographically verify against the configured CA.
    allowFingerprints: [process.env.PEER_FINGERPRINT!],
  }),
);

Behind a TLS-terminating proxy

When a proxy terminates TLS, it forwards the verified client identity in request headers. Because those headers are spoofable by anything that can reach the app directly, the header path is opt-in — only enable it when the app is exclusively reachable through the terminating proxy.

Envoy (X-Forwarded-Client-Cert)

ts
app.use(
  clientCertAuth({
    header: { format: "xfcc" }, // default header: x-forwarded-client-cert
    allowSANs: ["URI:spiffe://acme/svc-a"],
  }),
);

nginx / HAProxy / Traefik (structured headers)

For proxies that forward parsed fields in separate headers, name each header. The verifyheader lets the middleware require the proxy's own verification result (nginx $ssl_client_verify === "SUCCESS"):

ts
app.use(
  clientCertAuth({
    header: {
      format: "structured",
      subjectDN: "x-ssl-client-s-dn",
      issuerDN: "x-ssl-client-i-dn",
      fingerprint: "x-ssl-client-fingerprint",
      verify: "x-ssl-client-verify", // value must equal verifySuccessValue ("SUCCESS")
    },
    allowIssuerCNs: ["acme-internal-ca"],
  }),
);

Allow-lists & checks

  • requireVerified (default true) — refuse any certificate the TLS terminator did not verify.
  • allowSubjectCNs / allowIssuerCNs — exact CN match.
  • allowFingerprints — SHA-256 fingerprint match in constant time (colons/spaces and case are ignored, so a value copied from openssl works as-is).
  • allowSANs — at least one Subject Alternative Name must match (as TYPE:value like URI:spiffe://acme/svc-a, or as a bare value).
  • checkValidity (default true) — reject certificates outside their [notBefore, notAfter] window when known (belt-and-braces for header-forwarded certs).
  • verify(cert, ctx) — a custom async hook run last; returning false rejects with 403.

The ClientCertificate

Whatever the source, handlers receive one normalized shape:

ts
interface ClientCertificate {
  subjectDN?: string;        // "CN=svc-a,OU=payments,O=acme"
  subjectCN?: string;        // "svc-a"
  issuerDN?: string;
  issuerCN?: string;
  serialNumber?: string;
  fingerprint256?: string;   // uppercase hex, no separators
  subjectAltNames: string[]; // ["URI:spiffe://acme/svc-a", "DNS:svc-a.internal"]
  notBefore?: Date;
  notAfter?: Date;
  verified: boolean;         // did the TLS terminator verify the chain?
  pem?: string;              // when the proxy forwarded it (XFCC Cert=)
}

The building blocks are exported from @daloyjs/core/mtls too: parseForwardedClientCert() (Envoy XFCC), normalizePeerCertificate() (Node getPeerCertificate() shape), and setClientCertificate() / getClientCertificate() for custom adapters.