Skip to content
ecdsa.com

ML-DSA-65 sign/verify · WebCrypto

How to sign and verify with ML-DSA-65 in WebCrypto

To sign and verify with ML-DSA-65 in WebCrypto, call crypto.subtle.generateKey({ name: "ML-DSA-65" }, …), then crypto.subtle.sign and crypto.subtle.verify with the same algorithm object; a context string goes into that object. This is the algorithm set of the WICG "Modern Algorithms in WebCrypto" proposal: the recipe runs the same page in Chromium, Firefox and WebKit and shows which of them support it today.

Tested against the WebCrypto API in Chromium 156.0.8078.4 (Playwright 1.64.1 headless, crypto.subtle), Firefox 157.0 (Playwright 1.64.1: ML-DSA not supported), and WebKit 27.2 (Playwright 1.64.1: ML-DSA not supported) — the same crypto.subtle interface browsers implement.

mldsa.mjs — sign, verify, context string, and verification with an SPKI public key
const algorithm = { name: "ML-DSA-65" };
const encoder = new TextEncoder();
export async function signAndVerify() {
const { publicKey, privateKey } = await crypto.subtle.generateKey(algorithm, true, ["sign", "verify"]);
const message = encoder.encode("hello, post-quantum world");
const signature = await crypto.subtle.sign(algorithm, privateKey, message);
const raw = await crypto.subtle.exportKey("raw-public", publicKey); // the bare 1952-byte key
console.log("public key", raw.byteLength, "bytes, signature", signature.byteLength, "bytes");
console.log("verified:", await crypto.subtle.verify(algorithm, publicKey, signature, message));
console.log("tampered message:", await crypto.subtle.verify(algorithm, publicKey, signature, encoder.encode("hello, post-quantum world!")));
// The context string is part of the algorithm parameters, on both sides
const withContext = { ...algorithm, context: encoder.encode("invoice-v1") };
const invoiceSignature = await crypto.subtle.sign(withContext, privateKey, message);
console.log("with the context:", await crypto.subtle.verify(withContext, publicKey, invoiceSignature, message),
"without:", await crypto.subtle.verify(algorithm, publicKey, invoiceSignature, message));
}
// Verify with an SPKI public key (the DER inside a "BEGIN PUBLIC KEY" file, e.g. from OpenSSL 3.5)
export async function verifyWithSpki(spki, message, signature) {
const publicKey = await crypto.subtle.importKey("spki", spki, algorithm, false, ["verify"]);
return crypto.subtle.verify(algorithm, publicKey, signature, message);
}

How it works

  1. generateKey({ name: "ML-DSA-65" }, true, ["sign", "verify"]) creates the pair; exportKey("raw-public", publicKey) returns the 1952-byte public key.
  2. sign({ name: "ML-DSA-65" }, privateKey, message) returns a 3309-byte ArrayBuffer; verify returns true or false.
  3. A context string is a property of the algorithm object: { name: "ML-DSA-65", context }, used on both sides.
  4. importKey("spki", der, { name: "ML-DSA-65" }, false, ["verify"]) takes the DER inside OpenSSL's BEGIN PUBLIC KEY file; the run verifies OpenSSL's signature in the browser.

Gotchas

  • Support is not universal yet: Chromium 156 runs every step, while Firefox 157 and WebKit 27.2 reject generateKey with NotSupportedError. Feature-detect and fall back, or keep the operation on the server.
  • Signing is randomized, so the same message gives a different signature each time.
  • Node.js 24 has the same API in crypto.subtle, but marks it experimental and prints an ExperimentalWarning on first use.
  • exportKey("raw-public") is the bare key; use "spki" when another library expects a BEGIN PUBLIC KEY file.

Related recipes