SIWE message validation and SIWE_DOMAIN

Every rule the strict EIP-4361 parser enforces, how the expected domain is resolved from SIWE_DOMAIN or NEXT_PUBLIC_SITE_URL, and what each 400, 401 and 500 string means.

6 min readUpdated 6 August 2026siwe, eip-4361, siwe-domain, authentication, clock-skew

Every wallet signature this platform accepts is checked against a strict EIP-4361 parser in backend/src/utils/siwe.ts. It reads the whole message, not a few fields out of it, and it refuses anything that is not addressed to this site, is not version 1, or is outside its validity window — before it spends the nonce or calls out to verify the signature.

The practical consequence for you is one configuration decision and one class of failure. The decision is which host name the platform considers to be itself. The failure is that a user browsing to any other host cannot sign in with a wallet, no matter how correct their signature is.

The sign-in flow says the message is read with three regular expressions and that domain, URI and the timestamps are not checked. That describes the parser this one replaced. Where the two disagree, this page is the current behaviour.

The expected domain

expectedSiweDomain() resolves one host name, and the message must claim it. The expectation is taken from configuration and never from the request's Host header, because the caller controls that header on their own request.

Order Source Result
1 SIWE_DOMAIN, trimmed, if non-empty Used exactly as written
2 NEXT_PUBLIC_SITE_URL new URL(value).host — host and port, no scheme, no path
3 Neither is set 500"SIWE cannot be validated: set SIWE_DOMAIN, or NEXT_PUBLIC_SITE_URL, in .env."
4 NEXT_PUBLIC_SITE_URL is set but is not an absolute URL 500"SIWE cannot be validated: NEXT_PUBLIC_SITE_URL is not a valid absolute URL. Set SIWE_DOMAIN to the bare host instead."
The authoritative host a SIWE message must claim. Backend-only, read from the root .env at request time. Not in .env.example. Leave it unset unless the host users browse to differs from NEXT_PUBLIC_SITE_URL.

There is no fail-open path here. If neither variable is usable the endpoint returns 500 rather than skipping the check, so a wallet login cannot be completed against an install that has not decided what it is called.

SIWE_DOMAIN=exchange.example.com is right. https://exchange.example.com, exchange.example.com/, and Exchange.Example.com are all wrong — the comparison is a case-sensitive string equality against the host the browser reports, and browsers report the host lowercased and without a scheme. Include the port only if your users' address bar shows one: localhost:3000, exchange.example.com:8443.

SIWE_DOMAIN holds exactly one value. It is not a list, and nothing splits it on commas.

It is backend-only, so a restart is enough

Unlike NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID, SIWE_DOMAIN is never compiled into the frontend bundle, and it is read from process.env each time a message is validated rather than once at module load. Editing .env and running pnpm restart is sufficient — there is no frontend rebuild in this loop. See Installing Wallet Connect for the variable that does need one.

The browser decides what the message claims

Both signing paths build the message from the page they are running on:

const siweMessage = new SiweMessage({
  domain: window.location.host,     // line 163
  address: account.address,
  statement: "Sign in with Ethereum to P2P Platform",
  uri: window.location.origin,      // line 166
  version: "1",
  chainId: Number.parseInt(String(network.chainId || "1")),
  nonce: await fetchServerNonce(),
  issuedAt: new Date().toISOString(),
  expirationTime: new Date(Date.now() + 5 * 60_000).toISOString(),
});

frontend/app/[locale]/(dashboard)/user/profile/components/tabs/wallet-tab.tsx builds the identical object for linking.

So the claimed domain is whatever host the user typed. Any reachable host that is not the configured one produces a 401 at sign-in, even though it serves the same application from the same database.

Reached as Configured as Outcome
exchange.example.com exchange.example.com Signs in
www.exchange.example.com exchange.example.com 401 SIWE domain does not match this site
203.0.113.10 (bare IP) exchange.example.com 401
staging.example.com exchange.example.com 401
localhost:3000 (dev) .env.example default NEXT_PUBLIC_SITE_URL="http://localhost" → host localhost 401 — the port is part of the host

Two things follow, and both are operational rather than cosmetic.

