Skip to content

Verification ​

What verification establishes ​

parseCertificatePem reads a certificate into typed fields. verifyCertificateChain builds a path to a trust anchor and validates it using the inputs below.

InputWhat to supply
leafThe certificate you want to verify.
intermediatesAvailable issuer certificates for path building. Missing issuers are not fetched automatically.
rootsCA certificates your application trusts. micro509 does not load a system trust store. Advanced callers can also supply bare trustAnchors.
purposeThe intended use, such as serverAuth. Omit it to leave out this application-purpose check.
serviceIdentityThe DNS, IP, URI, or SRV identity you expect. Omit it to leave out identity matching.
atThe validation time. Defaults to the current time.
revocationCRL/OCSP evidence and policy. Omit this option to skip revocation checking. Supplying it, even as {}, enables the check.

A successful result contains the validated chain and parsed leaf. It reflects the purpose, identity, time, and revocation options you supplied. For certificates that require revocation evidence, missing or unusable evidence is indeterminate and fails under the default hard-fail policy. See chain-level revocation for evidence selection and certificates exempted from checking.

Chain verification ​

For existing certificates, pass the leaf, intermediates, and trusted roots to verifyCertificateChain. The server verification recipe shows a function you can use with your application's PEM inputs.

The runnable example below creates its own three-certificate chain. Its successful result contains the certificates in leaf-to-root order. On failure, branch on result.error.code. Read the optional result.error.index together with result.error.details.source to distinguish a chain certificate from a bad root or intermediate input. A no_trusted_root failure has no index.

Generate and verify a complete root, intermediate, and leaf chain
ts
import {
  createSelfSignedCertificate,
  createCertificate,
  generateKeyPair,
  verifyCertificateChain,
} from 'micro509';

// Build a root CA
const root = await createSelfSignedCertificate({
  subject: { commonName: 'Demo Root CA' },
  extensions: {
    basicConstraints: { ca: true },
    keyUsage: ['keyCertSign', 'cRLSign'],
  },
});

// Intermediate CA signed by the root
const intKeys = await generateKeyPair();
const intermediate = await createCertificate({
  issuer: { commonName: 'Demo Root CA' },
  subject: { commonName: 'Demo Intermediate CA' },
  publicKey: intKeys.publicKey,
  signerPrivateKey: root.keyPair.privateKey,
  issuerCertificate: root.certificate.pem,
  extensions: {
    basicConstraints: { ca: true },
    keyUsage: ['keyCertSign', 'cRLSign'],
  },
});

// Leaf signed by the intermediate
const leafKeys = await generateKeyPair();
const leaf = await createCertificate({
  issuer: { commonName: 'Demo Intermediate CA' },
  subject: { commonName: 'api.example.com' },
  publicKey: leafKeys.publicKey,
  signerPrivateKey: intKeys.privateKey,
  issuerCertificate: intermediate.pem,
  extensions: {
    extendedKeyUsage: ['serverAuth'],
    subjectAltNames: [{ type: 'dns', value: 'api.example.com' }],
  },
});

const result = await verifyCertificateChain({
  leaf: leaf.pem,
  intermediates: [intermediate.pem],
  roots: [root.certificate.pem],
  purpose: 'serverAuth',
  serviceIdentity: {
    type: 'dns',
    value: 'api.example.com',
  },
});

if (result.ok) {
  const parsedLeaf = result.value.leaf;
  console.log(`\
Valid chain: ${result.value.chain.length} certificates
leaf:        ${parsedLeaf.subject.values.commonName}
serial:      ${parsedLeaf.serialNumberHex}
expires:     ${parsedLeaf.notAfter.toISOString()}`);
} else {
  console.log(`\
Failed:   ${result.error.code}
At index: ${result.error.index}`);
}

Distrust a public key ​

subjectPublicKeyInfoFingerprint(cert) returns SHA-256 over the certificate's DER SubjectPublicKeyInfo as { bytes, hex, colonHex }. Supply one or more 64-digit hexadecimal SHA-256 values through distrustedSpkiSha256 to verifyCertificateChain, buildCandidatePath, validateCandidatePath, or any validateFor* profile. Values are case-insensitive and must not contain colons. The default list is empty. Invalid values throw RangeError.

