Web3 Authentication Testing Guide
How to Test Sign-In with Ethereum (SIWE) with Playwright: Complete 2026 Guide
A scenario-by-scenario walkthrough of testing SIWE flows with Playwright. Wallet connection mocking, EIP-4361 message construction, personal_sign interception, nonce verification, session issuance, domain binding, and the pitfalls that break real Web3 auth test suites.
“Over 15 million unique addresses have signed EIP-4361 messages since the standard's adoption, making SIWE the dominant wallet-based authentication pattern across decentralized applications.”
Dune Analytics, 2025
SIWE Authentication Flow
1. Why Testing SIWE Is Harder Than It Looks
Sign-In with Ethereum (SIWE), defined by EIP-4361, replaces traditional username/password authentication with cryptographic wallet signatures. The user connects their Ethereum wallet (MetaMask, WalletConnect, Coinbase Wallet), the application constructs a structured plaintext message containing a nonce, domain, chain ID, and expiration, and the wallet signs that message using personal_sign. The server then recovers the signer address from the signature, validates all message fields, and issues a session. This sounds straightforward until you try to test it.
The first problem is that Playwright cannot control browser extensions. MetaMask, the most popular Ethereum wallet, is a Chrome extension that injects window.ethereuminto every page. Playwright does not support loading unpacked extensions in Chromium (only Chrome channel), and even when you load the extension, automating its popup requires a separate browser context targeting the extension's internal pages. Most teams abandon this approach within a week and switch to mocking the wallet provider entirely.
The second problem is the EIP-4361 message format itself. The message is a multiline plaintext string with a strict structure: domain, address, statement, URI, version, chain ID, nonce, issued-at timestamp, and optional expiration and resources. Your test must construct or validate this exact format because the server parses it field by field. A single missing newline or a misplaced field causes the server to reject the signature.
Third, the nonce is ephemeral. The server generates a random nonce, stores it in a server-side session or cookie, and expects the signed message to contain that exact nonce. Your test must capture the nonce from the initial request and embed it in the mock signature flow. Replaying a previously valid signature with a stale nonce must fail, and your tests need to verify this.
Fourth, domain binding ties the message to the origin that requested it. The EIP-4361 message includes both a domain field (the hostname) and a uri field (the full page URL). The server must reject signatures where these fields do not match the requesting origin. Testing this requires crafting messages with intentionally wrong domains and verifying the server rejects them.
Fifth, session issuance after successful verification is its own surface area. The server sets an HTTP-only cookie or returns a JWT, and your tests must verify that subsequent requests carry valid credentials, that the session expires correctly, and that logout actually clears all session state.
Why SIWE Testing Is Complex
No Extension Control
Playwright cannot automate MetaMask popup
EIP-4361 Format
Strict multiline message structure
Ephemeral Nonce
Server-generated, single-use
Domain Binding
Origin validation on every sign-in
Session Issuance
Cookie/JWT lifecycle after verify
2. Setting Up a Reliable SIWE Test Environment
Before writing any SIWE tests, you need a deterministic wallet mock, a local Ethereum key pair, and the siwe library for message construction and verification. The goal is to eliminate all external dependencies (no MetaMask, no browser extensions, no real blockchain interaction) so your tests run in milliseconds and are completely reproducible.
SIWE Test Environment Checklist
- Install siwe, ethers, and @playwright/test as dev dependencies
- Generate a deterministic test wallet from a fixed mnemonic (never use a funded wallet)
- Create a wallet mock module that injects window.ethereum before page load
- Configure your backend SIWE nonce endpoint to accept localhost origins
- Set up environment variables for the test wallet address and private key
- Disable any CAPTCHA or bot detection on the SIWE flow for the test environment
- Ensure your backend SIWE verification accepts the test chain ID (31337 for Hardhat)
Environment Variables
Generating a Deterministic Test Wallet
Use a fixed private key from the Hardhat default accounts. This ensures every test run signs messages with the same address, making assertions deterministic. Never use a wallet that holds real funds in any test configuration, even for a moment.
Mocking the Wallet Provider (window.ethereum)
Complex3. Scenario: Mocking the Wallet Provider
Goal: Inject a fake window.ethereum provider that responds to eth_requestAccounts, eth_chainId, and personal_sign RPC calls using your deterministic test wallet. This eliminates the MetaMask dependency entirely.
Preconditions: The test wallet private key and address are available in environment variables. The application detects window.ethereumon page load to enable the “Sign In with Ethereum” button.
The mock must implement the EIP-1193 provider interface. At minimum it needs to handle three RPC methods: eth_requestAccounts returns the test address, eth_chainId returns the test chain ID as a hex string, and personal_signsigns the provided message with the test private key. The trick is injecting this mock before the application JavaScript executes. Playwright's page.addInitScript runs code in the page context before any other scripts, making it the right tool.
What to Assert Beyond the UI: After injection, verify that window.ethereum.isMetaMask is true within the page context, that eth_requestAccountsreturns the expected address, and that the application's “Connect Wallet” button becomes active. Use page.evaluate to call the provider directly and assert the return values.
Happy Path SIWE Login
Moderate4. Scenario: Happy Path SIWE Login
Goal: Complete the full SIWE login flow: fetch nonce, construct EIP-4361 message, sign it with the mock wallet, submit to the verify endpoint, and confirm the user lands on the authenticated dashboard.
Preconditions: The mock wallet is injected. The backend nonce endpoint is reachable. The verify endpoint accepts signatures from the test wallet address.
The core challenge here is bridging the gap between the Playwright Node.js context (where you have access to ethers and the private key) and the browser context (where personal_sign is called). The mock wallet stores the message hex in window.__siweMessageToSign and waits for a resolve callback. Your test polls for that message, signs it in the Node.js context using ethers, and pushes the signature back into the browser to resolve the promise.
Happy Path SIWE Login Sequence
Inject Mock
addInitScript sets window.ethereum
Click Sign In
App fetches nonce from backend
Build EIP-4361
Message with nonce, domain, chainId
personal_sign
Mock captures message hex
Sign in Node.js
ethers.Wallet.signMessage()
Verify + Session
Backend issues session cookie
Happy Path: Playwright vs Assrt
import { test, expect } from '@playwright/test';
import { Wallet } from 'ethers';
import { injectMockWallet } from '../helpers/inject-wallet';
test('complete SIWE login', async ({ page }) => {
await injectMockWallet(page, PRIVATE_KEY);
await page.goto(APP_URL);
await page.getByRole('button', {
name: /sign in with ethereum/i
}).click();
const msgHex = await page.waitForFunction(
() => window.__siweMessageToSign
);
const msg = Buffer.from(
(await msgHex.jsonValue()).slice(2), 'hex'
).toString('utf8');
const sig = await new Wallet(PRIVATE_KEY)
.signMessage(msg);
await page.evaluate(
(s) => window.__siweSignResolve(s), sig
);
await page.waitForURL('**/dashboard');
const res = await page.request.get(
APP_URL + '/api/siwe/session'
);
expect(res.ok()).toBe(true);
});Nonce Verification and Replay Protection
Complex5. Scenario: Nonce Verification and Replay Protection
Goal: Verify that the server rejects replayed signatures by enforcing single-use nonces, and that a signature signed with an incorrect or expired nonce fails verification.
Preconditions: A valid SIWE login has already been completed once (so you have a valid signature/message pair from a previous nonce). The backend stores nonces in a server-side session or database with a TTL.
Nonce replay is one of the most critical security properties of SIWE. The server generates a random nonce (typically a UUID or a cryptographically secure random string) and associates it with the user's session. When the signed message comes back, the server checks that the nonce in the message matches the one it issued. After successful verification, the nonce is invalidated. If an attacker intercepts a valid signature, they cannot replay it because the nonce has already been consumed.
What to Assert Beyond the UI: Check server logs or a database query to confirm the nonce record was deleted after the first successful verification. Verify that the error response body contains a meaningful error message mentioning the nonce, not a generic 500 error.
Domain Binding Validation
Complex6. Scenario: Domain Binding Validation
Goal: Verify that the server rejects SIWE messages where the domain or uri fields do not match the actual server origin. Domain binding prevents phishing attacks where a malicious site tricks users into signing a message intended for a different application.
Preconditions:The backend verifies the domain and URI fields in the EIP-4361 message against the expected origin (configured via environment variable or derived from the request's Host header).
Domain Binding Attack Scenario
Phishing Site
evil-site.com
Tricks User
Signs message for wrong domain
Submits to Real Server
POST /api/siwe/verify
Server Rejects
Domain mismatch detected
Attack Fails
422 Unprocessable Entity
Session Lifecycle and Token Expiry
Moderate7. Scenario: Session Lifecycle and Token Expiry
Goal: Verify that after a successful SIWE login, the session persists across page reloads, that the session endpoint returns the correct wallet address, and that an expired session correctly forces re-authentication.
Preconditions: The backend uses HTTP-only session cookies with a configurable TTL. The session endpoint at /api/siwe/sessionreturns the current user's address or a 401 if not authenticated.
SIWE sessions are typically short-lived compared to traditional sessions. The EIP-4361 message includes optional expirationTime and notBefore fields that constrain when the signature is valid. Your backend should respect these fields in addition to its own session TTL. Test both: a message whose expiration has passed and a session cookie that has exceeded the server TTL.
Session Lifecycle: Playwright vs Assrt
test('session persists', async ({ request }) => {
const cookie = await loginViaSiwe(request);
const res = await request.get(
APP_URL + '/api/siwe/session',
{ headers: { cookie } }
);
expect(res.ok()).toBe(true);
const session = await res.json();
expect(session.address.toLowerCase())
.toBe(wallet.address.toLowerCase());
});
test('logout clears session', async ({ request }) => {
const cookie = await loginViaSiwe(request);
await request.post(APP_URL + '/api/siwe/logout',
{ headers: { cookie } });
const res = await request.get(
APP_URL + '/api/siwe/session',
{ headers: { cookie } });
expect(res.status()).toBe(401);
});Rejection and Error Paths
Straightforward8. Scenario: Rejection and Error Paths
Goal:Verify the application handles wallet rejections, invalid signatures, and network errors gracefully. A user might click “Reject” in their wallet, the signature could be tampered with in transit, or the nonce endpoint could be unreachable.
Preconditions: The mock wallet can be configured to reject signature requests or return malformed signatures.
What to Assert Beyond the UI: Verify that the server returns structured error responses (not 500 errors with stack traces) for invalid signatures. Check that no session is created on the server when verification fails. Confirm that the frontend error state is recoverable, meaning the user can retry the sign-in flow without refreshing the page.
9. Common Pitfalls That Break SIWE Test Suites
These are real problems sourced from GitHub issues, Stack Overflow discussions, and production incident reports in the SIWE ecosystem. Each one has broken real test suites.
SIWE Testing Anti-Patterns
- Hardcoding a nonce in test fixtures instead of fetching it from the server. The server will reject signatures with stale or unrecognized nonces, causing every test to fail after the first run.
- Forgetting to hex-encode the message before passing it to personal_sign. The EIP-4361 spec requires a UTF-8 string, but the RPC call expects hex-encoded bytes. The siwe library handles this, but manual construction often skips the encoding.
- Using Date.now() in the issuedAt field without accounting for clock skew between test runner and server. If the server clock is even 1 second behind, the message may be rejected as "issued in the future."
- Testing against Ethereum mainnet (chainId: 1) in CI. Use Hardhat's chainId 31337 or a local Anvil instance. Mainnet RPC calls are slow, rate-limited, and non-deterministic.
- Not cleaning up sessions between tests. SIWE sessions are stateful. If test A logs in and test B assumes a logged-out state, B will fail randomly depending on execution order.
- Injecting the wallet mock after page.goto instead of before. The app's JavaScript runs on load and checks for window.ethereum. If the mock arrives too late, the app initializes in "no wallet" mode.
- Assuming all SIWE libraries produce identical message formatting. The siwe npm package, siwe-py, and custom implementations can produce subtly different whitespace or field ordering, causing cross-language signature verification to fail.
- Skipping domain validation tests because "it works in development." Domain binding is the primary security property of SIWE. If your server does not validate the domain, a phishing site can harvest signatures.
The addInitScript Timing Issue
The most common cause of flaky SIWE tests is wallet injection timing. Playwright's page.addInitScript runs before any page JavaScript, but only if you call it before page.goto. If you call addInitScriptafter navigation has started, the script may or may not execute before the application's bundle, depending on network timing. Always structure your test as: create page, inject wallet, then navigate.
EIP-4361 Message Parsing Differences
The EIP-4361 message format is deceptively strict. The domain line must end with “wants you to sign in with your Ethereum account:” followed by the address on a new line. The statement is separated by a blank line above and below. Resources are listed one per line with a “- ” prefix. If your backend uses a regex parser instead of the official siwe library, subtle formatting differences (trailing whitespace, missing blank lines) will cause intermittent failures that are extremely difficult to debug.
10. Writing These Scenarios in Plain English with Assrt
Every scenario above requires deep knowledge of EIP-4361 message construction, ethers.js signing, Playwright init scripts, and RPC method mocking. The happy path alone is 40+ lines of TypeScript. Multiply that by seven scenarios, add the wallet mock helper, and you have a test suite that is brittle to maintain and opaque to anyone who is not a Web3 specialist. Assrt lets you express the same test intent in plain English, generates the equivalent Playwright code including the wallet mock setup, and regenerates selectors and RPC handling when your SIWE implementation changes.
The nonce replay scenario from Section 5 illustrates this well. In raw Playwright, you need to manually fetch the nonce, construct the SiweMessage object, sign it, submit it, then replay the same signature and assert the 422 status. In Assrt, you describe the scenario intent and the framework handles the wallet plumbing.
Assrt compiles each scenario block into the same Playwright TypeScript you saw in the preceding sections. The wallet mock injection, EIP-4361 message construction, and signature bridge are all handled by the Assrt SIWE adapter. When your application migrates from the siwe npm package to a custom verification endpoint, or when you switch from session cookies to JWTs, Assrt detects the structural change and regenerates the test plumbing while your scenario files remain untouched.
Start with the wallet mock injection test. Once it passes, add the happy path login, then the nonce replay test, then domain binding, then session lifecycle. In a single afternoon you can have complete SIWE coverage that protects against replay attacks, phishing, and session fixation. Most Web3 applications ship without any of these tests, making this a significant security advantage.
Related Guides
How to Test Auth0 Universal Login
A practical, scenario-by-scenario guide to testing Auth0 Universal Login with Playwright....
How to Test Azure AD Login
A practical, scenario-by-scenario guide to testing Azure AD (Entra ID) login with...
How to Test Clerk Sign-In
A practical, scenario-by-scenario guide to testing Clerk authentication with Playwright....
Ready to automate your testing?
Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.