DEV Community

Cover image for Build a Complete PKI from Scratch in Node.js
KIKOUNGA
KIKOUNGA

Posted on

Build a Complete PKI from Scratch in Node.js

Intro

When I needed to implement document signing in a production application, I quickly realized that most guides online either:

  • Rely on OpenSSL CLI commands and child_process.execSync (fragile, platform-dependent)
  • Use outdated libraries that haven't been maintained since 2018
  • Skip the "why" entirely and just paste code

So I built it myself — a complete PKI (Public Key Infrastructure) using node-forge for certificate generation combined with Node.js built-in crypto for key management. This post walks you through every step and explains a real compatibility issue you will hit if you use node-forge alone.

By the end, you'll have a working PKI that:

  • Issues X.509 certificates programmatically
  • Encrypts private keys securely at rest
  • Signs PDFs or any data
  • Handles the node-forge decryption bug correctly

What is a PKI (and why should you care)?

A PKI is the infrastructure that makes digital trust possible. Before diving into code, it helps to understand how the pieces fit together.

Think of it like a notary system. When you need to prove your identity for a legal document, you don't ask a random person to vouch for you — you go to a recognized institution that everyone trusts. That institution verifies your identity and stamps the document.

A PKI works the same way, but digitally:

  1. Certificate Authority (CA) — the trusted institution. It issues certificates that vouch for identities.
  2. Certificates (X.509) — the digital equivalent of a stamped document. Each certificate binds a public key to an identity (a person, a service, a role).
  3. Key pairs — each actor has a private key (kept secret) and a public key (shared freely). What you sign with the private key, anyone can verify with the public key.

Real-world uses: HTTPS (TLS), document signing (PDF/PAdES), code signing, email (S/MIME).

The trust chain

A PKI is structured as a hierarchy. A production-grade three-tier setup looks like this:

  • Root CA — the anchor of trust. Self-signed. Kept offline (air-gapped machine or HSM). You only bring it online to sign a new Intermediate CA, which happens rarely (once every few years).
  • Intermediate CA — signed by the Root, used for day-to-day certificate issuance. If it's compromised, you revoke it and issue a new one from the Root — without ever putting the Root CA at risk.
  • Signing Certificate — the leaf. Issued to a specific actor (person, service, role). If compromised, the Intermediate CA revokes it and issues a new one.

This layering is what gives PKIs their resilience: the higher the tier, the less it's exposed, the harder it is to compromise.

The node-forge Compatibility Problem

Let me save you the debugging session I went through.

node-forge encrypts private keys with forge.pki.encryptRsaPrivateKey(). This works fine. The problem appears later, when you try to decrypt the same key with forge.pki.decryptRsaPrivateKey() — it silently returns null for keys encrypted with AES-256 in certain environments.

This is a known but poorly documented issue. Depending on your Node.js version and the exact AES-256 encryption parameters forge uses, the decryption fails without throwing an error.

The fix: use Node.js built-in crypto.createPrivateKey() for decryption. It handles all standard PKCS#1/PKCS#8 encrypted key formats correctly, then you hand the decrypted key back to forge for operations that require it (like building a PKCS#12 bundle).

Here's the flow that works:

node-forge encryptRsaPrivateKey()   →  encrypted PEM on disk
                                           ↓
Node.js crypto.createPrivateKey()   →  decrypted CryptoKey object
                                           ↓
privateKeyObject.export({ type: 'pkcs1' })  →  unencrypted PEM
                                           ↓
node-forge privateKeyFromPem()      →  forge key object (for P12, signing, etc.)
Enter fullscreen mode Exit fullscreen mode

We'll see this in practice in Step 4.

Prerequisites

  • Node.js 20 LTS or later
  • TypeScript (optional but recommended)
pnpm add node-forge
pnpm add -D @types/node-forge
Enter fullscreen mode Exit fullscreen mode

node is a built-in module — no extra installation needed for crypto.

