Skip to content
ecdsa.com
Node.js

Node.js node:crypto

Node.js's built-in crypto module: ECDSA, EdDSA, RSA and post-quantum signatures on top of the OpenSSL bundled with each Node.js release — DER signatures, random nonces and no low-S policy by default.
License
MIT
Checked version
24.22.0 ·
Source
github.com/nodejs/node
Documentation
nodejs.org/api/crypto.html

Compatibility at a glance

ECDSAEdDSAPost-quantum

ECDSA curves
P-224P-256P-384P-521secp256k1brainpoolP256r1brainpoolP384r1brainpoolP512r1
EdDSA
Ed25519Ed448
Post-quantum algorithms
ML-KEMML-DSASLH-DSA
Public key formats
spki-pemspki-derx509jwksec1-uncompressedsec1-compressed
Private key formats
pkcs8-pempkcs8-derencrypted-pkcs8sec1-pemsec1-derjwkraw-scalar

node:crypto is the cryptography module that ships with every Node.js release. Its one-shot crypto.sign() and crypto.verify() functions and the KeyObject class cover ECDSA, Ed25519, Ed448, RSA and, as of Node.js 24.22, ML-DSA, SLH-DSA and ML-KEM. Everything runs on the OpenSSL that Node.js bundles (3.5.8 in Node.js 24.22.0), so most behaviour mirrors the OpenSSL command line — and differs from the WebCrypto API that the same process exposes as crypto.subtle.

The interop bug you are most likely to hit is the signature encoding. crypto.sign() returns ASN.1 DER, while WebCrypto, JWS/JWT, .NET and most hardware wallets use the fixed-width raw r‖s form. A signature in the other encoding does not throw: crypto.verify() simply returns false, which looks exactly like a forged signature. Set dsaEncoding: "ieee-p1363" on the key object on the signing side and on the verifying side, or convert the bytes with the DER ⇄ raw converter.

Two quieter defaults matter when the other side is strict. Signatures are never normalized to low-S, so roughly half of them are rejected by verifiers that insist on it, such as libsecp256k1 and @noble/curves 2.x. And the nonce is random, so the same key and message give different bytes on every call — compare verification results in tests, never signature bytes. If one signer ever repeats a nonce, the nonce reuse checker recovers the key, which is why a random nonce needs a healthy random source.

Pass null as the algorithm and an EC key is quietly signed with SHA-256, whatever its curve; a P-384 key then fails against any peer that expects SHA-384. Name the hash in every call, load keys once with crypto.createPublicKey() or crypto.createPrivateKey() — they read PEM, DER, JWK and even the bare SEC1 point — and keep the resulting KeyObject instead of re-parsing strings.

To check a signature produced elsewhere, paste it with the key into the signature verifier; to see which defaults collide with another stack, pick both sides in the library interop matrix.

Facts and the checks behind them