Redirect the aliases, do not serve on them. The sample configuration in Nginx names both example.com and www.example.com on one server_name line, so both hosts serve the application — and only one of them can carry wallet sign-in. Add a 301 from the alias to the canonical host, or accept that half your users cannot use the feature.

A local or staging install needs its own value. The stock NEXT_PUBLIC_SITE_URL="http://localhost" resolves to localhost while pnpm dev serves the frontend on port 3000, so wallet sign-in fails on every fresh development box until you set SIWE_DOMAIN=localhost:3000 or correct NEXT_PUBLIC_SITE_URL to include the port.

The URI: line is checked the same way: its host must equal the expected domain. A value that is not a parseable absolute URL fails the same test, since the host it yields is the empty string.

What the parser enforces

Rule Enforced as Failure
Message is a string of at most 8192 characters, at least 6 lines Length and line count 400 Malformed SIWE message
Line 1 is <domain> wants you to sign in with your Ethereum account: An optional scheme:// prefix is allowed and stripped 400
Line 2 is an address on a line of its own /^0x[a-fA-F0-9]{40}$/ 400
Line 3 is blank; then either a statement line and a blank, or a second blank Matches what SiweMessage.prepareMessage() emits with and without a statement 400
Remaining lines are Key: value Blank lines, Resources: and - list items are skipped; anything else is fatal 400
A URI: line exists 400
domain equals the expected domain Exact string comparison 401 SIWE domain does not match this site
URI: host equals the expected domain new URL(uri).host 401 SIWE uri does not match this site
Version: is 1 400 Unsupported SIWE version
Chain ID: is present and numeric /^\d{1,10}$/ 400 Malformed SIWE message
Nonce: is present, alphanumeric, 8–128 characters /^[A-Za-z0-9]{8,128}$/ 400 Malformed SIWE message
Expiration Time, if present, is in the future 401 SIWE message has expired
Not Before, if present, is in the past 401 SIWE message is not yet valid
Issued At is present, parseable, and no more than 10 minutes old Hard cap, always applied 401 SIWE message is stale

