Writing

Sign-In with Ethereum When the Wallet Is a Gnosis Safe

· Blockchain · Engineering Notes, Web3

Drafted August 2023. Finished and published August 2026 as part of the migration away from WordPress.

Sign-In with Ethereum (SIWE), specified in EIP-4361, gives Ethereum accounts a common message format for authenticating with off-chain services.

The implementation in this note did not use EIP-4361. It used the same basic signing and recovery pattern with a fixed statement. That worked for externally owned accounts, but in July we found that the assumption did not extend cleanly to a Gnosis Safe.

MetaMask only began recognising SIWE messages in March this year. Before that integration, a valid SIWE message appeared as a generic request to sign plaintext. MetaMask can now parse the fields and present the action as a sign-in request.

Before wallet recognition

A real MetaMask signature request from 2021, showing a wallet message as unstructured plaintext
A MetaMask signature request from November 2021. Source: Signature request message unreadable.

After wallet recognition

A real MetaMask sign-in request showing recognised fields including URI, version, chain ID, nonce and issued-at time
MetaMask presenting a recognised EIP-4361 message as a sign-in request. Source: SpruceID's MetaMask integration announcement.

The implementation we had

The backend held a fixed statement and its hash. The flow was:

  1. The frontend sent the connected wallet address in the API path.
  2. The backend checked the database for that address and the current statement hash.
  3. If no record existed, the backend returned the statement for the wallet to sign.
  4. The frontend sent the resulting signature back using the same address in the path.
  5. The backend recovered the signer, compared the addresses and stored the acceptance.

The database recorded when the acceptance was stored, but the timestamp was not part of the signed message. There was no nonce in the message either. This was a persistent acceptance record, not a general-purpose login challenge or a session-token system.

The backend used Web3 to recover the signer:

const signerAddress = web3.eth.accounts.recover(
  messageToSign,
  signature,
)

const valid =
  signerAddress.toLowerCase() === walletAddress.toLowerCase()

This differs from EIP-4361 in several important ways:

July implementationEIP-4361
Fixed application statementStructured sign-in message
Wallet address passed in the API pathAddress included in the signed message
No domain or URI in the messageRequest bound to a domain and URI
No chain ID in the messageChain ID included
No nonce or validity periodNonce and issued-at time required, with optional validity times
EOA address recoveryERC-191 for EOAs and ERC-1271 for contract accounts

The fixed statement suited this acceptance flow. It would not suit a reusable login because the same signature could be replayed. A login system needs a unique, consumed challenge.

What failed

A user connecting through a wallet setup backed by a Gnosis Safe could not get past the signature step. At first, wallet connection and signature verification were both suspected.

The frontend was using an older wallet-connection stack. We updated it to web3-react 8, added WalletConnect v2 and enabled the Gnosis Safe connector. The Safe connector also required the application to be loaded in a Safe app context. That addressed the connection side, but the backend still rejected some off-chain signatures.

A Rabby and Ledger combination also failed even though the same Ledger worked through MetaMask. Several wallet-specific problems were present, but the backend had one definite limitation: it only knew how to recover an EOA signer.

Why EOA recovery is not enough

An EOA address is derived from a public key. A Gnosis Safe address belongs to a smart contract and has no corresponding private key.

A Safe can require several owners to approve an action. Its signature bytes can contain multiple owner approvals, or the Safe can record approval of a message on-chain. Recovering one owner’s address would not prove that the Safe approved the message under its threshold rules.

This is the problem ERC-1271 was designed to solve. Instead of recovering an address locally, the verifier asks the contract wallet whether a signature is valid.

The fallback

I added an on-chain route for wallets whose off-chain signature was rejected. Both routes started with the same fixed statement from the backend.

Sequence diagram showing the backend returning a fixed statement, an EOA signing it for off-chain verification, and a Gnosis Safe submitting its hash through the on-chain fallback

