Skip to content
ecdsa.com
WebCrypto · Node.js

WebCrypto (crypto.subtle)

The Web Cryptography API built into every browser and into Node.js, Deno and Bun: ECDSA on the three NIST curves with raw r‖s signatures, Ed25519 everywhere, and post-quantum algorithms only in some engines so far.
License
W3C Software and Document License (specification); each engine ships under its own license
Checked version
Node.js 24.22.0, Chromium 156.0.8078.4, Firefox 157.0, WebKit 27.2 ·
Source
github.com/w3c/webcrypto
Documentation
w3c.github.io/webcrypto/

Compatibility at a glance

ECDSAEdDSA

ECDSA curves
P-256P-384P-521
EdDSA
Ed25519
Public key formats
spki-derjwksec1-uncompressed

crypto.subtle is the standard cryptography interface of the web platform. The same calls — generateKey, importKey, sign, verify, exportKey — work in Chromium, Firefox and WebKit and in server runtimes such as Node.js, which makes it the natural choice for code that must run on both sides. It only exists in secure contexts: on a plain-http page crypto.subtle is undefined.

The specification fixes the ECDSA signature format to raw r‖s, 64 bytes on P-256, and the check found exactly that in all four engines. That matches JWS and JWT, so ES256 tokens need no conversion, but it does not match OpenSSL, Java's SHA256withECDSA, Go's SignASN1 or node:crypto, which all speak ASN.1 DER. Because a DER signature passed to verify() returns a plain false rather than an error, the mismatch looks like a forgery; convert with the DER ⇄ raw converter or check both forms in the signature verifier.

Keys are the other common source of friction. importKey("spki") takes DER bytes, never PEM text; "raw" takes the SEC1 point, and the compressed form is accepted by Node.js, Chromium and Firefox but refused by WebKit, so send the uncompressed point for portable code. Private keys come in as PKCS#8 DER or JWK only — the SEC1 EC PRIVATE KEY files OpenSSL writes by default have to be converted to PKCS#8 first, for example with the JWK converter.

The curve list ends at P-256, P-384 and P-521: secp256k1 and Brainpool raise NotSupportedError everywhere, so Bitcoin and Ethereum keys need a library such as @noble/curves. Ed25519 works in all four engines; Ed448 only in Node.js. Post-quantum support is uneven: Node.js 24.22 implements ML-DSA and ML-KEM behind an experimental warning, Chromium 156 accepts ML-DSA and ML-KEM-768/1024 but not ML-KEM-512, and Firefox 157 and WebKit 27.2 have neither — feature-detect before relying on them, and try the algorithms in the post-quantum signature playground.

The sample shows the portable pattern: name the hash in the algorithm object, import foreign PEM keys by decoding them to DER first, and remember that the 64-byte output has to be wrapped in DER before an OpenSSL-style verifier will read it.

Facts and the checks behind them

