The sign-in flow
How a wallet signature becomes a Bicrypto session — the nonce, what the backend verifies, what it deliberately does not verify, the rate limit that allows two attempts, and the 2FA gap.
Signing in with a wallet is three HTTP requests and two wallet prompts. Nothing about it is asynchronous, nothing is polled, and no on-chain transaction is involved at any point.
The three requests
In order:
-
Connect. The user clicks Connect wallet, the Reown modal opens, and they pick a wallet. This is the first wallet prompt. Nothing has been sent to your server yet.
-
Fetch a nonce. The browser calls
GET /api/auth/login/nonce. The server generates 16 random bytes, hex-encodes them, and writessiwe:nonce:<value>into Redis with a 300-second TTL andNX. -
Build and sign the message. The browser assembles a SIWE message — domain, address, statement, URI, version, chain ID and that nonce — and asks the wallet to sign it. This is the second wallet prompt. The address it signs for comes from the injected provider's currently selected account.
-
Verify. The browser posts
{ message, signature }toPOST /api/auth/login/wallet. Everything in the next section happens here. -
Session. On success the response carries
accessToken,sessionIdandcsrfToken, which the framework converts intoSet-Cookieheaders. The browser then reads/api/user/profileto populate the client store.
The session that results is identical to the one a password login produces — same cookies, same expiry, same entry in the user's Active Sessions list, same revocation on logout.
What the backend verifies
POST /api/auth/login/wallet runs these checks in order, and stops at the first
failure.
| # | Check | Failure |
|---|---|---|
| 1 | The wallet_connect extension is enabled and lic/37548018.lic exists |
403 |
| 2 | Both message and signature are present |
400 |
| 3 | NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID is set on the backend |
500 |
| 4 | The message contains an address on a line of its own | 400 |
| 5 | The message contains a Nonce: line |
401 |
| 6 | The nonce still exists in Redis — consumed with DEL |
401 |
| 7 | The chain ID is on the supported allow-list | 401 |
| 8 | The signature recovers to the claimed address | 401 |
| 9 | A provider_user row exists for that address |
401 |
| 10 | The account is not BANNED, SUSPENDED or INACTIVE |
403 |
| 11 | 2FA, if the platform and the account both have it on | 200 with a challenge |
Two of these deserve detail.
Step 6 is the replay defence. The nonce is deleted atomically, and the
delete has to report exactly one key removed. A captured (message, signature)
pair is therefore worthless: the second submission finds nothing to delete and
is refused. Nonces also expire on their own after five minutes, so a signature
sitting in a paused debugger is not indefinitely valid either. There is no
database table of used nonces — if Redis is flushed mid-flight, in-progress
sign-ins fail and users simply retry.
Step 8 goes out to the network. Verification is a viem public client
pointed at https://rpc.walletconnect.org/v1/ with your project ID in the query
string. That means two things an operator needs to know: the backend, not just
the browser, needs outbound HTTPS; and smart-contract wallets work, because that
call falls through to EIP-1271 isValidSignature on-chain when the address is
not an EOA. A Safe or other contract wallet can sign in, provided the contract
is deployed on the chain named in the message.
What the backend does not verify
The message is parsed with three anchored regular expressions, for the address,
the Chain ID: line and the Nonce: line. Everything else in a SIWE message is
read past.
The SIWE standard binds a signature to a site by naming the domain in the message the user signs. This implementation does not compare that domain against your own, and does not enforce the message's expiry timestamps.
All of the replay protection therefore comes from the single-use nonce and its five-minute TTL. Those are real defences and they cover the common case — a signature stolen from a network log cannot be reused. But a message signed for one origin will verify at another, so tell your users the same thing every wallet already tells them: read what you are signing, and check the site name in the approval sheet.
The rate limit
Both GET /api/auth/login/nonce and POST /api/auth/login/wallet declare the
shared strict limiter: 5 requests per 900 seconds, failing closed. It is
not configurable from any screen or environment variable.
The consequences are sharper than the numbers suggest.
The key is the IP address, not the user. Neither endpoint resolves an optional session, so even a signed-in user linking a wallet is counted against their IP. Everyone behind the same NAT — an office, a campus, a mobile carrier gateway — shares one budget of five.
One sign-in costs two requests. Nonce plus verify. Five requests is therefore two complete attempts per fifteen minutes, not five. A user who mistypes their way through a wallet prompt, cancels, and tries again is one attempt from being locked out with "Too many attempts. Please try again in 15 minutes."
The bucket is shared with other sensitive endpoints. Anything else on the
strict limiter draws from the same five.
It fails closed. If Redis errors, the limiter refuses rather than allows. That is correct for an authentication endpoint, but it means a Redis problem presents as a 429 rather than a 500.
Two-factor authentication
If the platform setting twoFactorStatus is on and the user has 2FA enabled on
their account, the wallet login endpoint does exactly what the password endpoint
does: it generates a code, delivers it by SMS or email (or expects an
authenticator app), issues a two-factor challenge token, and returns 200
with { twoFactor, twoFactorToken, message: "2FA required" } and no session
cookies.
The password login form has a branch for this response and shows the code entry
screen. The wallet login form does not. It treats any 200 as success: it fetches
/api/user/profile, which returns 401 because no session exists, silently
ignores that failure, shows a "Login successful" toast and closes the modal.
The observable result for a user with 2FA enabled is a success message followed by still being signed out. If the account is on SMS or email 2FA they will also receive a code they are never asked for.
Until this is fixed, the workable combinations are: 2FA users sign in with their
password, or wallet-only users do not enable 2FA. Completing the challenge is
possible through POST /api/auth/otp/login with the returned twoFactorToken,
but there is no screen that offers it after a wallet sign-in.
Account status
The status checks run after the signature is verified, so the error message a user sees is specific to their account rather than generic.
| Status | Response |
|---|---|
BANNED |
403 — "Your account has been banned. Please contact support." |
SUSPENDED |
403 — "Your account is suspended. Please contact support." |
INACTIVE |
403 — "Your account is inactive. Please verify your email or contact support." |
An INACTIVE account is the one operators trip over: a user who registered by
email, never clicked the verification link, and then linked a wallet cannot sign
in with that wallet either. Wallet ownership does not substitute for email
verification.
What gets recorded
A successful wallet sign-in writes a auth.login user-activity row titled
Signed in, with a description naming the truncated address, and the full
address in the row's metadata. It is visible to the user in their own activity
list and to an admin on the user's record.
Failed attempts are captured by the request-context logger under the LOGIN
module with the specific reason — unrecognised address, bad signature, expired
nonce — which is what you read when a user reports that sign-in "just does not
work".
Related
- Linking a wallet — step 9 above only passes for addresses that have been linked first.
- Wallets, chains and endpoints — the allow-list step 7 checks against.
- Troubleshooting.