← Back to the tool

Public-key · Under the hood

ECDSA sign & verify

What each button in the tool actually computes, step by step — with the real formulas.

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.

The actual WebCrypto call
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.

The actual WebCrypto call
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.

No separate call — also inside sign()
// 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).

r=(k⋅G)x mod ns=k−1(H(m)+r⋅d) mod nr = (k \cdot G)_x \bmod n \qquad s = k^{-1}(H(m) + r \cdot d) \bmod n

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.

Checking it yourself
new Uint8Array(signature).length === 64  // true for P-256, always — no DER wrapper

Compare 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.

∣signature∣=2×32=64 bytes, for P-256|\text{signature}| = 2 \times 32 = 64 \text{ bytes, for P-256}

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.

The actual WebCrypto calls
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.

u1=H(m)s−1 mod nu2=rs−1 mod n(u1G+u2Q)x=?ru_1 = H(m) s^{-1} \bmod n \qquad u_2 = r s^{-1} \bmod n \qquad (u_1 G + u_2 Q)_x \stackrel{?}{=} r

Now try it

Back to ECDSA sign & verify →

Generate a real P-256 key pair, sign a message, verify it — then tamper with the message and watch verification reject it.