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:
- Certificate Authority (CA) — the trusted institution. It issues certificates that vouch for identities.
- 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).
- 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.)
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
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: -1option 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),
};
}
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),
};
}
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.nonRepudiationmeans 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),
};
}
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' };
}
}
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);
}
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' });
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:
- Hash the data — SHA-256 produces a fixed-size 32-byte digest of any input
- Sign the hash — the private key encrypts the hash. Only the holder of this private key can produce this output.
- 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;
}
}
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();
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 };
}
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
@signpdfto 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:
- 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.
- node-forge for certificate generation — it handles X.509 structure, extensions, and signing well
-
Node.js
cryptofor key decryption —forge.pki.decryptRsaPrivateKeysilently returnsnullfor AES-256 encrypted keys;crypto.createPrivateKey()does not -
Encrypt keys immediately —
forge.pki.encryptRsaPrivateKey()before any key ever touches disk -
workers: -1for 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)