The check applies to every certificate key in candidate paths, including cross-signs and roots, and to bare trust anchors. Path building can try another unblocked issuer candidate. A blocked key fails with public_key_distrusted; details.actual is its lowercase SHA-256 hex value. On an error, there is no returned chain. error.index identifies the certificate's position in the candidate path. A bare trust anchor has no index. micro509 ships no blocklist and makes no network lookup. The caller supplies and updates the list.

This is application trust policy. RFC 5280 §6.2 allows additional restrictions on acceptable certification paths.

When validating a path built against a bare trust anchor, pass the buildCandidatePath result's value.trustAnchor and set anchorCertificateInChain: false on validateCandidatePath. When the distrust list is nonempty, omitting that anchor throws RangeError because the bare anchor's public key must also be checked.

eMudhra: one compromised key, two issuer paths ​

In October 2026, eight eMudhra intermediate CA keys were found to be compromised. The same keys also appeared in Sectigo cross-certificates. The published incident evidence includes both paths. Removing the eMudhra root alone does not block the path through Sectigo's AAA Certificate Services root.

Run this example against the actual DV G3B certificates. It loads copies hosted with these docs, uses a fixed verification time, and prints the intermediate's fingerprint and the verification result for each issuer path. The distrust list contains the published compromised key hash.

ts
import {
  parseCertificatesFromSource,
  subjectPublicKeyInfoFingerprint,
  verifyCertificateChain,
} from 'micro509';

const fixtures = new URL('../fixtures/emudhra/', import.meta.url);
async function loadFixture(name: string) {
  const response = await fetch(new URL(name, fixtures));
  if (!response.ok) throw new Error(`Fixture download failed: ${name}`);
  return response;
}
const aaaRoot = new Uint8Array(
  await (await loadFixture('aaa-certificate-services-root.crt')).arrayBuffer(),
);
const compromisedKey =
  'ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105';

for (const host of [
  'dv-g3b-emudhra.we-have-a-problem.com',
  'dv-g3b-aaa-emudhra.we-have-a-problem.com',
]) {
  const pem = await (await loadFixture(`${host}.pem`)).text();
  const [leaf, intermediate, embeddedRoot] = parseCertificatesFromSource(pem);
  if (!leaf || !intermediate) throw new Error('Incomplete incident fixture');

  const fingerprint = await subjectPublicKeyInfoFingerprint(intermediate);
  console.log(host, { intermediate: intermediate.subject.values, fingerprint });
  const input = {
    leaf: leaf.der,
    intermediates: [intermediate.der],
    roots: [embeddedRoot?.der ?? aaaRoot],
    at: new Date('2026-10-23T20:00:00Z'),
  };
  const baseline = await verifyCertificateChain(input);
  console.log('Without distrust or revocation evidence:', baseline.ok);
  const blocked = await verifyCertificateChain({
    ...input,
    distrustedSpkiSha256: [compromisedKey],
  });
  console.log('With the compromised key blocked:', blocked);
}

Both baseline paths validate with the supplied roots at that time. Both then fail with error.code: 'public_key_distrusted', error.index: 1, and the same SPKI hash in error.details.actual. The intermediate certificates have different issuers but carry the same compromised public key.

The baseline omits revocation evidence to demonstrate why signature and path validation alone do not reject this compromised key. The intermediates were also revoked. The repository's offline incident tests exercise their issuer CRLs, with fixture provenance and hashes.

Verification purposes ​

Four built-in validation profiles. serverAuth, clientAuth, and ca are passed as purpose to verifyCertificateChain, or through the equivalent validateForTlsServer, validateForTlsClient, and validateForCa wrappers; code signing has its own validateForCodeSigning profile:

ts
import {
  createSelfSignedCertificate,
  createCertificate,
  generateKeyPair,
  verifyCertificateChain,
  validateForCodeSigning,
} from 'micro509';

// Shared root CA
const root = await createSelfSignedCertificate({
  subject: { commonName: 'Demo Root CA' },
  extensions: {
    basicConstraints: { ca: true },
    keyUsage: ['keyCertSign', 'cRLSign'],
  },
});

