Skip to content
ecdsa.com
Node.js · WebCrypto

@noble/curves

Paul Miller's audited, dependency-free elliptic-curve library for JavaScript: ECDSA on NIST, secp256k1 and Brainpool curves, EdDSA, Schnorr and more — deterministic, low-S and message-hashing by default since version 2.
License
MIT
Checked version
2.4.0 ·
Source
github.com/paulmillr/noble-curves
Documentation
github.com/paulmillr/noble-curves#readme

Compatibility at a glance

ECDSAEdDSA

ECDSA curves
P-256P-384P-521secp256k1brainpoolP256r1brainpoolP384r1brainpoolP512r1
EdDSA
Ed25519Ed25519phEd25519ctxEd448Ed448ph
Public key formats
sec1-uncompressedsec1-compressed

@noble/curves implements elliptic-curve cryptography in plain TypeScript with no dependencies and has been audited several times. It is the engine under ethers v6, viem and many Bitcoin and Nostr tools, and the usual answer when WebCrypto lacks a curve: it covers P-256, P-384, P-521, secp256k1 and the Brainpool r1 curves, Ed25519 and Ed448 with their prehash and context variants, and BIP-340 Schnorr signatures.

Its defaults are the strict ones. A signature is 64 bytes of compact r‖s, the nonce follows RFC 6979 — the check reproduced the RFC's P-256 test vector — and s is always normalized into the lower half. Verification is just as strict: every high-S signature that node:crypto produced was rejected, every low-S one accepted. Signatures from OpenSSL, Java, Go or WebCrypto are high-S about half the time, so verifying them needs lowS: false, and DER input needs format: "der" — without it a 70-byte DER blob throws a length error.

Version 2 changed the meaning of the first argument. In 1.x, sign() and verify() took a hash you computed; in 2.x they hash the message themselves with the curve's hash, and the check confirmed that 1.9.7 reproduces the RFC vector only when given SHA-256 of the message. Code upgraded without changes therefore hashes twice and fails against every other library; pass prehash: false where you really hold a digest. Version 1 also left P-256 signatures unnormalized, so signatures that verified in 1.x can fail in 2.x.

Keys are raw bytes only: a 32-byte secret scalar, and public keys as SEC1 points — compressed (33 bytes) by default, uncompressed on request. SPKI, PKCS#8 and JWK are not read, so keys from OpenSSL or a JWKS have to be unpacked first; the JWK converter and the curve playground help with that. There is no post-quantum code in this package; the same author publishes it separately.

The sample shows the two options that make noble interoperate with an OpenSSL-style signer — format and lowS — and the deterministic output of its own signer. To compare a signature across encodings by hand, use the DER ⇄ raw converter; for wallet signatures, the Ethereum signature verifier runs on the same curve code.

Facts and the checks behind them

