Skip to Content
EngineeringAuth and HD wallets

Auth and HD wallets

Sign-in is EIP-4361 (SIWE): the user proves control of a wallet address by signing a message, and gets back a JWT. There is no password, no email, and no account record to create beforehand — the first successful signature is the registration.

The flow

GET /auth/nonce → { nonce } ↓ client assembles the SIWE message and signs it in the wallet POST /auth/verify { message, signature } → { token, address }

Server side, in auth/auth.controller.ts:61-82, in this order:

  1. siwe.verify(message, signature) → the address and the nonce carried in the message.
  2. nonces.redeem(nonce) — the nonce is burned before the user is created.
  3. users.findOrCreate(address).
  4. jwt.sign(user.id).

Step 2 comes before step 3 on purpose (commented at auth.controller.ts:77). Reverse them and a replayed signature would create the user before the redemption failed.

POST /auth/verify answers 200, not the Nest default 201 for @Post — an explicit @HttpCode(200) at auth.controller.ts:42.

The client assembles the message with the same siwe library rather than by hand (apps/web/src/shared/lib/siwe-message.ts:17), so the text it signs and the text the server parses cannot drift apart.

What the signature check actually covers

siwe.verifier.ts:70 calls parsed.verify({ signature, domain }). The library checks the domain (from SIWE_DOMAIN), expirationTime, notBefore, and the signature itself.

chainId is not verified. It is not passed into parsed.verify(). The client does put a chainId into the signed message (siwe-message.ts:24, from wagmi’s useChainId()), so it is covered by the signature — but the server never compares it against an expected value such as DERIVE_CHAIN_ID. Searching git grep chainId -- apps/api/src/auth finds only the spec file.

Every rejection returns the same opaque SIWE_REJECTED with no detail (auth.controller.ts:62). That is a deliberate choice against probing, not thin error handling.

Nonces

Stored in Postgres, table auth_nonces (nonce varchar(64) PK, issued_at, expires_at, used_at). TTL is 5 minutes (NONCE_TTL_MS in auth/auth.module.ts:16).

Replay protection is a single atomic statement, not a read-then-write:

UPDATE ... WHERE used_at IS NULL AND expires_at > now() RETURNING ...

at auth/nonce.store.ts:30-44. Covered by nonce.store.integration.spec.ts:38, which redeems the same nonce concurrently and asserts exactly one winner.

This is the UPDATE-returns-a-tuple trap again (nonce.store.ts:31-36). A forgotten destructuring here silently breaks replay protection rather than throwing.

There is no cleanup job. auth_nonces_expires_idx was created for a sweeper that does not exist; git grep auth_nonces finds only the migration and the store. The table grows without bound.

Tokens

An access JWT and nothing else. There are no refresh tokens — no endpoint, no field, no rotation.

The payload is only { sub: userId }, deliberately without the wallet address (auth/jwt.service.ts:16). TTL is '7d', hardcoded in auth/auth.module.ts:17 rather than read from the environment. The secret is JWT_SECRET (minimum 32 characters).

The browser keeps the token in sessionStorage, not localStorage, bound to the address it was issued for (apps/web/src/shared/api/token-store.ts): on read the stored address is compared with the connected one, and a mismatch is treated as no token at all. After seven days the user signs in again.

Authorising a request

  • AuthGuard (auth/auth.guard.ts:16) reads Authorization: Bearer <token> and sets request.user = { userId }.
  • There is no global guard — no APP_GUARD in app.module.ts. Protection is explicit, per controller.
  • @Authenticated() (auth/authenticated.decorator.ts:23) combines @UseGuards(AuthGuard) with @ApiBearerAuth() in one mark.
  • @CurrentUser() (auth.guard.ts:50) extracts userId and throws a single UNAUTHORIZED if the guard did not run.

The two halves of @Authenticated() were once applied separately, and PositionsController and SubaccountsController ended up guarded at runtime but not marked secure in OpenAPI — so the generated client never sent the header. That is why they are one decorator now, and why authenticated.decorator.spec.ts:46 asserts both halves together.

Protected controllers: portfolio, positions, subaccounts, treasury, withdrawals. Public routes are simply the ones without the decorator — auth.controller.ts and everything market-data.

Users and HD index

UsersRepository.findOrCreate (auth/users.repository.ts:25) normalises the address to EIP-55 checksum form with getAddress before querying, then INSERT ... ON CONFLICT (wallet_address) DO NOTHING and re-reads.

Table users: id uuid PK, wallet_address varchar(42) UNIQUE, hd_index integer UNIQUE, created_at.

The HD index comes from a sequence, users_hd_index_seq, not from max+1 — two users signing in at the same moment from different addresses would otherwise race for the same index (users.repository.ts:35).

That index is the link between identity and money. HdWallet (auth/hd-wallet.ts:6) derives m/44'/60'/0'/0/{hd_index} from MASTER_MNEMONIC — a standard path, so the addresses are recoverable by any ordinary wallet.

MASTER_PRIVATE_KEY cannot substitute for MASTER_MNEMONIC: child addresses cannot be derived from a bare private key. They are two different secrets with two different jobs.

HdWallet is provided as AUTH_DEPS.HD_WALLET and exported to treasury, where deposit-watcher.ts, gas-funder.ts and sweeper.ts use it. GET /deposit-address (treasury/treasury.controller.ts:35) is the whole chain in one place: JWT → userIdfindByIdhdIndexHdWallet.addressFor. hd-wallet.spec.ts:28 asserts that the address shown to the user and the address that signs the sweep are the same one.