Generating the key pair
Clicking "Generate P-256 key pair" asks the browser for an ECDSA key pair on the P-256 curve — a random private scalar d, plus the public point Q = dG computed the same way as the ECC module's scalar multiplication.
const keyPair = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" }, // secp256r1 — the only curve this tool uses
true, // extractable — so this tool can export and show both keys
["sign", "verify"]
);
const publicKeyBytes = await crypto.subtle.exportKey("spki", keyPair.publicKey);
const privateKeyBytes = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey);namedCurve is the only curve-choosing option WebCrypto exposes for ECDSA — "P-256", "P-384", and "P-521" are the only values every implementation is required to support.
Hashing the message first
ECDSA never signs raw message bytes directly — the hash: "SHA-256" parameter in this tool's sign() call means your message is reduced to a 256-bit digest first, and everything below operates on that digest, not your original text.
const signature = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" }, // message is hashed with this before signing
privateKey,
new TextEncoder().encode(message)
);The hash option here is independent of the curve — you could pair P-256 with SHA-384 if you wanted, though matching them (as this tool does) is the conventional choice.
Choosing k and computing (r, s)
Your browser generates a fresh random nonce k for this specific signature — never reused, never exposed by this tool — and combines it with your private key exactly as covered in the ECC module's nonce-leak section.
// Same call as above. Generating k and computing (r, s) both happen
// inside sign() — k is never returned to JavaScript, by design: a
// leaked or reused k lets an attacker recover the private key d.
const signature = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
privateKey,
new TextEncoder().encode(message)
);This is a deliberate API design choice, not an oversight — exposing k would make it far too easy for a caller to accidentally reuse it across two signatures, which fully breaks ECDSA (covered in the ECC module).
The signature you copy is raw r‖s, not DER
The base64 string this tool shows you decodes to exactly 64 bytes for P-256: the 32-byte big-endian r immediately followed by the 32-byte big-endian s (the IEEE P1363 format). This is a Web Crypto–specific choice — most other libraries, and the ASN.1 DER encoding used inside X.509 certificates, wrap r and s in a different structure, so raw Web Crypto signatures aren't directly interchangeable with those formats without conversion.
new Uint8Array(signature).length === 64 // true for P-256, always — no DER wrapperCompare this to Node's crypto.sign(), which by default returns DER-encoded ASN.1 — a different byte length that varies slightly signature to signature, since DER integer encoding is variable-width.
Verifying: recomputing a point, not decrypting
Verification is not "decrypt the signature and compare" — ECDSA has no decryption step. Instead, the verifier computes two values from the signature and the public key, uses them to reconstruct a curve point, and checks whether that point's x-coordinate equals r.
Swap in a different public key, as this tool's "wrong key" button does, and Q changes — which changes the reconstructed point entirely. The odds that an unrelated point's x-coordinate happens to equal the original r are astronomically small, which is exactly why verification fails cleanly rather than partially.
const publicKey = await crypto.subtle.importKey(
"spki",
publicKeyBytes,
{ name: "ECDSA", namedCurve: "P-256" },
false,
["verify"]
);
const valid = await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
publicKey,
signatureBytes,
new TextEncoder().encode(message)
);
// valid is a plain boolean — no exception on a bad signature, unlike decrypt()Unlike RSA-OAEP's decrypt() or AES-GCM's decrypt(), verify() doesn't throw when a signature is invalid — it just resolves to false. "Couldn't verify" in this tool's status banner only shows up for a structurally malformed key or signature, which is a separate failure caught by the try/catch around this code.