Hello
Connecting to the chain…
◆ THESILENTKNIGHT · FULL-STACK & WEB3
Orbit · your guide
Hey 👋 I'm Orbit, Pramod's guide. Scroll on and I'll show you around.
All posts
6 min read

Hardening wallet login: what a signature proves, and what it doesn't

Recovering an address from a signature is one line of code. Making wallet login safe is everything around that line: server-issued nonces, domain binding, expiry, and contract wallets.

Web3SecurityDjangoAuthentication
Hardening wallet login: the checks around an Ethereum signature that make it safe

Short answer: wallet login is a challenge-response flow. The server issues a one-time nonce, the user signs a structured message containing it, and the server recovers the address from the signature and compares. The signature check itself is one library call and it is the least important part. What makes the login safe is the message around it: a server-issued single-use nonce so a captured signature cannot be replayed, a domain field so a signature for one site cannot be relayed to another, an expiry so old approvals die, and a separate verification path for smart-contract wallets, which cannot produce a recoverable signature at all.

I built wallet login as part of BlockAuth, an open-source (MIT) authentication package for Django REST Framework that bridges Web2 and Web3 login, BloclabsHQ/auth-pack. The snippets below are simplified for clarity; production specifics are omitted.

Why is a bare signature check not enough?

Because a signature proves possession of a key at some moment, for some message. It does not prove the message was meant for you, meant for now, or never used before. The three attacks that follow from that are the whole threat model:

  • Replay. If the signed message is static ("Login to MyApp"), anyone who ever sees one valid signature can log in as that wallet forever.
  • Cross-site relay. If the message does not name your origin, a malicious site can ask a user to "verify your wallet", collect the signature, and present it to your API. The user signed exactly what your server expected, just not for you.
  • Stale approval. Without a timestamp and expiry, a signature harvested months ago is as good as a fresh one.

Every hardening step below closes one of these.

What should the signed message actually contain?

Use the Sign-In with Ethereum structure (EIP-4361) rather than inventing a message format. It is human-readable in the wallet prompt, and every field exists to close one of the holes above:

the message the wallet shows
app.example.com wants you to sign in with your Ethereum account:
0x3C44...93BC
 
URI: https://app.example.com/login
Version: 1
Chain ID: 1
Nonce: 8f4d2a91c07b
Issued At: 2026-07-07T10:24:00Z
Expiration Time: 2026-07-07T10:34:00Z

The parts that matter:

  • Domain and URI bind the signature to your origin. On verification, compare them against your own configuration, never against what the client sent.
  • Nonce is issued by your server, stored, and consumed on first use. At least 8 random alphanumeric characters; tie it to the session or wallet address that requested it.
  • Issued At and Expiration Time give the approval a lifetime. Ten minutes is generous; a signature is produced in seconds.
  • Chain ID stops a signature scoped to a testnet from being replayed against mainnet.

The wallet signs this text with the EIP-191 personal_sign prefix, so the signature can never double as a valid transaction.

How do you verify the signature on the server?

Recover the address from the signed message and compare it, then consume the nonce. In Python the recovery is eth-account:

wallet login verification (simplified)
from eth_account import Account
from eth_account.messages import encode_defunct
 
def verify_wallet_login(address: str, message: str, signature: str) -> bool:
    fields = parse_siwe(message)
 
    # 1) The message must be for us, and for now
    if fields.domain != settings.AUTH_DOMAIN:
        return False
    if fields.expiration_time < now():
        return False
 
    # 2) The nonce must be one we issued, unused, and is spent here
    if not consume_nonce(fields.nonce, address):
        return False
 
    # 3) Only now does the signature itself get checked
    recovered = Account.recover_message(
        encode_defunct(text=message), signature=signature
    )
    return recovered.lower() == address.lower()

Two details that bite in production. Compare addresses case-insensitively or through a checksum routine, because wallets disagree about casing and a byte-for-byte compare will reject valid logins. And make the nonce check happen before signature recovery and consume the nonce even on a failed attempt, so an attacker cannot brute-force against the same challenge.

What about smart-contract wallets?

Address recovery only works for externally owned accounts. A smart-contract wallet, a Safe, or anything built on account abstraction has no private key to recover against, so recover_message can never match. The standard answer is ERC-1271: call isValidSignature(hash, signature) on the wallet contract itself and accept the login if it returns the magic value 0x1626ba7e.

That changes your architecture in one important way: verification now needs an RPC connection to the right chain, not just a hash function. Budget for it, cache it, and decide explicitly whether a chain outage should block logins or fail closed. If you skip ERC-1271, you are silently telling every smart-wallet user that login is broken.

How does a verified wallet become a session?

The same way any login does: issue your normal session or token pair and treat the wallet address as the account identifier. In BlockAuth the wallet endpoint returns the same JWT access and refresh pair as the password flow, and a first-time address creates the user on the spot. That symmetry is the point of bridging Web2 and Web3 auth in one package: downstream code checks one identity model, and a user who arrived by wallet can later attach an email, or the reverse.

Being straight about scope: the package ships the signature-verification login flow; the EIP-4361 message structure, nonce lifecycle, and ERC-1271 path described here are the hardening you own around any wallet login, whatever library issues the tokens.

Key takeaways

  • A signature proves key possession, not intent. The message structure carries the security: nonce, domain, expiry, chain id.
  • Nonces are server-issued, single-use, and consumed even on failure. A static login message is a permanent credential leak waiting to happen.
  • Verify the domain field against your own config. This is the only thing standing between you and cross-site signature relay.
  • Contract wallets cannot be verified by recovery. Support ERC-1271 or accept that a growing share of users cannot log in.
  • After verification, wallet login is just login. Issue the same session your password flow issues and keep one identity model.

Questions you might have next

  • Should I use a SIWE library or write the parser myself? Use a maintained implementation for parsing and field validation if one fits your stack, and keep the nonce store and domain check in your own code either way, since they depend on your infrastructure.
  • Where do I store the nonce? Anywhere with atomic consume semantics: a database row with a unique constraint or a cache entry deleted on first read. The property you need is that two concurrent attempts cannot both succeed.
  • Does this replace passwords? It can, but pairing works better in practice. A wallet proves control of an address; an email gives you recovery and communication. BlockAuth treats them as two doors into one account, which is also how the derived-wallet onboarding flow closes the loop from the other direction.

Authentication is one of the seams that decide whether an onchain product ships. For the full map, see building production Web3 apps.

© 2026 Pramod Kodag · TheSilentKnight