Build a path to a trusted anchor
buildToAnchor() searches alternative certificate paths until it reaches an explicitly configured trust anchor. It returns certificates in signer → anchor order, including both endpoints. It never returns a partial path. The existing build() method retains its first-issuer selection, partial-chain results, and existing errors.
The example includes a card certificate with a damaged signature. Its public key still verifies the partition, but its own signature cannot be confirmed by the root. The search backtracks from that branch and finds the original card.
import "reflect-metadata";
import * as x509 from "@peculiar/x509";
const algorithm = { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" };
const validity = { notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z") };
const rootKeys = await crypto.subtle.generateKey(algorithm, false, ["sign", "verify"]);
const issuerKeys = await crypto.subtle.generateKey(algorithm, false, ["sign", "verify"]);
const signerKeys = await crypto.subtle.generateKey(algorithm, false, ["sign", "verify"]);
const root = await x509.X509CertificateGenerator.createSelfSigned({
...validity,
serialNumber: "01",
name: "CN=Manufacturer Root",
keys: rootKeys,
signingAlgorithm: algorithm,
extensions: [new x509.BasicConstraintsExtension(true)],
});
const card = await x509.X509CertificateGenerator.create({
...validity,
serialNumber: "02",
subject: "CN=Manufacturer Card",
issuer: root.subject,
publicKey: issuerKeys.publicKey,
signingKey: rootKeys.privateKey,
signingAlgorithm: algorithm,
extensions: [new x509.BasicConstraintsExtension(true)],
});
const signer = await x509.X509CertificateGenerator.create({
...validity,
serialNumber: "03",
subject: "CN=Partition",
issuer: card.subject,
publicKey: signerKeys.publicKey,
signingKey: issuerKeys.privateKey,
signingAlgorithm: algorithm,
});
// Corrupt only the card's signature. Its key still verifies the partition.
// Decreasing a trailing signature byte makes this copy sort before the original.
const damagedBytes = new Uint8Array(card.rawData.slice(0));
let index = damagedBytes.length - 1;
while (!damagedBytes[index]) index--;
damagedBytes[index]--;
const damagedCard = new x509.X509Certificate(damagedBytes);
const builder = new x509.X509ChainBuilder({ certificates: [damagedCard, card] });
const path = await builder.buildToAnchor(signer, {
trustAnchors: root, // Included automatically; no need to repeat it in certificates.
limits: { maxDepth: 8, maxSearchSteps: 1_000 },
});
console.log("signer → anchor:", path.map((cert) => cert.subject));
console.log("used the original card:", path[1] === card);
console.log("anchor → signer:", [...path].reverse().map((cert) => cert.subject));
// Failure is explicit: no partial path is returned.
try {
await builder.buildToAnchor(signer, { trustAnchors: root, limits: { maxDepth: 2 } });
} catch (error) {
if (!(error instanceof x509.X509ChainBuildError)) throw error;
console.log("search outcome:", error.code, error.limit);
console.log("rejections:", error.rejections.map((event) => event.reason));
}
Trust anchors
Pass a single certificate or a nonempty readonly array:
const builder = new x509.X509ChainBuilder({ certificates: intermediates });
const path = await builder.buildToAnchor(signer, {
trustAnchors: root,
});
// An array means reaching ANY one of these anchors is sufficient.
const anotherPath = await builder.buildToAnchor(signer, {
trustAnchors: [oldRoot, newRoot],
});
Anchors automatically participate in the search; they do not need to appear in certificates. Anchor identity uses the entire DER certificate, not just its name or key. A starting certificate that equals an anchor produces [signer]. The anchor's own signature is not checked, but the signature of its child is verified using the anchor's key. Missing anchors, an empty array, or malformed entries throw TypeError. Each entry must provide the certificate's public data and methods; constructor identity is not required, so objects from another copy of the library remain usable. Sets and other iterables are not accepted as anchor lists.
To require paths to several roots, make a separate call for each root and the appropriate signer candidate. To expose anchor → signer order, use [...path].reverse().
Alternative paths and cycles
Issuer matching requires exact equality of the library's subject and issuer string representations, as in build(). This is not full X.500 distinguished-name matching: case folding, whitespace normalization, and equivalent attribute orderings within an RDN are not handled. Different ASN.1 string types, such as PrintableString and UTF8String, can still match when they decode to the same string representation.
The search uses depth-first backtracking. Same-name issuer candidates are ranked as follows:
- Configured trust anchors.
- Other candidates whose SKI matches the current certificate's AKI key identifier.
- Other same-name candidates.
Within each group, complete DER bytes determine the order. AKI/SKI values are ordering hints: absent, mismatched, or unreadable identifiers do not exclude a signature-valid issuer. The sequence of DER certificates in the returned path is independent of pool and anchor array order for the same certificates, parameters, and crypto-provider behavior. It is not necessarily the shortest path.
DER-identical input certificates are deduplicated. A repeated certificate in the current path rejects that branch, while other branches remain available. A repeated distinguished name is not itself a cycle. Self-issued key rollover is supported. An untrusted self-signed certificate does not end the search: a cross-certificate may still connect it to a configured anchor.
For DER-identical copies, the returned object is the first instance added: the starting certificate, then anchors in their supplied order, then the pool in its supplied order. Reordering duplicates can therefore change object reference identity (===) while preserving the same DER path. Compare DER when checking which certificate was selected independently of its instance.
The pool and anchor arrays are snapshotted at the start of each call and are not mutated. Signature-verification outcomes are cached by child/issuer pair only for that call. Changing builder.certificates affects subsequent calls.
Search limits
X509ChainBuilder.defaultLimits is a frozen object. Optional overrides are merged with these defaults:
| Option | Default | Meaning |
|---|---|---|
maxDepth | 16 | Maximum certificates in a path, including signer and anchor |
maxSearchSteps | 10_000 | Total child/issuer pairs considered across all branches |
Each considered pair costs a step, including cache hits, cycles, invalid signatures, and verification errors. Each step performs at most one signature verification. Limits must be positive safe integers; invalid values throw RangeError.
A depth limit prunes only the affected branch. A shorter alternative can still succeed. Exhausting the step budget stops the entire search. If no path is found and any potential branch was pruned by depth, the result is LIMIT_EXCEEDED, not NO_PATH.
These limits bound traversal, not input parsing, certificate sizes, or pool preparation. Applications processing untrusted evidence should separately bound its bytes and certificate count before parsing it.
Failure diagnostics
X509ChainBuildError distinguishes two outcomes:
NO_PATH: available branches were exhausted without a confirmed path.LIMIT_EXCEEDED: a limit prevented completion;limitnames the constraint. A suitable path may still exist.
The public constructor takes named parameters: new X509ChainBuildError({ code: "NO_PATH" }). TypeScript requires limit when code is "LIMIT_EXCEEDED" and rejects a limit for "NO_PATH". Optional rejections and rejectionsTruncated provide diagnostics; omitted diagnostics default to an empty list and false.
rejections contains the first 64 events in traversal order. rejectionsTruncated indicates that further events were omitted. Reasons are NO_ISSUER, SIGNATURE_INVALID, VERIFICATION_ERROR, CYCLE, and DEPTH. An exception verifying a particular pair is recorded as VERIFICATION_ERROR with its original cause; alternative candidates are still tried. NO_PATH therefore means no path was confirmed with this provider and these matching rules, not that every cryptographic algorithm is supported.
NO_ISSUER is recorded only when no issuer candidates match the certificate's issuer name. If candidates exist, their specific rejection events describe why they could not be used; a cycle-only branch records CYCLE without an additional NO_ISSUER summary. Revisiting a pair can record its rejection again, including when signature verification was cached.
try {
await builder.buildToAnchor(signer, { trustAnchors: root });
} catch (error) {
if (!(error instanceof x509.X509ChainBuildError)) throw error;
console.log(error.code, error.limit);
for (const event of error.rejections) {
console.log(event.certificate.subject, event.candidate?.subject, event.reason);
}
}
Cancellation and crypto providers
const controller = new AbortController();
const pending = builder.buildToAnchor(signer, {
trustAnchors: root,
signal: controller.signal,
}, customCrypto);
controller.abort();
await pending; // Rejects with controller.signal.reason.
Cancellation is cooperative during preparation and between search operations, including cache-only traversal. It propagates the signal's original reason, rather than a chain-search error. An in-flight WebCrypto operation is not interrupted, so this is not a hard deadline for individual cryptographic operations. Omitting the third argument uses cryptoProvider.get(), as build() does.
Scope of validation
A successful return confirms a signature-linked path to the explicitly configured anchor. It does not validate certificate dates, CA/basic constraints, key usage, name constraints, policies, critical extensions, or revocation. Applications must apply the validation and trust policies they require.
API: X509ChainBuilder, X509ChainBuildError, X509ChainBuildToAnchorParams