Step 1 — Generate the Root CA

The Root CA is self-signed: it signs its own certificate with its own private key. There is no higher authority vouching for it. It is trusted by declaration — you add it manually to your application's trust store.

Key decisions for the Root CA:

  • RSA 4096-bit: Root CAs have long lifetimes (10 years). The higher key strength compensates for the longer exposure window. The workers: -1 option tells node-forge to use all available CPU threads for key generation — without it, a 4096-bit key can block your process for several seconds.
  • basicConstraints: cA: true: marks this certificate as a CA. Without this extension, other software refuses to use it to verify or sign anything.
  • keyUsage: keyCertSign: explicitly declares that this certificate is authorized to sign other certificates.
  • critical: true: if a parser doesn't understand this extension, it must reject the certificate. This prevents old or lenient parsers from ignoring security-relevant constraints.
import forge from 'node-forge';
import crypto from 'crypto';

export interface CACertificate {
  certificate: string; // PEM format
  privateKey: string;  // PEM format, AES-256 encrypted
  publicKey: string;   // PEM format
}

export async function generateRootCA(passphrase: string): Promise<CACertificate> {
  // workers: -1 uses all available CPU threads — essential for 4096-bit to avoid blocking
  const keys = forge.pki.rsa.generateKeyPair({ bits: 4096, workers: -1 });

  const cert = forge.pki.createCertificate();
  cert.publicKey = keys.publicKey;

  // Serial numbers must be unique within a CA — use random bytes, not sequential integers
  cert.serialNumber = crypto.randomBytes(16).toString('hex');

  const now = new Date();
  cert.validity.notBefore = now;
  cert.validity.notAfter = new Date();
  cert.validity.notAfter.setFullYear(now.getFullYear() + 10);

  const attrs = [
    { name: 'commonName', value: 'My Root CA' },
    { name: 'organizationName', value: 'My Org' },
    { name: 'countryName', value: 'FR' },
  ];

  cert.setSubject(attrs);
  cert.setIssuer(attrs); // Self-signed: issuer = subject, identical fields

  cert.setExtensions([
    {
      name: 'basicConstraints',
      cA: true,          // This is a CA — can sign other certificates
      critical: true,
    },
    {
      name: 'keyUsage',
      keyCertSign: true, // Authorized to sign certificates
      cRLSign: true,     // Authorized to sign Certificate Revocation Lists
      critical: true,
    },
    {
      name: 'subjectKeyIdentifier',
      // Fingerprint of this key, referenced by child certs via authorityKeyIdentifier
    },
  ]);

  // Self-sign: the CA's own private key signs its own certificate
  cert.sign(keys.privateKey, forge.md.sha256.create());

  // Encrypt the private key before returning — never store it in plaintext
  const privateKeyPem = forge.pki.encryptRsaPrivateKey(keys.privateKey, passphrase, {
    algorithm: 'aes256',
  });

  return {
    certificate: forge.pki.certificateToPem(cert),
    privateKey: privateKeyPem,
    publicKey: forge.pki.publicKeyToPem(keys.publicKey),
  };
}
Enter fullscreen mode Exit fullscreen mode

Notice setIssuer(attrs) receives the same array as setSubject(attrs). That is the definition of a self-signed certificate — the issuer and subject are identical.

The private key is immediately encrypted before the function returns. It should never exist in plaintext on disk.

Step 2 — Issue an Intermediate CA

The Intermediate CA sits between the Root CA and the signing certificates. Its role: absorb the day-to-day risk of certificate issuance so the Root CA never has to be exposed online.

Key differences from the Root CA:

  • Signed by the Root CA, not self-signed — the Root CA's private key signs this certificate
  • pathlenConstraint: 0: this Intermediate CA can sign leaf certificates but cannot create further intermediate CAs. Limits blast radius if compromised.
  • authorityKeyIdentifier: links this certificate back to the Root CA's public key fingerprint — the mechanism chain-walkers use to find the issuer