Nothing below is copied from documentation. The check content/libraries/verify/webcrypto/run.sh ran WebCrypto (crypto.subtle) Node.js 24.22.0, Chromium 156.0.8078.4, Firefox 157.0, WebKit 27.2 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() returned exactly 64 bytes of raw r‖s on P-256 in Node.js, Chromium, Firefox and WebKit; the specification defines no DER option.

    Recorded by the check
    fact ecdsa-signature-encoding = raw
  • Low-S when signing

    either

    Signatures are not normalized: high-S values appeared among 64 signatures in every engine.

    Recorded by the check
    fact ecdsa-low-s-sign = either
  • High-S when verifying

    accepts-high-s

    Every engine accepted a signature whose s had been replaced by n − s.

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

    random

    Random nonce in every engine — signing the same data twice gives different bytes, and there is no deterministic option.

    Recorded by the check
    fact ecdsa-nonce = random
  • What the signing call hashes

    hashes-message

    sign() hashes the data with the hash named in the algorithm object; each engine's signature verified against SHA-256 of the message in an independent implementation. There is no way to sign a precomputed digest.

    Recorded by the check
    fact ecdsa-prehash = hashes-message
  • Malformed ECDSA signature

    returns-false

    A DER signature, a 63-byte signature and a 3-byte blob all made verify() return false in all four engines; nothing throws.

    Recorded by the check
    fact ecdsa-malformed-signature = returns-false
    evidence ecdsa-malformed-signature: Node.js: DER false, 63 bytes false, 3 bytes false
    evidence ecdsa-malformed-signature: Chromium: DER false, 63 bytes false, 3 bytes false
    evidence ecdsa-malformed-signature: Firefox: DER false, 63 bytes false, 3 bytes false
    evidence ecdsa-malformed-signature: WebKit: DER false, 63 bytes false, 3 bytes false
  • ECDSA curves

    P-256P-384P-521

    P-256, P-384 and P-521 only; secp256k1, K-256 and brainpoolP256r1 raised NotSupportedError in every engine.

    Recorded by the check
    fact ecdsa-curves = P-256, P-384, P-521
    evidence ecdsa-curves: Node.js: P-256 true; P-384 true; P-521 true; secp256k1 NotSupportedError; K-256 NotSupportedError; brainpoolP256r1 NotSupportedError
    evidence ecdsa-curves: Chromium: P-256 true; P-384 true; P-521 true; secp256k1 NotSupportedError; K-256 NotSupportedError; brainpoolP256r1 NotSupportedError
    evidence ecdsa-curves: Firefox: P-256 true; P-384 true; P-521 true; secp256k1 NotSupportedError; K-256 NotSupportedError; brainpoolP256r1 NotSupportedError
    evidence ecdsa-curves: WebKit: P-256 true; P-384 true; P-521 true; secp256k1 NotSupportedError; K-256 NotSupportedError; brainpoolP256r1 NotSupportedError
  • EdDSA

    Ed25519

    Ed25519 signs and verifies in all four engines.

    Recorded by the check
    fact eddsa = Ed25519
  • Ed448 support by engine

    Node.js

    Ed448 worked only in Node.js, which flags it as experimental; Chromium, Firefox and WebKit refused it.

    Recorded by the check
    fact ed448-engines = Node.js
  • Post-quantum algorithms

    none

    No post-quantum algorithm works in all four engines yet, so portable code cannot rely on one.

    Recorded by the check
    fact pqc = none
    evidence pqc: Node.js: ML-DSA-44 true; ML-DSA-65 true; ML-DSA-87 true; ML-KEM-512 true; ML-KEM-768 true; ML-KEM-1024 true
    evidence pqc: Chromium: ML-DSA-44 true; ML-DSA-65 true; ML-DSA-87 true; ML-KEM-512 NotSupportedError; ML-KEM-768 true; ML-KEM-1024 true
    evidence pqc: Firefox: ML-DSA-44 NotSupportedError; ML-DSA-65 NotSupportedError; ML-DSA-87 NotSupportedError; ML-KEM-512 NotSupportedError; ML-KEM-768 NotSupportedError; ML-KEM-1024 NotSupportedError
    evidence pqc: WebKit: ML-DSA-44 NotSupportedError; ML-DSA-65 NotSupportedError; ML-DSA-87 NotSupportedError; ML-KEM-512 TypeError; ML-KEM-768 TypeError; ML-KEM-1024 TypeError
  • ML-DSA support by engine

    Node.jsChromium

    ML-DSA-44, ML-DSA-65 and ML-DSA-87 signed and verified in Node.js (experimental) and Chromium 156; Firefox and WebKit raised NotSupportedError.

    Recorded by the check
    fact ml-dsa-engines = Node.js, Chromium
  • ML-KEM support by engine

    Node.jsChromium

    encapsulateBits and decapsulateBits with ML-KEM-768 worked in Node.js and Chromium 156; Chromium refused ML-KEM-512, and Firefox and WebKit supported no ML-KEM at all.

    Recorded by the check
    fact ml-kem-engines = Node.js, Chromium
  • Public key formats

    spki-derjwksec1-uncompressed

    Every engine imported SPKI as DER bytes, a JWK and the uncompressed SEC1 point ("raw"); PEM text and a 64-byte x‖y without the 0x04 prefix raised DataError.

    Recorded by the check
    fact public-key-formats = spki-der, jwk, sec1-uncompressed
    evidence public-key-formats: Node.js: spki-pem DataError; spki-der true; jwk true; sec1-uncompressed true; sec1-compressed true; raw-xy DataError
    evidence public-key-formats: Chromium: spki-pem DataError; spki-der true; jwk true; sec1-uncompressed true; sec1-compressed true; raw-xy DataError
    evidence public-key-formats: Firefox: spki-pem DataError; spki-der true; jwk true; sec1-uncompressed true; sec1-compressed true; raw-xy DataError
    evidence public-key-formats: WebKit: spki-pem DataError; spki-der true; jwk true; sec1-uncompressed true; sec1-compressed DataError; raw-xy DataError
  • Compressed points by engine

    Node.jsChromiumFirefox

    A compressed 33-byte point imported with "raw" in Node.js, Chromium and Firefox, but WebKit raised DataError.

    Recorded by the check
    fact compressed-point-engines = Node.js, Chromium, Firefox
  • Private key formats

    pkcs8-derjwk

    Private keys import as PKCS#8 DER or JWK only; a bare 32-byte scalar was rejected by every engine.

    Recorded by the check
    fact private-key-formats = pkcs8-der, jwk
    evidence private-key-formats: Node.js: pkcs8-der true; jwk true; raw-scalar SyntaxError
    evidence private-key-formats: Chromium: pkcs8-der true; jwk true; raw-scalar SyntaxError
    evidence private-key-formats: Firefox: pkcs8-der true; jwk true; raw-scalar DataError
    evidence private-key-formats: WebKit: pkcs8-der true; jwk true; raw-scalar SyntaxError

Usage

Sign with P-256, import a PEM public key by decoding it to DER, and verify — the signature is the 64-byte raw form.
// The same code runs in browsers, Deno, Bun and Node.js (globalThis.crypto).
const { subtle } = globalThis.crypto;
const curve = { name: "ECDSA", namedCurve: "P-256" };
const ecdsa = { name: "ECDSA", hash: "SHA-256" };

const { privateKey, publicKey } = await subtle.generateKey(curve, true, ["sign", "verify"]);
const data = new TextEncoder().encode("order #1042 approved");
const signature = new Uint8Array(await subtle.sign(ecdsa, privateKey, data));

// A PEM public key from another system: importKey("spki") wants the DER bytes,
// so strip the armour and base64-decode first. PEM text itself is a DataError.
const spki = new Uint8Array(await subtle.exportKey("spki", publicKey));
const pem = `-----BEGIN PUBLIC KEY-----\n${btoa(String.fromCharCode(...spki))}\n-----END PUBLIC KEY-----`;
const der = Uint8Array.from(atob(pem.replace(/-----[^-]+-----|\s/g, "")), (c) => c.charCodeAt(0));
const imported = await subtle.importKey("spki", der, curve, false, ["verify"]);

// Always 64 bytes on P-256: raw r||s, never DER. Convert before handing it to OpenSSL or Java.
console.log("signature bytes:", signature.length);
console.log("verified:", await subtle.verify(ecdsa, imported, signature, data));

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