The existing EOA route and the Gnosis Safe fallback both prove acceptance of the same backend statement.

The frontend hashed the statement and offered a transaction to a small contract:

const messageHash = web3.eth.accounts.hashMessage(messageToSign)

await contract.methods
  .submitSignature(messageHash)
  .send({ from: walletAddress })

The contract stored the latest hash against msg.sender. When a Gnosis Safe executed the transaction, the submitting address was the Safe itself.

The backend checked both possible records:

const provider = new ethers.providers.JsonRpcProvider(rpcUrl)
const signatures = new ethers.Contract(contractAddress, abi, provider)

const latestOnChainHash = await signatures.latestHash(walletAddress)
const accepted =
  databaseMessageHash === expectedMessageHash ||
  latestOnChainHash === expectedMessageHash

The backend accepted either record, allowing the frontend to continue past the acceptance step. The fallback reached production in late July.

Why I used a separate contract

The reason was time. We were focused on other features, and I did not know how to implement ERC-1271 for a Safe. I knew how to build and query a small contract, so this was the quickest fallback I could ship. It requires a transaction, costs the user gas and creates another approval mechanism to maintain.

Where ERC-1271 support stands

ERC-1271 is not new. Gnosis Safe 1.3.0, released in May 2021, moved its validation into the compatibility fallback handler. The November 2021 libraries release included an audited and deployed SignMessageLib.

That does not make the complete integration automatic. In an April 2023 Safe Wallet issue, the Safe team described two existing options. Recording a message on-chain costs gas and does not work well with applications expecting an immediately returned signature. The gasless option requires the application to integrate with the Safe Transaction Service.

Safe Wallet added synchronous off-chain signing in version 1.10.0 on 22 May.

The route I would take next

For a reusable login flow, I would use an EIP-4361 message and separate EOA verification from contract-wallet verification:

  1. Parse the EIP-4361 message and validate its domain, URI, chain ID, nonce and time fields.
  2. Select the RPC provider using the message’s chain ID.
  3. Recover and compare the signer if the address is an EOA.
  4. Call isValidSignature if the address contains contract code.
  5. Consume the nonce once so the signature cannot be replayed.

Using ethers 5, the verification can be reduced to a function that completes only when the signature is valid:

import { ethers } from 'ethers'

const ERC1271_ABI = [
  'function isValidSignature(bytes32 hash, bytes signature) view returns (bytes4)',
]
const ERC1271_MAGIC_VALUE = '0x1626ba7e'

async function requireValidWalletSignature(
  provider: ethers.providers.Provider,
  address: string,
  message: string,
  signature: string,
): Promise<void> {
  const code = await provider.getCode(address)

  if (code === '0x') {
    const signer = ethers.utils.verifyMessage(message, signature)
    if (signer.toLowerCase() !== address.toLowerCase()) {
      throw new Error('Invalid EOA signature')
    }
    return
  }

  const wallet = new ethers.Contract(address, ERC1271_ABI, provider)
  const hash = ethers.utils.hashMessage(message)
  const result = await wallet.isValidSignature(hash, signature)

  if (result.toLowerCase() !== ERC1271_MAGIC_VALUE) {
    throw new Error('Invalid contract signature')
  }
}

For a Safe, this assumes that an ERC-1271-compatible fallback handler is configured. The verification call is read-only, but obtaining the approval remains separate. The application can collect the required owner signatures off-chain and pass the combined Safe signature to isValidSignature. Alternatively, the owners can approve the message through an on-chain Safe transaction. My fallback follows the second idea, but stores the hash in a separate contract instead of using the Safe’s own approval mechanism.

The Safe handler normally reverts when a signature is invalid or an on-chain approval is missing, so the function rejects in those cases. A production implementation should classify expected validation reverts as invalid signatures while keeping RPC and configuration failures separate. Those failures need different responses, especially while wallet and Safe support are still changing quickly.

← All posts