On success the parser returns three values and nothing else: the address lowercased (matching the lowercasing setter on providerUser.providerUserId, so the lookup does not depend on the table's collation), the chain id in eip155:<id> form, and the nonce.

The parser this replaced scraped Chain ID: with a regex and fell back to 1 when it was absent or unreadable, which routed verification to Ethereum mainnet for a message that never named a chain. A message with no Chain ID: line, or one carrying anything but 1–10 digits, is now a 400.

The value is still checked a second time, later, against the eight-entry allow-list in backend/src/api/auth/utils.ts — see Wallets, chains and endpoints. That second check runs inside verifySignature, after the nonce has been consumed, and reports as 401 "Signature verification failed" with an Unsupported SIWE chainId line under the AUTH log module.

What is not checked

The statement line is parsed but never compared. Both signing paths hard-code it to "Sign in with Ethereum to P2P Platform", which is what your users see in their wallet's approval sheet regardless of your branding; there is no setting that changes it. Request ID and Resources are skipped. Nothing validates the address's EIP-55 checksum — the signature recovery in verifySignature is what proves the address, and it is case-insensitive.

The validity window and your server clock

There are two independent bounds on age.

The five-minute expiry the frontend writes. Both signing paths set expirationTime to five minutes after the browser's current time. This is the bound that fires in practice.

The ten-minute cap on Issued At. Applied unconditionally, so a third-party client that omits Expiration Time is still bounded. Issued At missing, or unparseable — the string yesterday, for example — is treated as stale rather than as fresh.

Both are evaluated against the server's clock and both timestamps come from the browser's clock, so what actually matters is the difference between them.

Skew Symptom
Server clock more than ~5 minutes ahead of the user's Every wallet sign-in fails with 401 SIWE message has expired
Server clock more than 10 minutes ahead, message with no Expiration Time 401 SIWE message is stale
Server clock behind the user's Neither check fires; the message reads as issued in the future and still inside its window

A whole platform reporting expired or stale on the first attempt is a clock problem, not a user problem. Confirm with timedatectl on the app server and make sure NTP is actually synchronising, because nothing else in the platform is this sensitive to a fast clock.

The nonce's own Redis TTL is also 300 seconds, so a user who leaves the wallet prompt open long enough to trip the message expiry is usually about to trip the nonce expiry as well. The messages differ — SIWE message has expired comes from the parser, Invalid or expired login nonce from the Redis consume — but the remedy is the same: start again.

Where validation sits in the login sequence

POST /api/auth/login/wallet runs these in order and stops at the first failure. The steps are narrated to the request log under the LOGIN module, so the last recorded step names the check that failed.

# Step Failure
1 wallet_connect is enabled and its .lic file exists 403
2 message and signature are both present in the body 400 Message and signature are required
3 NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID is set on the backend 500 Wallet connect project ID is not defined
4 expectedSiweDomain() resolves 500 — one of the two strings above
5 parseAndValidateSiwe — everything in the table above 400 or 401
6 The nonce is consumed from Redis with DEL, which must remove exactly one key 401 Invalid or expired login nonce
7 verifySignature — chain allow-list, then recovery against rpc.walletconnect.org 401 Signature verification failed
8 A provider_user row exists for the lowercased address 401 Wallet address not recognized
9 The account is not BANNED, SUSPENDED or INACTIVE 403
10 The 2FA gate 200 with a challenge and no cookies

Step 5 runs before step 6, deliberately. A message that fails validation does not burn the user's nonce, so they can retry immediately with the same one until its 300-second TTL runs out — which matters, because the nonce endpoint and the login endpoint share a rate-limit budget of five requests per fifteen minutes.

The same validation runs on linking, and on unlinking

parseAndValidateSiwe(message, expectedSiweDomain()) is called from three handlers, with the same expectations and the same error strings in each:

Verifies a SIWE signature and issues session cookies. Public.
Links a verified address to the signed-in account. Requires a session.
Removes a wallet link. Requires a session. Validates a SIWE message only when walletDisconnectRequiresSignature is on.

So a linking failure and a login failure have identical causes. If a user reports that Connect wallet on /user/profile?tab=wallet fails, everything on this page applies unchanged — a misconfigured SIWE_DOMAIN breaks both, and fixing it fixes both. The connect handler's order is the same as the login handler's, with an authentication check ahead of it and a duplicate-address check behind it; it logs under the WALLET module rather than LOGIN.

When on, unlinking a wallet also requires a fresh SIWE signature over the address being removed. Read from the settings table by key; there is no admin screen for it and no seeded row, so it is off unless you insert one.

With that setting on, the disconnect handler additionally requires that the signed address equals the address being unlinked, answering 401 "Signature does not match the address being unlinked." when it does not.

Reading the failures

Every one of these responses carries the exact string as HTTP JSON in the shape { "message": "…", "statusCode": 401 }.

The wallet login form reads data.error from the response, and the platform sends data.message. The toast therefore falls back to a generic Authentication failed for every case in the tables above.

To see which check actually fired, read the response body in the browser's Network tab, or read the backend log — the LOGIN and WALLET modules record each step, and the last one before the failure is Parsing and validating the SIWE message for anything on this page.

Once you have the string, it maps straight back:

Response Meaning
500 SIWE cannot be validated: set SIWE_DOMAIN, or NEXT_PUBLIC_SITE_URL, in .env. Neither variable is set in the backend's environment, or the backend has not been restarted since they were
500 SIWE cannot be validated: NEXT_PUBLIC_SITE_URL is not a valid absolute URL… The value is missing its scheme, or is otherwise not a URL. Set SIWE_DOMAIN to the bare host
401 SIWE domain does not match this site The user is on a host that is not the configured one — an alias, an IP, a port, a staging name
401 SIWE uri does not match this site Same cause; the origin the page was served from disagrees with the configuration
400 Unsupported SIWE version Something other than the platform's own frontend built the message
400 Malformed SIWE message The message is not EIP-4361, or its chain id or nonce is outside the accepted pattern
401 SIWE message has expired / is stale Clock skew, or a signature prompt left open too long
401 SIWE message is not yet valid A Not Before in the future; the stock frontend never sets one