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.

6 min readUpdated 3 August 2026siwe, nonce, session, 2fa, rate-limit

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

Issues a single-use SIWE nonce and stores it in Redis for five minutes
Verifies a SIWE signature and issues session cookies
Reads the profile of the user the new session belongs to

In order:

  1. 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.

  2. Fetch a nonce. The browser calls GET /api/auth/login/nonce. The server generates 16 random bytes, hex-encodes them, and writes siwe:nonce:<value> into Redis with a 300-second TTL and NX.

  3. 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.

  4. Verify. The browser posts { message, signature } to POST /api/auth/login/wallet. Everything in the next section happens here.

  5. Session. On success the response carries accessToken, sessionId and csrfToken, which the framework converts into Set-Cookie headers. The browser then reads /api/user/profile to 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".