Skip to content
ecdsa.com
Node.js · WebCrypto

ethers.js

ethers.js v6, the long-standing JavaScript library for Ethereum wallets and contracts: 65-byte r‖s‖v signatures with v = 27/28, EIP-191 message hashing, RFC 6979 nonces and a hard refusal of high-S signatures.
License
MIT
Checked version
6.17.0 ·
Source
github.com/ethers-io/ethers.js
Documentation
docs.ethers.org/v6/

Compatibility at a glance

ECDSAEthereum and Bitcoin

ECDSA curves
secp256k1
EdDSA
none
Public key formats
sec1-uncompressedsec1-compressedraw-xy

ethers covers everything an application needs to talk to Ethereum — providers, contracts, ABI coding, ENS — and its Wallet and SigningKey classes are what most JavaScript code uses to sign. The curve arithmetic comes from @noble/curves; ethers 6.17.0 pins version 1.2.0 of it.

wallet.signMessage() implements personal_sign: it prefixes the message with \x19Ethereum Signed Message:\n and its length (EIP-191), hashes with keccak-256, and returns 65 bytes of r ‖ s ‖ v with v = 27 or 28. The check confirmed the digest against an independent keccak implementation and the signature against @noble/curves' RFC 6979 output, so the same key and message always give the same bytes. To sign a hash you already computed — an EIP-712 digest, a transaction hash — use SigningKey.sign(), which takes exactly 32 bytes and adds no prefix; anything else fails with invalid digest length.

On input ethers is tolerant about shape and strict about form. verifyMessage() recovered the signer from v = 27/28, from v = 0/1 and from the 64-byte EIP-2098 compact form, but refused the mirrored high-S signature with non-canonical s; use ._s — even though it is mathematically valid. Signatures produced by OpenSSL, cloud KMS or hardware modules that do not normalize s must be converted to low-S (s → n − s, flip v) before ethers accepts them; the Ethereum signature verifier shows which form a signature is in.

Most verification bugs in wallet logins are not about the curve at all: a message that differs by a newline or a trailing space, a different chain id in an EIP-712 domain, or a smart-contract wallet whose signatures need ERC-1271 instead of ecrecover. Inspect typed data with the EIP-712 inspector and full sign-in messages with the SIWE verifier.

The sample signs a sign-in message, recovers the address and shows the compact form; compare recovered addresses, never signature bytes, because the same signature has several valid encodings.

Facts and the checks behind them

Nothing below is copied from documentation. The check content/libraries/verify/ethers/run.sh ran ethers.js 6.17.0 on Node.js 24.22.0; ethers depends on @noble/curves 1.2.0 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

    rsv-65

    signMessage() returns 65 bytes as hex: r, s and the recovery byte v.

    Recorded by the check
    fact ecdsa-signature-encoding = rsv-65
  • Recovery id v

    27-28

    v is 27 or 28 in the signatures ethers produces.

    Recorded by the check
    fact recovery-v = 27-28
  • Message prefix

    eip-191

    hashMessage() equals keccak-256 of \x19Ethereum Signed Message:\n, the decimal byte length and the message, computed independently with @noble/hashes.

    Recorded by the check
    fact message-prefix = eip-191
  • Default hash

    keccak256

    Message signatures use keccak-256, not SHA-256.

    Recorded by the check
    fact ecdsa-default-hash = keccak256
  • What the signing call hashes

    hashes-message-or-digest

    signMessage() hashes the message with the EIP-191 prefix; SigningKey.sign() signs a 32-byte digest unchanged and produced the identical signature for that digest.

    Recorded by the check
    fact ecdsa-prehash = hashes-message-or-digest
    evidence ecdsa-prehash: SigningKey.sign over the EIP-191 digest equals signMessage: true; a 2-byte input: invalid digest length
    evidence ecdsa-prehash: recoverAddress(digest, signature) returns the signer: true
  • ECDSA nonce

    rfc6979

    RFC 6979: the signature matched @noble/curves' deterministic signature over the same digest.

    Recorded by the check
    fact ecdsa-nonce = rfc6979
  • Low-S when signing

    low-s

    No high-S value among 64 message signatures.

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

    rejects-high-s

    The mirrored signature (r, n − s) with v flipped is refused by verifyMessage() and Signature.from() with non-canonical s; use ._s.

    Recorded by the check
    fact ecdsa-low-s-verify = rejects-high-s
    evidence ecdsa-low-s-verify: verifyMessage with (r, n - s, flipped v): non-canonical s; use ._s
    evidence ecdsa-low-s-verify: Signature.from on the high-S twin: non-canonical s; use ._s
  • Signature shapes accepted

    v-27-28v-0-1eip-2098

    verifyMessage() recovered the signer from 65-byte signatures with v = 27/28 or v = 0/1 and from the 64-byte EIP-2098 compact encoding.

    Recorded by the check
    fact signature-input-shapes = v-27-28, v-0-1, eip-2098
    evidence signature-input-shapes: 65-byte v 27/28: recovers the signer
    evidence signature-input-shapes: 65-byte v 0/1: recovers the signer
    evidence signature-input-shapes: 64-byte EIP-2098: recovers the signer
  • ECDSA curves

    secp256k1

    secp256k1 only.

    Recorded by the check
    fact ecdsa-curves = secp256k1
  • EdDSA

    none

    No EdDSA.

    Recorded by the check
    fact eddsa = none
  • Post-quantum algorithms

    none

    No post-quantum algorithms.

    Recorded by the check
    fact pqc = none
  • Public key formats

    sec1-uncompressedsec1-compressedraw-xy

    SigningKey.computePublicKey() converted between compressed and uncompressed SEC1 points and also accepted a 64-byte x‖y without the 0x04 prefix.

    Recorded by the check
    fact public-key-formats = sec1-uncompressed, sec1-compressed, raw-xy
    evidence public-key-formats: 64-byte x||y: true

Usage

Sign a sign-in message with personal_sign semantics, recover the signer, and show the 65-byte and EIP-2098 forms.
import { Signature, Wallet, verifyMessage } from "ethers";

const wallet = Wallet.createRandom();
const message = "Sign in to example.com\nNonce: 8f2c1a";

// personal_sign: EIP-191 prefix + keccak256, RFC 6979 nonce, low-S, v = 27 or 28.
const signature = await wallet.signMessage(message);
const { v, compactSerialized } = Signature.from(signature);

// Recover the address and compare it; never compare signature bytes.
const signer = verifyMessage(message, signature);

console.log("signature bytes:", (signature.length - 2) / 2, "v:", v);
console.log("signer matches:", signer === wallet.address);
console.log("EIP-2098 compact bytes:", (compactSerialized.length - 2) / 2);

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.

Setup
npm init -y >/dev/null && npm install --no-audit --no-fund ethers@6.17.0
Command
node usage.mjs
Output must match (regular expression)
signature bytes: 65 v: 2[78]\nsigner matches: true\nEIP-2098 compact bytes: 64

Libraries