function pemToBytes(pem) { const b64 = pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""); return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));}const key = await crypto.subtle.importKey( "spki", // the body of "BEGIN PUBLIC KEY" pemToBytes(publicKeyPem), { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"],); const ok = await crypto.subtle.verify( { name: "ECDSA", hash: "SHA-256" }, key, rawSignature, // exactly 64 bytes: r‖s new TextEncoder().encode(message),);console.log(ok ? "valid" : "INVALID");How it works
- The curve is declared at import time and must match the key —
importKeyrejects an SPKI whose curve differs fromnamedCurve. - The hash is declared at verify time. Conventional pairings: P-256 with SHA-256, P-384 with SHA-384, P-521 with SHA-512.
verifyresolves to a boolean for a wrong signature; a rejected promise means the inputs themselves are malformed.
Gotchas
- WebCrypto accepts only raw r‖s signatures. A DER signature from OpenSSL or Node's default
signquietly returnsfalse— no error. Convert DER → raw first. - secp256k1 is not in WebCrypto:
namedCurvesupports P-256, P-384 and P-521 only. For Bitcoin/Ethereum keys use a library such as @noble/curves. crypto.subtleexists only in secure contexts (HTTPS or localhost) — on plain HTTP it isundefinedand every call fails before cryptography even starts.