Nothing below is copied from documentation. The check content/libraries/verify/node-crypto/run.sh ran Node.js node:crypto 24.22.0 on OpenSSL 3.5.8 in the container image node:24.22.0-bookworm-slim on October 20, 2026; the page must match what it printed, and each fact lists those lines.

  • ECDSA signature encoding

    der

    crypto.sign() returns an ASN.1 DER SEQUENCE, usually 70 to 72 bytes on P-256. With dsaEncoding: "ieee-p1363" in the key object it returns the fixed 64-byte r‖s form instead; the option has to be set on the signing and on the verifying side.

    Recorded by the check
    fact ecdsa-signature-encoding = der
    evidence ecdsa-signature-encoding: default output parses as DER: true; ieee-p1363 output length 64
  • Low-S when signing

    either

    Signatures are not normalized: among 64 signatures made in the check, at least one had s in the upper half of the range, as expected from a random nonce.

    Recorded by the check
    fact ecdsa-low-s-sign = either
    evidence ecdsa-low-s-sign: high-S seen among 64 signatures
  • High-S when verifying

    accepts-high-s

    crypto.verify() accepted a signature whose s had been replaced by n − s, in DER and in raw form. Only a strict low-S peer will ever object.

    Recorded by the check
    fact ecdsa-low-s-verify = accepts-high-s
  • ECDSA nonce

    random

    Random nonce: signing the same message twice with the same key gave different bytes, and the RFC 6979 test key did not reproduce the RFC's signature. Node.js offers no deterministic ECDSA option.

    Recorded by the check
    fact ecdsa-nonce = random
    evidence ecdsa-nonce: same input twice gives identical bytes: false; r equals RFC 6979 A.2.5: false
  • What the signing call hashes

    hashes-message

    The data is hashed with the algorithm you name — the signature verified against SHA-256 of the message in an independent implementation. There is no call that signs a precomputed digest, so pass the message itself.

    Recorded by the check
    fact ecdsa-prehash = hashes-message
    evidence ecdsa-prehash: signature verifies against SHA-256(message) in @noble/curves: true
  • Default hash

    sha256

    With null or undefined as the algorithm, an EC key is signed with SHA-256 regardless of the curve. Name the hash explicitly for P-384 and P-521 keys.

    Recorded by the check
    fact ecdsa-default-hash = sha256
  • Malformed ECDSA signature

    returns-false

    A raw r‖s signature where DER is expected, DER where raw is expected and plain garbage all make crypto.verify() return false; nothing throws, so a format problem looks like a bad signature.

    Recorded by the check
    fact ecdsa-malformed-signature = returns-false
  • ECDSA curves

    P-224P-256P-384P-521secp256k1brainpoolP256r1brainpoolP384r1brainpoolP512r1

    P-224, P-256, P-384, P-521, secp256k1 and the Brainpool r1 curves generated keys and completed a sign and verify round trip. crypto.getCurves() lists OpenSSL names, such as prime256v1 for P-256.

    Recorded by the check
    fact ecdsa-curves = P-224, P-256, P-384, P-521, secp256k1, brainpoolP256r1, brainpoolP384r1, brainpoolP512r1
  • EdDSA

    Ed25519Ed448

    Ed25519 and Ed448 sign through crypto.sign(null, data, key); Ed448 also accepts a context option.

    Recorded by the check
    fact eddsa = Ed25519, Ed448
  • Post-quantum algorithms

    ML-KEMML-DSASLH-DSA

    ML-KEM-512/768/1024 (with crypto.encapsulate() and crypto.decapsulate()), ML-DSA-44/65/87 and SLH-DSA all worked in Node.js 24.22.0, which bundles OpenSSL 3.5.8.

    Recorded by the check
    fact pqc = ML-KEM, ML-DSA, SLH-DSA
  • Public key formats

    spki-pemspki-derx509jwksec1-uncompressedsec1-compressed

    crypto.createPublicKey() read SPKI as PEM and as DER, an X.509 certificate in PEM, a JWK, and the bare SEC1 point — uncompressed or compressed — with format: "raw-public". A 64-byte x‖y without the 0x04 prefix was rejected.

    Recorded by the check
    fact public-key-formats = spki-pem, spki-der, x509, jwk, sec1-uncompressed, sec1-compressed
  • Private key formats

    pkcs8-pempkcs8-derencrypted-pkcs8sec1-pemsec1-derjwkraw-scalar

    crypto.createPrivateKey() read PKCS#8 as PEM and DER, passphrase-encrypted PKCS#8, SEC1 EC PRIVATE KEY as PEM and DER, a JWK with d, and the bare 32-byte scalar with format: "raw-private".

    Recorded by the check
    fact private-key-formats = pkcs8-pem, pkcs8-der, encrypted-pkcs8, sec1-pem, sec1-der, jwk, raw-scalar

Usage

Sign and verify with P-256, naming the hash and the signature encoding on both sides — here raw r‖s for a WebCrypto or JOSE peer.
import { generateKeyPairSync, sign, verify } from "node:crypto";

const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
const message = Buffer.from("order #1042 approved");

// Name the hash and the encoding explicitly, on both sides:
// "der" for OpenSSL, Java and Go peers, "ieee-p1363" (raw r||s) for WebCrypto and JOSE.
const signature = sign("sha256", message, { key: privateKey, dsaEncoding: "ieee-p1363" });
const ok = verify("sha256", message, { key: publicKey, dsaEncoding: "ieee-p1363" }, signature);

console.log("signature bytes:", signature.length);
console.log("verified:", ok);

The snippet harness runs every sample verbatim in the container image node:24.22.0-bookworm-slim, and the run fails unless the output matches.

Command
node usage.mjs
Output must match (regular expression)
signature bytes: 64\nverified: true

Libraries