Skip to content
ecdsa.com
Python

web3.py (eth-account)

The Ethereum Foundation's web3.py, whose signing is done by eth-account: 65-byte r‖s‖v signatures with v = 27/28, EIP-191 and RFC 6979 — and, like viem but unlike ethers, recovery from high-S signatures.
License
MIT
Checked version
8.0.0 ·
Source
github.com/ethereum/web3.py
Documentation
web3py.readthedocs.io/en/stable/

Compatibility at a glance

ECDSAEthereum and Bitcoin

ECDSA curves
secp256k1
EdDSA
none

web3.py is the Python client for Ethereum nodes; message and transaction signing are delegated to the eth-account package (0.14.0 with web3 8.0.0), which in turn uses eth-keys for the curve arithmetic. Most signing code imports eth_account directly, as the sample does.

Account.sign_message(encode_defunct(text=...)) implements personal_sign: the EIP-191 prefix with the decimal byte length, keccak-256, an RFC 6979 nonce (matched against an independent reference signer) and s in the low half, returned as 65 bytes ending in v = 27 or 28. encode_defunct is the piece people forget: signing raw bytes with unsafe_sign_hash skips the prefix, which is exactly what makes a signature unusable as a login proof elsewhere.

Account.recover_message() recovered the signer from the mirrored high-S signature as well as from v = 0/1, so it accepts more forms than ethers does — normalize s yourself if signatures are stored or deduplicated. The 64-byte EIP-2098 compact form was rejected with Unexpected recoverable signature length: Expected 65, but got 64 bytes; expand it before recovery.

For typed data use encode_typed_data (EIP-712) rather than hashing the JSON yourself, and check what a wallet is being asked to sign with the EIP-712 inspector. Smart-contract wallets need an ERC-1271 call to the contract instead of recover_message; sign-in messages are easiest to debug in the SIWE verifier.

The sample signs a sign-in message and compares the recovered address — the comparison that should replace any byte-for-byte check of signatures.

Facts and the checks behind them

Nothing below is copied from documentation. The check content/libraries/verify/web3py/run.sh ran web3.py (eth-account) 8.0.0 in the container image python:3.14.8-slim-trixie on October 20, 2026; the page must match what it printed, and each fact lists those lines.

  • ECDSA signature encoding

    rsv-65

    sign_message() returns 65 bytes: r, s and v.

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

    27-28

    v is 27 or 28.

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

    eip-191

    The digest equals keccak-256 of \x19Ethereum Signed Message:\n, the decimal length and the message, computed independently.

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

    keccak256

    Message signatures use keccak-256.

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

    hashes-message-or-digest

    sign_message() hashes with the EIP-191 prefix; unsafe_sign_hash() 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: unsafe_sign_hash over the EIP-191 digest equals sign_message: True
  • ECDSA nonce

    rfc6979

    RFC 6979: the signature matched an independent reference implementation with s normalized.

    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

    accepts-high-s

    recover_message() recovered the signer from (r, n − s) with v flipped.

    Recorded by the check
    fact ecdsa-low-s-verify = accepts-high-s
    evidence ecdsa-low-s-verify: recover_message on (r, n - s, flipped v): the signer
  • Signature shapes accepted

    v-27-28v-0-1

    65-byte signatures with v = 27/28 or v = 0/1 recovered the signer; the 64-byte EIP-2098 form raised ValidationError: Unexpected recoverable signature length: Expected 65, but got 64 bytes.

    Recorded by the check
    fact signature-input-shapes = v-27-28, v-0-1
    evidence signature-input-shapes: v-27-28: recovers the signer
    evidence signature-input-shapes: v-0-1: recovers the signer
    evidence signature-input-shapes: eip-2098: ValidationError: Unexpected recoverable signature length: Expected 65, but got 64 bytes
  • 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

Usage

Sign a sign-in message with personal_sign semantics and recover the signer's address.
from eth_account import Account
from eth_account.messages import encode_defunct

account = Account.create()
message = encode_defunct(text="Sign in to example.com\nNonce: 8f2c1a")

# personal_sign: EIP-191 prefix + keccak-256, RFC 6979, low-S, v = 27 or 28.
signed = account.sign_message(message)

# Recover and compare addresses; checksummed strings compare exactly.
signer = Account.recover_message(message, signature=signed.signature)
print("signature bytes:", len(signed.signature), "v:", signed.v)
print("signer matches:", signer == account.address)

The snippet harness runs every sample verbatim in the container image python:3.14.8-slim-trixie, and the run fails unless the output matches.

Setup
pip install --quiet --root-user-action=ignore web3==8.0.0 eth-account==0.14.0
Command
python usage.py
Output must match (regular expression)
signature bytes: 65 v: 2[78]\nsigner matches: True

Libraries