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.
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." |
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:
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.
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 |
Related
- Wallets, chains and endpoints — the chain allow-list the parsed chain id is checked against.
- The sign-in flow — the nonce, the session and the 2FA gate that follow validation.
- Linking a wallet — the connect handler in full.
- Troubleshooting.