// Issue a leaf with the extended key usage under test
async function issue(cn = '', eku = '') {
  if (eku !== 'serverAuth' && eku !== 'clientAuth' && eku !== 'codeSigning') {
    throw new Error(`Unsupported EKU: ${eku}`);
  }
  const keys = await generateKeyPair();
  return createCertificate({
    issuer: { commonName: 'Demo Root CA' },
    subject: { commonName: cn },
    publicKey: keys.publicKey,
    signerPrivateKey: root.keyPair.privateKey,
    issuerCertificate: root.certificate.pem,
    extensions: {
      extendedKeyUsage: [eku],
      subjectAltNames: [{ type: 'dns', value: cn }],
    },
  });
}

// TLS server (default)
const server = await issue('srv.example', 'serverAuth');
const r1 = await verifyCertificateChain({
  leaf: server.pem,
  roots: [root.certificate.pem],
  purpose: 'serverAuth',
});

// TLS client
const client = await issue('cli.example', 'clientAuth');
const r2 = await verifyCertificateChain({
  leaf: client.pem,
  roots: [root.certificate.pem],
  purpose: 'clientAuth',
});

// Code signing
const signer = await issue('sign.example', 'codeSigning');
const r3 = await validateForCodeSigning({
  leaf: signer.pem,
  roots: [root.certificate.pem],
});

// CA certificate (verify an intermediate as a CA)
const caKeys = await generateKeyPair();
const intermediate = await createCertificate({
  issuer: { commonName: 'Demo Root CA' },
  subject: { commonName: 'Demo Intermediate CA' },
  publicKey: caKeys.publicKey,
  signerPrivateKey: root.keyPair.privateKey,
  issuerCertificate: root.certificate.pem,
  extensions: {
    basicConstraints: { ca: true },
    keyUsage: ['keyCertSign', 'cRLSign'],
  },
});
const r4 = await verifyCertificateChain({
  leaf: intermediate.pem,
  roots: [root.certificate.pem],
  purpose: 'ca',
});

console.log(`\
serverAuth:  ${r1.ok ? `ok, serial ${r1.value.leaf.serialNumberHex}` : `${r1.error.code}@${r1.error.index}`}
clientAuth:  ${r2.ok ? `ok, serial ${r2.value.leaf.serialNumberHex}` : `${r2.error.code}@${r2.error.index}`}
codeSigning: ${r3.ok ? `ok, serial ${r3.value.leaf.serialNumberHex}` : `${r3.error.code}@${r3.error.index}`}
ca:          ${r4.ok ? `ok, serial ${r4.value.leaf.serialNumberHex}` : `${r4.error.code}@${r4.error.index}`}`);

Service identity matching ​

ts
import {
  createSelfSignedCertificate,
  parseCertificatePem,
  subjectAltNameToString,
  unwrap,
} from 'micro509';
import { matchServiceIdentity } from 'micro509/verify';

// Create and parse a certificate to match against
const { certificate } = await createSelfSignedCertificate({
  subject: { commonName: 'example.com' },
  extensions: {
    subjectAltNames: [{ type: 'dns', value: 'example.com' }],
  },
});

const parsed = unwrap(parseCertificatePem(certificate.pem));

const result = matchServiceIdentity({
  certificate: parsed,
  serviceIdentity: { type: 'dns', value: 'example.com' },
});

const sans = (parsed.subjectAltNames ?? [])
  .map((name) => subjectAltNameToString(name, { prefix: true }))
  .join(', ');

if (result.ok) {
  console.log(`\
matched: dns example.com
SANs:    ${sans}
serial:  ${parsed.serialNumberHex}`);
} else {
  console.log(result.error.code);
  // 'subject_alt_name_mismatch' | ...
}

Supported identity types:

  • DNS-ID — with wildcard matching and case-insensitive comparison
  • IP-ID — with IPv6 normalization
  • URI-ID — scheme + host matching, with wildcard matching except for SIP and IP hosts compared by octets
  • SRV-ID — service name matching via otherName SAN, with wildcard matching

Advanced validation policy ​

Path building tries every issuer candidate and bare trust anchor whose subject matches, so an input with many same-subject certificates costs more work than its size suggests. maxPathBuildingChecks on verifyCertificateChain, buildCandidatePath and the validateFor* profiles bounds how many candidates and anchors one search may examine, including those it skips because they are already on the path or their names do not match. The default is 100,000. When the bound stops the search before a trusted path is found, the result is path_building_limit_exceeded.

