Skip to content
ecdsa.com

Verify a certificate chain · Node.js

How to verify a certificate chain against your own trust store in Node.js

To verify a certificate chain against your own trust store in Node.js, use PKI.js's CertificateChainValidationEngine with your roots as trustedCerts and the intermediates plus the leaf as certs: node:crypto can check single signatures but has no path validator outside TLS. This recipe validates a leaf → intermediate → root chain from a test CA, the chains that must fail, and shows why a single-link check is not enough.

Tested with PKI.js 3.4.2 (Node.js 24.22.0, globalThis.crypto) and Node.js 24.22.0 (node:crypto X509Certificate).

verify.mjs — RFC 5280 path validation with PKI.js
import { X509Certificate } from "node:crypto";
import { readFileSync } from "node:fs";
import * as pkijs from "pkijs"; // node:crypto has no path validator; PKI.js does RFC 5280 validation
const load = (path) => pkijs.Certificate.fromBER(new X509Certificate(readFileSync(path)).raw);
export async function verifyChain(leafPath, intermediatePaths, rootPaths, checkDate = new Date()) {
const engine = new pkijs.CertificateChainValidationEngine({
trustedCerts: rootPaths.map(load),
// PKI.js validates the LAST certificate of certs: the leaf goes at the end
certs: [...intermediatePaths.map(load), load(leafPath)],
checkDate,
});
const { result, resultCode, resultMessage } = await engine.verify();
return { valid: result, code: resultCode, message: resultMessage };
}
link.mjs — what node:crypto alone can check: one issuer, one signature
import { X509Certificate } from "node:crypto";
import { readFileSync } from "node:fs";
// One link only: did this issuer sign this certificate? (No dates, no CA flags, no path.)
const leaf = new X509Certificate(readFileSync("expired.pem"));
const issuer = new X509Certificate(readFileSync("intermediate.pem"));
console.log("names match:", leaf.checkIssued(issuer), "signature valid:", leaf.verify(issuer.publicKey));

How it works

  1. pkijs.Certificate.fromBER parses the DER that X509Certificate.raw returns, so files in PEM or DER work.
  2. trustedCerts are the roots; certs are the intermediates followed by the leaf. The engine builds the path, checks signatures, validity, basic constraints and name constraints.
  3. engine.verify() resolves to { result, resultCode, resultMessage } — true for the good chain, code 8 with The certificate is either not yet valid or expired for the expired leaf.
  4. X509Certificate.checkIssued(issuer) and verify(issuer.publicKey) check one link: names and signature.

Gotchas

  • PKI.js validates the last certificate of certs as the end entity. With the leaf first, it validates the intermediate instead — and reports an expired leaf as valid. Put the leaf last, as verifyChain does.
  • The single-link check ignores dates and CA flags: the expired certificate passes checkIssued and verify (true and true in the run). Never use it as a trust decision.
  • A look-alike root and a missing intermediate both give No valid certificate paths found (code -1); the engine does not fetch missing issuers.

Related recipes