Concepts
Security model
What a verified proof guarantees, what it doesn't, and the four things to harden before real users show up.
The generated code is small on purpose, so you can audit it in one sitting. This page tells you what to look for.
What a verified proof guarantees#
When verifySignedRequest() or loginRoute() says valid: true, you know:
| Guarantee | Because | Tested |
|---|---|---|
Who: the holder of identityKey signed it |
an ECDSA signature only that wallet can make | ✔ |
What: this exact action and body |
both are inside the signed bytes | ✔ changed body and changed action are rejected |
| For whom: your server, and only yours | the signing key is derived with your server's identity as counterparty | ✔ another server rejects it |
| When: within the last ~2 minutes | expiresAt is signed, with a 2-minute window and 30 s of clock skew allowed |
by @bsv/auth |
| Once: it was never accepted before | consumeNonce refuses repeats |
✔ replay rejected |
The ✔ rows are covered by the test file on the Testing page. Run it yourself.
What it does not give you#
- Authorization. A valid proof tells you who, not whether they're allowed. Check
identityKeyagainst your own rules (owners, roles, allow-lists) before doing anything. - Secrecy. Proofs aren't encrypted. Use HTTPS, which production config enforces.
- Sessions. Every signed request stands alone. If you want "logged in for an hour", add a session after login.
- CORS is not security.
CLIENT_ORIGINcontrols which browser pages can read responses. Scripts, servers andcurlignore it. Only the proof check authenticates anyone.
Server identity is trusted via your API origin#
Before signing, the client fetches the server's identity key from GET /api/identity. It trusts whatever comes back from VITE_API_URL over HTTPS, so the trust is exactly as strong as your DNS and TLS. For most apps that's fine.
When it isn't (a mobile app that must keep talking to the same server identity across domain moves, proxies or CDNs), pin the key:
const { login } = useWalletLogin({ serverIdentityKey: '03d234…' })
const { signedFetch } = useSignedRequest('03d234…')Pinned keys skip the fetch entirely. Pinning only works if SERVER_PRIVATE_KEY never changes, so set it and keep it.
Replay protection across instances#
The generated nonceStore.ts is a Map in process memory. That's correct for one process, but:
- Two or more instances: a proof consumed on instance A is unknown to instance B, so it can be replayed there within its 2-minute window.
- Restarts: the memory is wiped. Proofs expire after 2 minutes anyway, so the window is small.
- Capacity: it holds 10,000 nonces. When it's full it rejects new proofs until old ones expire. That fails safe, but it's a cheap denial-of-service: anyone can generate keys and valid proofs. Put a rate limit in front of
/api/loginand your signed routes.
For anything beyond one instance, use an atomic shared store. With Redis it's one command, SET … NX PXAT:
// Replay protection shared by every server instance. SET NX is atomic: only the first caller wins.
import { createClient } from 'redis'
const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()
export async function consumeNonce (nonce: string, expiresAt: Date): Promise<boolean> {
if (expiresAt.getTime() <= Date.now()) return false
const ok = await redis.set(`bsv-nonce:${nonce}`, '1', { NX: true, PXAT: expiresAt.getTime() })
return ok === 'OK'
}Then import consumeNonce from ./bsv/redisNonceStore.js instead of ./bsv/nonceStore.js in loginRoute.ts and your signed routes. Redis deletes each key when its proof expires, so the store never grows. (Type-checked against redis 6. Any store with an atomic "insert if absent" works: a unique index in Postgres or Mongo, for example.)
The API client is deliberately strict#
Every generated request goes through client/src/bsv/apiClient.ts, which:
- only calls
VITE_API_URL. Paths must be plain (/api/x), with no query strings and no... - refuses redirects, so a proof can never be forwarded to another host.
- sends no cookies or credentials (
credentials: 'omit') and no referrer. - times out after 10 s and caps requests and responses at 1 MiB.
- rejects responses that aren't strict UTF-8 JSON.
The server side matches: express.json({ limit: '64kb' }). Loosen any of these deliberately, not accidentally.
Before you launch#
- Every route that changes data checks a proof and authorizes the
identityKey - Nonce store is shared, or you run exactly one instance
- Rate limits on
/api/loginand signed routes SERVER_PRIVATE_KEYis stable, secret and backed up- Demo routes (
/api/echo, demo pages) removed - HTTPS everywhere (production config refuses anything else)
- You've read
auth.ts,nonceStore.ts,config.tsandapiClient.ts. Together they're a few hundred lines, and they're the whole trust boundary.