A user notice explicitText or noticeRef organization longer than the 200 characters RFC 5280 §4.2.1.4 allows a DisplayText is kept whole by the parser and reported as oversizedExplicitText on the qualifier or oversizedOrganization on the noticeRef, and the chain validates. §4.2.1.4 asks certificate users to handle an oversized explicitText gracefully, and micro509 applies the same policy to the organization. rejectOversizedDisplayText: true on verifyCertificateChain or validateCandidatePath rejects such a certificate with display_text_oversized instead, and details.userNoticeField names the field. PKITS 4.8.19 leaves that choice for explicitText to the application.

Error codes ​

Stability

Error-code unions (VerifyErrorCode and every other *ErrorCode / *ReasonCode union) may gain new members in minor releases as functionality grows. Treat them as non-exhaustive: keep a default branch in switch statements over codes. Renaming or removing a code is a breaking change and only happens in a major release.

Every failure mode in the VerifyErrorCode type (the runtime list is exported as VERIFY_ERROR_CODES; this table is checked against it by a repo test). Every other error-code union in the library is tabled in the error-code reference, under the same enforcement:

CodeMeaning
no_trusted_rootNo trust anchor matched the chain
issuer_not_foundCould not find issuer for a certificate
signature_invalidCryptographic signature check failed
certificate_expiredCertificate outside validity window
ca_requiredNon-CA certificate used as issuer
key_cert_sign_requiredIssuer missing keyCertSign key usage
path_length_exceededChain exceeds pathLenConstraint
path_building_limit_exceededPath building exceeded bounded work limits
authority_key_identifier_mismatchAKI/SKI cross-check failed
extended_key_usage_invalidEKU doesn't match requested purpose
subject_alt_name_mismatchSAN doesn't match service identity
common_name_fallback_suppressedCN match suppressed by presented identifiers
self_signed_leaf_not_allowedSelf-signed leaf without explicit opt-in
unrecognized_critical_extensionUnknown critical extension
no_rev_avail_conflictnoRevAvail with cA or a revocation pointer
display_text_oversizedNotice text over 200 chars, opt-in rejection
intermediate_eku_constraintIntermediate has restrictive EKU
explicit_policy_requiredPolicy required but not satisfied
initial_policy_set_not_satisfiedInitial policy set not met
unsupported_initial_name_constraintsInitial name constraints use unsupported forms
unsupported_name_constraintsUnsupported name constraint form
name_constraints_violatedName constraints check failed
unsupported_signature_algorithm_parametersUnknown signature algorithm
ec_domain_parameters_missingEC public key without a named curve
certificate_revokedRevocation evidence confirms revocation
public_key_distrustedCertificate or anchor public key is distrusted
revocation_indeterminateRevocation unknown under hard-fail policy
unsupportedCertificate input micro509 does not decode
limit_exceededCertificate input exceeds a decoding limit

CSR verification ​

ts
import {
  createCertificateSigningRequest,
  generateKeyPair,
  subjectAltNameToString,
  verifyCertificateSigningRequest,
} from 'micro509';

// Build a CSR to verify
const keyPair = await generateKeyPair({ kind: 'ed25519' });
const csr = await createCertificateSigningRequest({
  subject: { commonName: 'csr.example' },
  publicKey: keyPair.publicKey,
  signerPrivateKey: keyPair.privateKey,
  extensions: {
    subjectAltNames: [{ type: 'dns', value: 'csr.example' }],
  },
});

const result = await verifyCertificateSigningRequest(csr.pem);

if (result.ok) {
  const sans = (result.value.subjectAltNames ?? [])
    .map((name) => subjectAltNameToString(name))
    .join(', ');
  const body = csr.pem.trimEnd().split('\n').slice(1, -1).join('');
  console.log(`\
subject:   ${result.value.subject.values.commonName}
sig algo:  ${result.value.signatureAlgorithmName}
SANs:      ${sans}
signature: …${body.slice(-44)}`);
} else {
  console.log('CSR invalid:', result.error.code);
}

Detached signatures ​

micro509/crypto signs and verifies raw bytes when there is no X.509 or CMS structure around the signature: a timestamp token, a code-signing blob, a hand-built TBS. signData infers the algorithm from the key and returns the signature beside its AlgorithmIdentifier material; verifySignature checks it against the signer's SubjectPublicKeyInfo, retrying the alternate DER/raw ECDSA encoding when needed.