export interface IntermediateCACertificate {
  certificate: string; // PEM format
  privateKey: string;  // PEM format, AES-256 encrypted
  publicKey: string;   // PEM format
}

export async function generateIntermediateCA(
  rootCA: CACertificate,
  rootPassphrase: string,
  options: {
    commonName: string;
    organization?: string;
    country?: string;
    validityYears?: number;
  }
): Promise<IntermediateCACertificate> {
  const { commonName, organization = '', country = '', validityYears = 5 } = options;

  // Decrypt the Root CA private key to sign this certificate
  // — see Step 4 for why we use Node.js crypto here instead of forge
  const rootPrivateKey = decryptPrivateKey(rootCA.privateKey, rootPassphrase);

  const keys = forge.pki.rsa.generateKeyPair({ bits: 2048, workers: -1 });
  const cert = forge.pki.createCertificate();

  cert.publicKey = keys.publicKey;
  cert.serialNumber = crypto.randomBytes(16).toString('hex');

  const now = new Date();
  cert.validity.notBefore = now;
  cert.validity.notAfter = new Date();
  cert.validity.notAfter.setFullYear(now.getFullYear() + validityYears);

  const subjectAttrs = [{ name: 'commonName', value: commonName }];
  if (organization) subjectAttrs.push({ name: 'organizationName', value: organization });
  if (country) subjectAttrs.push({ name: 'countryName', value: country });

  cert.setSubject(subjectAttrs);

  // Issuer = Root CA
  const rootCACert = forge.pki.certificateFromPem(rootCA.certificate);
  cert.setIssuer(rootCACert.subject.attributes);

  cert.setExtensions([
    {
      name: 'basicConstraints',
      cA: true,              // This is a CA — can sign leaf certificates
      pathlenConstraint: 0,  // But cannot sign further intermediate CAs
      critical: true,
    },
    {
      name: 'keyUsage',
      keyCertSign: true,
      cRLSign: true,
      critical: true,
    },
    {
      name: 'authorityKeyIdentifier',
      keyIdentifier: true,       // Points back to the Root CA's key fingerprint
      authorityCertIssuer: true,
    },
    { name: 'subjectKeyIdentifier' },
  ]);

  // Signed by Root CA's private key
  cert.sign(rootPrivateKey, forge.md.sha256.create());

  const privateKeyPem = forge.pki.encryptRsaPrivateKey(keys.privateKey, rootPassphrase, {
    algorithm: 'aes256',
  });

  return {
    certificate: forge.pki.certificateToPem(cert),
    privateKey: privateKeyPem,
    publicKey: forge.pki.publicKeyToPem(keys.publicKey),
  };
}
Enter fullscreen mode Exit fullscreen mode

After this step, the Intermediate CA certificate carries the Root CA's signature. Anyone verifying a leaf certificate will walk the chain: leaf → Intermediate CA → Root CA. As long as they trust the Root CA, the entire chain is trusted.

Step 3 — Issue a Signing Certificate

The signing certificate is what you hand to an actor (a user, a service, a role). It is now signed by the Intermediate CA, not the Root directly — the Root CA never needs to be online for day-to-day issuance.

Key differences from the CA certificates:

  • basicConstraints: cA: false: this is a leaf certificate — it cannot sign other certificates
  • keyUsage: digitalSignature + nonRepudiation: for signing documents. nonRepudiation means the signer cannot later deny having signed something — required for legal validity in most jurisdictions
  • authorityKeyIdentifier: points to the Intermediate CA's key fingerprint
export interface SigningCertificate {
  certificate: string; // PEM format
  privateKey: string;  // PEM format, AES-256 encrypted
  publicKey: string;   // PEM format
}

