node-forge
node-forge is a pure JavaScript implementation of a large slice of the TLS and PKI stack: AES, 3DES and RC2 ciphers, SHA and MD5 digests, HMAC, PBKDF2, RSA and Ed25519, plus ASN.1 parsing and the PKCS standards (5, 7, 8, 10, 12) and X.509 certificates. It runs identically in Node and in a browser because it implements every algorithm itself rather than calling out to OpenSSL or WebCrypto. It has zero runtime dependencies and represents binary data as JavaScript binary strings through its own ByteBuffer type, which is the single biggest source of confusion for people arriving from Node Buffers.
Reach for node-forge only for the parts platform crypto does not cover: X.509, CSRs, PKCS#12, and ASN.1. For hashing, symmetric encryption, and signatures, node:crypto or WebCrypto are faster, smaller, and have a much better security history than a JavaScript reimplementation that shipped four HIGH severity fixes in a single 2026 release.
Use it if
- You need to parse, build, or sign X.509 certificates and CSRs in JavaScript: node:crypto and WebCrypto both stop at keys and give you nothing for certificates
- You need to read or write PKCS#12 (.p12/.pfx) bundles, PKCS#7 signed messages, or raw ASN.1, which no platform API exposes
- The same code has to run in a browser and in Node with identical behavior, including in contexts where WebCrypto is unavailable because the page is not on a secure origin
- You are maintaining existing code that already speaks forge ByteBuffers and switching representations would touch everything
- You only need hashing, HMAC, AES, RSA, or key generation in Node: node:crypto does all of that in OpenSSL, faster, with no extra dependency and no extra attack surface
- You only need those primitives in a browser: SubtleCrypto is built in, constant-time in the parts that matter, and does not add 72 KB gzipped to your bundle
- You care about the security record: 1.4.0 (March 2026) fixed four HIGH severity issues at once, including RSA PKCS#1 v1.5 signature forgery for low-exponent keys (CVE-2026-33894), Ed25519 signature malleability from a missing S < L check (CVE-2026-33895), a basicConstraints bypass in verifyCertificateChain (CVE-2026-33896), and an infinite loop DoS in modInverse (CVE-2026-33891). CVE-2022-24771 was a closely related forgery bug in 2022
- You need timing-safe cryptography against a local attacker: JavaScript big-integer arithmetic in this library is not constant-time, and the README makes no such guarantee
- You need it to be fast: RSA key generation and bulk symmetric encryption run in JavaScript on the main thread, which is orders of magnitude slower than a native implementation and will block a UI or an event loop
- You want an actively developed library: 1.3.1 shipped in 2022 and 1.4.0 in 2026, with 402 open issues (463 including PRs), and the TLS/HTTP/socket layers in the README are effectively legacy
Setup reality
npm install node-forge and require('node-forge') gives you everything; there are no native builds and no dependencies, and prebuilt UMD bundles ship for browsers and CDNs. The pain is the data model. Forge represents bytes as binary strings and its own forge.util.ByteBuffer, so you convert at every boundary: Buffer.from(forgeBytes, 'binary') going out and buf.toString('binary') coming in, and forgetting the 'binary' encoding corrupts data silently rather than throwing. Bundle cost is real at roughly 72 KB gzipped for the full build, so browser projects should build a custom bundle with only the modules they use. Types come from the separate @types/node-forge package. Pin at least 1.4.0: everything below it has known signature forgery bugs.
Patterns
Generate random bytes and convert themrandom-bytes
const forge = require('node-forge')
const bytes = forge.random.getBytesSync(32) // binary string, not Buffer
const hex = forge.util.bytesToHex(bytes)
const b64 = forge.util.encode64(bytes)
const nodeBuf = Buffer.from(bytes, 'binary') // 'binary' is mandatoryEverything forge returns is a binary string. Buffer.from(bytes) without 'binary' reinterprets it as UTF-8 and quietly mangles any byte above 0x7F.
AES-GCM authenticated encryptionaes-gcm-encrypt
const key = forge.random.getBytesSync(32) // 32 bytes = AES-256
const iv = forge.random.getBytesSync(12)
const cipher = forge.cipher.createCipher('AES-GCM', key)
cipher.start({ iv, tagLength: 128 })
cipher.update(forge.util.createBuffer('secret payload', 'utf8'))
cipher.finish()
const ciphertext = cipher.output.getBytes()
const tag = cipher.mode.tag.getBytes()Store iv and tag next to the ciphertext; neither is secret. Reusing an iv with the same key breaks GCM completely. Key length picks the variant: 16, 24, or 32 bytes.
Decrypt AES-GCM and actually check the resultaes-gcm-decrypt
const decipher = forge.cipher.createDecipher('AES-GCM', key)
decipher.start({ iv, tagLength: 128, tag: forge.util.createBuffer(tag) })
decipher.update(forge.util.createBuffer(ciphertext))
if (!decipher.finish()) throw new Error('authentication failed')
const plaintext = decipher.output.toString()finish() returns a boolean instead of throwing. Ignore the return value and you accept tampered ciphertext as valid, which is the most common way people misuse this API.
Derive a key from a passwordderive-key-pbkdf2
const salt = forge.random.getBytesSync(16)
const key = forge.pkcs5.pbkdf2('correct horse', salt, 600000, 32,
forge.md.sha256.create())
// async form keeps the event loop free
forge.pkcs5.pbkdf2('correct horse', salt, 600000, 32,
forge.md.sha256.create(), (err, derived) => { /* ... */ })The digest argument is optional and defaults to SHA-1; always pass sha256. The sync form blocks for the whole iteration count, which at realistic iteration counts freezes a browser tab.
SHA-256 digest and HMAChash-and-hmac
const md = forge.md.sha256.create()
md.update('the quick brown fox', 'utf8')
console.log(md.digest().toHex())
const hmac = forge.hmac.create()
hmac.start('sha256', secretKey)
hmac.update('message')
console.log(hmac.digest().toHex())The 'utf8' second argument to update() matters: without it forge treats the string as raw bytes and any non-ASCII character hashes differently from every other implementation.
Generate an RSA key pair without blockingrsa-keypair
forge.pki.rsa.generateKeyPair({ bits: 2048, workers: -1 }, (err, keypair) => {
if (err) throw err
const privatePem = forge.pki.privateKeyToPem(keypair.privateKey)
const publicPem = forge.pki.publicKeyToPem(keypair.publicKey)
})The synchronous generateKeyPair({bits: 2048}) can take seconds and stalls the event loop or the UI thread. workers: -1 estimates the core count and uses web workers or the native Node API when available.
Sign and verify with RSArsa-sign-verify
const md = forge.md.sha256.create()
md.update('sign this', 'utf8')
const signature = privateKey.sign(md) // PKCS#1 v1.5 by default
const check = forge.md.sha256.create()
check.update('sign this', 'utf8')
const ok = publicKey.verify(check.digest().bytes(), signature)Every README example uses SHA-1; do not copy that. PKCS#1 v1.5 verification here has a history of forgery bugs (CVE-2022-24771, CVE-2026-33894), so prefer PSS via forge.pss.create and stay on 1.4.0 or newer.
Encrypt with RSA-OAEP rather than the defaultrsa-oaep-encrypt
const encrypted = publicKey.encrypt(plaintextBytes, 'RSA-OAEP', {
md: forge.md.sha256.create(),
})
const decrypted = privateKey.decrypt(encrypted, 'RSA-OAEP', {
md: forge.md.sha256.create(),
})encrypt() with no scheme argument silently uses RSAES PKCS#1 v1.5, which is padding-oracle prone. RSA can only encrypt a few hundred bytes, so encrypt a symmetric key, not your data.
Parse a certificate and read its fieldsread-certificate-pem
const cert = forge.pki.certificateFromPem(pem)
console.log(cert.subject.getField('CN').value)
console.log(cert.issuer.attributes.map((a) => `${a.shortName}=${a.value}`).join(','))
console.log(cert.validity.notAfter)
const san = cert.getExtension('subjectAltName')
console.log(san && san.altNames.map((n) => n.value))getField returns undefined rather than throwing when the attribute is absent, and plenty of real certificates have no CN at all, only SANs.
Create a self-signed certificateself-signed-certificate
const keys = forge.pki.rsa.generateKeyPair(2048)
const cert = forge.pki.createCertificate()
cert.publicKey = keys.publicKey
cert.serialNumber = '01'
cert.validity.notBefore = new Date()
cert.validity.notAfter = new Date(Date.now() + 365 * 24 * 3600 * 1000)
const attrs = [{ name: 'commonName', value: 'example.org' }]
cert.setSubject(attrs)
cert.setIssuer(attrs)
cert.setExtensions([
{ name: 'basicConstraints', cA: false },
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
{ name: 'subjectAltName', altNames: [{ type: 2, value: 'example.org' }] },
])
cert.sign(keys.privateKey, forge.md.sha256.create())
const pem = forge.pki.certificateToPem(cert)Pass sha256 explicitly: cert.sign(key) alone signs with SHA-1, which every modern client rejects. serialNumber is a hex string and must not start with a high bit, so prefix '00' if it does.
Build a certificate signing requestcreate-csr
const csr = forge.pki.createCertificationRequest()
csr.publicKey = keys.publicKey
csr.setSubject([{ name: 'commonName', value: 'api.example.org' }])
csr.setAttributes([
{
name: 'extensionRequest',
extensions: [
{ name: 'subjectAltName', altNames: [{ type: 2, value: 'api.example.org' }] },
],
},
])
csr.sign(keys.privateKey, forge.md.sha256.create())
const pem = forge.pki.certificationRequestToPem(csr)
console.log(csr.verify())SAN entries live inside the extensionRequest attribute, not on the subject. A CSR without them gets rejected by most CAs, including ACME servers.
Open a .p12 bundle and pull out the key and certread-pkcs12
const der = forge.util.decode64(p12Base64)
const asn1 = forge.asn1.fromDer(der)
const p12 = forge.pkcs12.pkcs12FromAsn1(asn1, 'password')
const keyBag = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[
forge.pki.oids.pkcs8ShroudedKeyBag
][0]
const certBag = p12.getBags({ bagType: forge.pki.oids.certBag })[
forge.pki.oids.certBag
][0]
const keyPem = forge.pki.privateKeyToPem(keyBag.key)
const certPem = forge.pki.certificateToPem(certBag.cert)Files exported by OpenSSL with no password need '' while Apple push certificates often need the password argument omitted entirely; those are two different code paths. If ASN.1 parsing fails, retry with pkcs12FromAsn1(asn1, false, password) for non-strict mode.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pkijs | npm | You need X.509, CMS, and PKCS work but want it layered on WebCrypto instead of a JavaScript reimplementation of every primitive |
| @peculiar/x509 | npm | Certificate and CSR creation or parsing is all you need, with a modern async API over WebCrypto |
| jose | npm | The actual job is JWT, JWS, JWE, or JWK rather than certificates; jose uses the platform crypto and is far smaller |
| jsrsasign | npm | You need an all-in-one pure-JS crypto and PKI toolkit and want a second option in the same category |