Nothing below is copied from documentation. The check content/libraries/verify/noble-curves/run.sh ran @noble/curves 2.4.0 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

    raw

    sign() returns 64 bytes of compact r‖s; format: "der" returns ASN.1 DER instead, and verify() needs the same option to read it.

    Recorded by the check
    fact ecdsa-signature-encoding = raw
    evidence ecdsa-signature-encoding: default 64 bytes; format "der" gives 71 bytes that parse as DER: true
  • Low-S when signing

    low-s

    No high-S value appeared in 64 signatures on any of the seven Weierstrass curves: s is always normalized.

    Recorded by the check
    fact ecdsa-low-s-sign = low-s
    evidence ecdsa-low-s-sign: no high-S in 64 signatures on any of the seven curves
  • High-S when verifying

    rejects-high-s

    With default options, every high-S signature made by node:crypto was rejected and every low-S one accepted; with lowS: false all 64 verified.

    Recorded by the check
    fact ecdsa-low-s-verify = rejects-high-s
    evidence ecdsa-low-s-verify: 64 node:crypto signatures: every high-S one rejected: true; every low-S one accepted: true; all 64 accepted with lowS false: true
  • ECDSA nonce

    rfc6979

    RFC 6979: the P-256 test vector from appendix A.2.5 reproduced exactly, with s replaced by n − s. extraEntropy: true mixes in randomness (hedged signatures).

    Recorded by the check
    fact ecdsa-nonce = rfc6979
    evidence ecdsa-nonce: r matches RFC 6979 A.2.5: true; s equals n - s(RFC): true; extraEntropy true changes the bytes: true
  • What the signing call hashes

    hashes-message

    2.x hashes the message with the curve's hash (SHA-256 for P-256); prehash: false signs and verifies a digest you computed.

    Recorded by the check
    fact ecdsa-prehash = hashes-message
  • Signing input in version 1

    digest

    In 1.9.7, sign() treated its input as the digest: only sign(sha256(m)) reproduced the RFC 6979 vector. Upgrading from 1.x without changing call sites double-hashes.

    Recorded by the check
    fact v1-sign-input = digest
    evidence v1-sign-input: 1.x sign(sha256(m)) gives the RFC 6979 r: true; 1.x sign(m) does: false
  • P-256 low-S in version 1

    either

    1.9.7 returned the RFC's high-S value unchanged for P-256; normalization on every curve is new in 2.x.

    Recorded by the check
    fact v1-p256-low-s-sign = either
    evidence v1-p256-low-s-sign: 1.x P-256 returned the RFC's high s unchanged: true
  • Malformed ECDSA signature

    throws

    A signature of the wrong length — DER bytes without format: "der", or a 3-byte blob — throws expected Uint8Array of length 64; a well-formed but wrong signature returns false.

    Recorded by the check
    fact ecdsa-malformed-signature = throws
    evidence ecdsa-malformed-signature: DER without format: throws expected Uint8Array of length 64, got length=71
    evidence ecdsa-malformed-signature: 3 bytes: throws expected Uint8Array of length 64, got length=3
    evidence ecdsa-malformed-signature: 64 zero bytes: false
  • ECDSA curves

    P-256P-384P-521secp256k1brainpoolP256r1brainpoolP384r1brainpoolP512r1

    P-256, P-384 and P-521 from nist.js, secp256k1 from secp256k1.js, and the Brainpool r1 curves from misc.js.

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

    Ed25519Ed25519phEd25519ctxEd448Ed448ph

    Ed25519 with its ph and ctx variants, Ed448 and Ed448ph all signed and verified.

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

    none

    No post-quantum algorithms in this package.

    Recorded by the check
    fact pqc = none
    evidence pqc: exports of the package mention ML-DSA or ML-KEM: false
  • Public key formats

    sec1-uncompressedsec1-compressed

    Public keys are SEC1 points, compressed or uncompressed; a 64-byte x‖y and SPKI DER were rejected.

    Recorded by the check
    fact public-key-formats = sec1-uncompressed, sec1-compressed
  • Default public key format

    sec1-compressed

    getPublicKey() returns the 33-byte compressed point unless you pass false for the uncompressed form.

    Recorded by the check
    fact default-public-key = sec1-compressed
  • Private key formats

    raw-scalar

    Secret keys are the raw 32-byte scalar; PKCS#8 DER was rejected.

    Recorded by the check
    fact private-key-formats = raw-scalar
  • Schnorr signatures

    BIP-340

    schnorr in secp256k1.js implements BIP-340: 64-byte signatures over x-only public keys.

    Recorded by the check
    fact schnorr = BIP-340

Usage

Verify a DER signature from node:crypto with the options that make noble lenient enough for it, then sign deterministically with noble itself.
import { generateKeyPairSync, sign } from "node:crypto";
import { p256 } from "@noble/curves/nist.js";

// A signature made elsewhere: node:crypto (OpenSSL) emits DER, with s in either half.
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
const message = new TextEncoder().encode("order #1042 approved");
const der = sign("sha256", message, privateKey);
const jwk = publicKey.export({ format: "jwk" });
const point = Uint8Array.from([4, ...Buffer.from(jwk.x, "base64url"), ...Buffer.from(jwk.y, "base64url")]);

// noble 2.x hashes the message itself and rejects high-S unless told otherwise.
console.log("verified:", p256.verify(der, message, point, { format: "der", lowS: false }));

// Its own signatures are compact r||s, RFC 6979 and low-S: the same bytes every time.
const secretKey = p256.utils.randomSecretKey();
const first = p256.sign(message, secretKey);
const second = p256.sign(message, secretKey);
console.log("deterministic:", Buffer.from(first).equals(Buffer.from(second)), first.length);

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.

Setup
npm init -y >/dev/null && npm install --no-audit --no-fund @noble/curves@2.4.0
Command
node usage.mjs
Output must match (regular expression)
verified: true\ndeterministic: true 64

Libraries