export async function generateSigningCertificate(
  intermediateCA: IntermediateCACertificate,
  passphrase: string,
  options: {
    commonName: string;
    organization?: string;
    country?: string;
    validityDays?: number;
  }
): Promise<SigningCertificate> {
  const { commonName, organization = '', country = '', validityDays = 730 } = options;

  // Decrypt the Intermediate CA private key — using the Node.js crypto workaround
  const intermediatePrivateKey = decryptPrivateKey(intermediateCA.privateKey, passphrase);

  const keys = forge.pki.rsa.generateKeyPair({ bits: 2048, workers: -1 });
  const cert = forge.pki.createCertificate();

  cert.publicKey = keys.publicKey;
  cert.serialNumber = crypto.randomBytes(16).toString('hex');

  const now = new Date();
  cert.validity.notBefore = now;
  cert.validity.notAfter = new Date();
  cert.validity.notAfter.setDate(now.getDate() + validityDays);

  const subjectAttrs = [{ name: 'commonName', value: commonName }];
  if (organization) subjectAttrs.push({ name: 'organizationName', value: organization });
  if (country) subjectAttrs.push({ name: 'countryName', value: country });

  cert.setSubject(subjectAttrs);

  // Issuer = Intermediate CA (not the Root)
  const intermediateCACert = forge.pki.certificateFromPem(intermediateCA.certificate);
  cert.setIssuer(intermediateCACert.subject.attributes);

  cert.setExtensions([
    {
      name: 'basicConstraints',
      cA: false,          // Leaf certificate — cannot sign other certificates
      critical: true,
    },
    {
      name: 'keyUsage',
      digitalSignature: true,  // Can sign data (documents, PDFs)
      nonRepudiation: true,    // Signature is legally binding — signer cannot deny it
      critical: true,
    },
    {
      name: 'authorityKeyIdentifier',
      keyIdentifier: true,      // Points to Intermediate CA by key fingerprint
      authorityCertIssuer: true,
    },
    { name: 'subjectKeyIdentifier' },
  ]);

  // Signed by the Intermediate CA's private key
  cert.sign(intermediatePrivateKey, forge.md.sha256.create());

  const privateKeyPem = forge.pki.encryptRsaPrivateKey(keys.privateKey, passphrase, {
    algorithm: 'aes256',
  });

  return {
    certificate: forge.pki.certificateToPem(cert),
    privateKey: privateKeyPem,
    publicKey: forge.pki.publicKeyToPem(keys.publicKey),
  };
}
Enter fullscreen mode Exit fullscreen mode

The Root CA private key is never touched during this step — it can remain offline. Only the Intermediate CA key is needed to issue new signing certificates.

Step 4 — Verify the Certificate Chain

Verification answers: "can I trust this certificate?" In a three-tier PKI, this means walking the full chain: signing certificate → Intermediate CA → Root CA.