ts
import {
  ecdsaSignatureDerToRaw,
  ecdsaSignatureRawToDer,
  signData,
  verifySignature,
} from 'micro509/crypto';
import {
  createSelfSignedCertificate,
  parseCertificatePem,
  unwrap,
} from 'micro509';

const { certificate, keyPair } = await createSelfSignedCertificate({
  subject: { commonName: 'signer.example' },
  algorithm: { kind: 'ecdsa', curve: 'P-256' },
});
const parsed = unwrap(parseCertificatePem(certificate.pem));

const data = new TextEncoder().encode('release-v1.tar.gz');
const signed = await signData(keyPair.privateKey, data);

const result = await verifySignature({
  signerSpkiDer: parsed.subjectPublicKeyInfoDer,
  signatureAlgorithm: { oid: signed.algorithmOid },
  publicKeyAlgorithm: {
    oid: parsed.publicKeyAlgorithmOid,
    parametersOid: parsed.publicKeyParametersOid,
  },
  signature: signed.signature,
  data,
});

const tampered = await verifySignature({
  signerSpkiDer: parsed.subjectPublicKeyInfoDer,
  signatureAlgorithm: { oid: signed.algorithmOid },
  publicKeyAlgorithm: {
    oid: parsed.publicKeyAlgorithmOid,
    parametersOid: parsed.publicKeyParametersOid,
  },
  signature: signed.signature,
  data: new TextEncoder().encode('release-v2.tar.gz'),
});

// ECDSA signatures convert between the DER encoding
// X.509/CMS embed and the raw r||s form WebCrypto and
// JOSE use, bridging an HSM or JWS signature into a
// PKIX structure, or the other way around.
const raw = ecdsaSignatureDerToRaw(signed.signature, 'P-256');
const der = ecdsaSignatureRawToDer(raw, 'P-256');

const rawHex = Array.from(raw, (byte) =>
  byte.toString(16).padStart(2, '0'),
).join('');
console.log(`\
algorithm: ${signed.algorithmOid}
valid:     ${result.ok && result.valid}
tampered:  ${tampered.ok && tampered.valid}
raw r||s:  ${raw.length} bytes, ${rawHex.slice(0, 48)}…
DER again: ${der.length} bytes`);

Verify with an imported key ​

Use verifySignatureWithKey when you already have a WebCrypto public key, including a non-extractable key. It verifies directly with that key. Pass the signature's algorithm OID and parameters, and select its encoding: 'der' for ECDSA from signData, 'raw' for ECDSA from WebCrypto or JOSE, and 'raw' for RSA or Ed25519.

This example imports a public JWK once, then checks a signature and an altered message with the same non-extractable key.

ts
import { generateKeyPair } from 'micro509/keys';
import { signData, verifySignatureWithKey } from 'micro509/crypto';

const keys = await generateKeyPair({ kind: 'rsa', modulusLength: 2048 });
const jwk = await crypto.subtle.exportKey('jwk', keys.publicKey);
const publicKey = await crypto.subtle.importKey(
  'jwk',
  jwk,
  { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
  false,
  ['verify'],
);
const data = new TextEncoder().encode('release-v1.tar.gz');
const signed = await signData(keys.privateKey, data);
const input = {
  publicKey,
  signatureAlgorithm: {
    oid: signed.algorithmOid,
    parametersDer: signed.parametersDer,
  },
  signatureEncoding: 'raw',
  signature: signed.signature,
  data,
} as const;

console.log('Public JWK:', jwk);
console.log('Key extractable:', publicKey.extractable);
console.log('Verification:', await verifySignatureWithKey(input));
console.log(
  'Altered message:',
  await verifySignatureWithKey({
    ...input,
    data: new TextEncoder().encode('release-v2.tar.gz'),
  }),
);

RSA scheme and hash must match the imported key. For RSA-PSS, pass parametersDer from the signature's algorithm identifier. Supported profiles use SHA-256, SHA-384 or SHA-512, the same MGF1 hash, and a salt as long as the digest. A signature mismatch returns { ok: true, valid: false }. An incompatible algorithm or a key without public verify usage returns an error branch with code and reason.

Released under the MIT License.