Each step does two things: confirm the issuer/subject linkage (via authorityKeyIdentifier), and verify the cryptographic signature (did the parent's private key sign this certificate?). If every link holds and the chain ends at a trusted root, the certificate is valid.

export function verifyCertificateChain(
  signingCertPem: string,
  intermediateCAPem: string,
  rootCAPem: string,
): { valid: boolean; error?: string } {
  try {
    const signingCert = forge.pki.certificateFromPem(signingCertPem);
    const intermediateCert = forge.pki.certificateFromPem(intermediateCAPem);
    const rootCert = forge.pki.certificateFromPem(rootCAPem);

    // Check the signing cert is a leaf (not a CA)
    const leafConstraints = signingCert.getExtension('basicConstraints') as { cA?: boolean } | null;
    if (leafConstraints?.cA) {
      return { valid: false, error: 'Signing certificate must not be a CA' };
    }

    // Check all certificates are within their validity windows
    const now = new Date();
    for (const cert of [signingCert, intermediateCert, rootCert]) {
      if (now < cert.validity.notBefore || now > cert.validity.notAfter) {
        return { valid: false, error: `Certificate "${cert.subject.getField('CN')?.value}" is expired or not yet valid` };
      }
    }

    // Walk the chain: verify each signature with the parent's public key
    // intermediateCert.verify(signingCert) = did Intermediate CA sign the signing cert?
    if (!intermediateCert.verify(signingCert)) {
      return { valid: false, error: 'Signing certificate signature is invalid (not signed by Intermediate CA)' };
    }

    // rootCert.verify(intermediateCert) = did Root CA sign the Intermediate CA?
    if (!rootCert.verify(intermediateCert)) {
      return { valid: false, error: 'Intermediate CA signature is invalid (not signed by Root CA)' };
    }

    // Root CA must be self-signed
    if (!rootCert.verify(rootCert)) {
      return { valid: false, error: 'Root CA self-signature is invalid' };
    }

    return { valid: true };
  } catch (err) {
    return { valid: false, error: err instanceof Error ? err.message : 'Verification failed' };
  }
}
Enter fullscreen mode Exit fullscreen mode

The cert.verify(issuerCert) call is the core of each step: it uses the issuer's public key to validate the signature embedded in the certificate. If the private key that signed it doesn't match the issuer's public key, verification fails.

In a real verification flow, you'd also check revocation status (CRL or OCSP). We keep it simple here.

Step 5 — Decrypting Private Keys (the node-forge compatibility issue)

Here is the part that took me the most time to debug.

node-forge encrypts private keys with forge.pki.encryptRsaPrivateKey(). The encrypted PEM header is BEGIN RSA PRIVATE KEY (PKCS#1 format with an encryption wrapper). When you later need to use that key, the natural choice is forge.pki.decryptRsaPrivateKey(). And this is where it breaks.

The symptom: forge.pki.decryptRsaPrivateKey(encryptedPem, passphrase) returns null. No exception, no error message — just null. Your code then crashes trying to call .sign() on a null value.

The root cause: node-forge's AES-256 decryption for the PBE-SHA1-AES-256-CBC scheme has compatibility issues in certain Node.js versions. It works in some environments, silently fails in others.

The fix: delegate decryption to Node.js built-in crypto, which uses OpenSSL and handles all standard encrypted key formats correctly:

import crypto from 'crypto';
import forge from 'node-forge';

/**
 * Decrypts an AES-256 encrypted PEM private key.
 *
 * node-forge's decryptRsaPrivateKey() silently returns null for AES-256
 * encrypted keys in some Node.js versions. Node.js built-in crypto handles
 * the same format correctly, so we use it for decryption and re-export
 * in a format forge can read.
 */
export function decryptPrivateKey(
  encryptedPem: string,
  passphrase: string,
): forge.pki.rsa.PrivateKey {
  // Step 1: Node.js crypto decrypts the key correctly
  const keyObject = crypto.createPrivateKey({
    key: encryptedPem,
    format: 'pem',
    passphrase: passphrase,
  });

  // Step 2: export as unencrypted PKCS#1 PEM — a format forge reads without issues
  const unencryptedPem = keyObject.export({
    type: 'pkcs1',
    format: 'pem',
  }) as string;

  // Step 3: now forge can parse it without problems
  return forge.pki.privateKeyFromPem(unencryptedPem);
}
Enter fullscreen mode Exit fullscreen mode

Usage is straightforward:

// Encrypted key lives on disk (or in a secrets manager)
const encryptedKeyPem = fs.readFileSync('pki/signing/accountant.key', 'utf-8');

// Decrypt with the workaround
const privateKey = decryptPrivateKey(encryptedKeyPem, process.env.PKI_PASSPHRASE);

// Now use it normally with forge
const p12 = forge.pkcs12.toPkcs12Asn1(privateKey, [cert], passphrase, { algorithm: '3des' });
Enter fullscreen mode Exit fullscreen mode

The key insight: never call forge.pki.decryptRsaPrivateKey() with AES-256 encrypted keys. Always go through crypto.createPrivateKey() instead.

Step 6 — Sign Data with a Certificate

With a decrypted private key and a certificate, you can sign arbitrary data. The process:

  1. Hash the data — SHA-256 produces a fixed-size 32-byte digest of any input
  2. Sign the hash — the private key encrypts the hash. Only the holder of this private key can produce this output.
  3. Encode as Base64 — raw signature bytes are binary; Base64 makes them safe to store in a database or transmit in JSON

To verify, the process runs in reverse: recompute the hash of the data, use the public key to decrypt the signature and recover the original hash. If they match, the data is authentic and unmodified.

export function signData(
  data: string | Buffer,
  privateKey: forge.pki.rsa.PrivateKey,
): string {
  const md = forge.md.sha256.create();
  const content = typeof data === 'string' ? data : data.toString('binary');
  md.update(content, 'utf8');

  // RSA-PKCS#1-v1.5 signature: hash → RSA sign → raw bytes
  const signature = privateKey.sign(md);

  // Base64-encode for safe storage and transport
  return forge.util.encode64(signature);
}

export function verifySignature(
  data: string | Buffer,
  signatureBase64: string,
  certificatePem: string,
): boolean {
  try {
    const cert = forge.pki.certificateFromPem(certificatePem);
    const publicKey = cert.publicKey as forge.pki.rsa.PublicKey;

    const md = forge.md.sha256.create();
    const content = typeof data === 'string' ? data : data.toString('binary');
    md.update(content, 'utf8');

    // Decodes the signature and verifies against the recomputed hash
    return publicKey.verify(md.digest().bytes(), forge.util.decode64(signatureBase64));
  } catch {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

verifySignature only confirms the data was signed with this certificate's private key. It does not confirm that the certificate is trustworthy. For full trust verification, also call verifySigningCertificate — a valid signature from an untrusted certificate is worthless.

Putting It All Together

async function main() {
  const passphrase = 'min-12-char-passphrase';

  // 1. Generate the Root CA once — store offline, never on a server
  const rootCA = await generateRootCA(passphrase);
  console.log('Root CA created');

  // 2. Generate the Intermediate CA — signed by Root, used for day-to-day issuance
  const intermediateCA = await generateIntermediateCA(rootCA, passphrase, {
    commonName: 'My Signing CA',
    organization: 'My Org',
    country: 'FR',
    validityYears: 5,
  });
  console.log('Intermediate CA created');

  // 3. Issue signing certificates per actor — signed by Intermediate CA
  //    Root CA does not need to be online for this step
  const accountantCert = await generateSigningCertificate(intermediateCA, passphrase, {
    commonName: 'Cabinet Dupont & Associés',
    organization: 'Cabinet Dupont',
    country: 'FR',
    validityDays: 730,
  });
  console.log('Accountant certificate issued');

  // 4. Verify the full chain: signing cert → Intermediate CA → Root CA
  const chainResult = verifyCertificateChain(
    accountantCert.certificate,
    intermediateCA.certificate,
    rootCA.certificate,
  );
  console.log('Chain valid:', chainResult.valid); // true

  // 5. Sign some data — first decrypt the private key using the workaround
  const privateKey = decryptPrivateKey(accountantCert.privateKey, passphrase);
  const signature = signData('Tax declaration 2024-Q4', privateKey);

  // 6. Verify the signature using the certificate's public key
  const signatureValid = verifySignature(
    'Tax declaration 2024-Q4',
    signature,
    accountantCert.certificate
  );
  console.log('Signature valid:', signatureValid); // true

  // Tamper detection — any change to the data fails verification
  const tampered = verifySignature(
    'Tax declaration 2024-Q4 (modified)',
    signature,
    accountantCert.certificate
  );
  console.log('Tampered signature valid:', tampered); // false
}

main();
Enter fullscreen mode Exit fullscreen mode

Storing and Loading Certificates

import fs from 'fs/promises';
import path from 'path';

// Save all three files to a directory
async function saveCertificate(cert: SigningCertificate, dir: string): Promise<void> {
  await fs.mkdir(dir, { recursive: true });

  await Promise.all([
    fs.writeFile(path.join(dir, 'cert.pem'), cert.certificate),
    fs.writeFile(path.join(dir, 'key.pem'), cert.privateKey),     // AES-256 encrypted
    fs.writeFile(path.join(dir, 'public.pem'), cert.publicKey),
  ]);
}

// Load back from disk
async function loadCertificate(dir: string): Promise<SigningCertificate> {
  const [certificate, privateKey, publicKey] = await Promise.all([
    fs.readFile(path.join(dir, 'cert.pem'), 'utf-8'),
    fs.readFile(path.join(dir, 'key.pem'), 'utf-8'),
    fs.readFile(path.join(dir, 'public.pem'), 'utf-8'),
  ]);

  return { certificate, privateKey, publicKey };
}
Enter fullscreen mode Exit fullscreen mode

A few rules to follow in production:

  • Never commit pki/ to git — add it to .gitignore. Certificates and encrypted keys should go in a secrets manager, not source control.
  • The Root CA private key should never be on an application server — generate it offline, store it in an HSM or vault, and only bring it online to sign new certificates (once every few years).
  • Minimum 12-character passphrase for key encryption — store it in environment variables or a secrets manager, never hardcoded.
  • Track issued certificates in a database — serial number, subject, validity dates, revocation status. You need this to implement a CRL or OCSP endpoint later.

Common Pitfalls

forge.pki.decryptRsaPrivateKey returns null: as described above, use crypto.createPrivateKey() instead. Always.

workers: -1 is essential for 4096-bit keys: without it, node-forge generates RSA 4096 keys synchronously, blocking the entire Node.js event loop for 2–10 seconds. With workers: -1, it uses all available CPU threads in parallel.

Clock skew: notBefore / notAfter validation fails if server clocks drift. Set notBefore to new Date(Date.now() - 5 * 60 * 1000) (5 minutes in the past) as a buffer against clock differences between issuer and verifier.

SHA-1 is dead: forge.md.sha256.create() is what we're using throughout. forge.md.sha1.create() exists but SHA-1 certificates are rejected by all modern runtimes and browsers.

Sequential serial numbers: serial numbers must be unique within a CA. Sequential integers can collide in distributed systems where multiple servers issue certificates concurrently. Use crypto.randomBytes(16) as shown above.

What's Next?

This PKI gives you the foundation for more advanced use cases:

  • PDF signing (PAdES) — combine this certificate chain with @signpdf to sign PDFs in a format that Adobe Reader can verify. I cover this in the next post.
  • Intermediate CA — add a layer between Root CA and signing certificates for better compartmentalization in large deployments
  • CRL (Certificate Revocation List) — a signed list of revoked serial numbers that clients check before trusting a certificate
  • OCSP — online revocation status checking, an alternative to CRLs

Conclusion

The key takeaways:

  1. Three-tier chain: Root CA (offline) → Intermediate CA (restricted) → Signing Certificate (daily use). Each tier can be rotated or revoked without compromising the ones above it.
  2. node-forge for certificate generation — it handles X.509 structure, extensions, and signing well
  3. Node.js crypto for key decryptionforge.pki.decryptRsaPrivateKey silently returns null for AES-256 encrypted keys; crypto.createPrivateKey() does not
  4. Encrypt keys immediatelyforge.pki.encryptRsaPrivateKey() before any key ever touches disk
  5. workers: -1 for RSA key generation — avoids blocking the event loop

The chain of signatures is what makes trust transitive: if you trust the Root CA, and the Root signed the Intermediate, and the Intermediate signed the leaf, then you trust the leaf. Break any link in the chain and verification fails.

Questions or edge cases you've hit? Drop them in the comments.

Top comments (0)