Authentication
Everything worth reading is public and needs no credentials. A session exists only for the endpoints that act on behalf of one wallet, and it is created by signing a message, not by a password.
Public, no credentials
Node ownership is public data. The same list can be read off the contract with nodesOf(address), so putting it behind a key would protect nothing and only make the protocol harder to verify.
| Endpoint | Needs |
|---|---|
GET /api/stats | Nothing |
GET /api/nodes/:address | Nothing |
GET /api/node/:id | Nothing |
GET /api/distributions | Nothing |
GET /api/deploy-quote | Nothing |
GET /api/price | Nothing |
GET /api/cron/health | Nothing |
/api/deploy-quote is unauthenticated because a wallet needs the payment address and the exact amount before it has signed anything, and /api/cron/health because everything it reports is already readable from the chain. Both are covered on the API overview.
Session endpoints
A session is needed for the routes that act for a specific wallet. There are two, and they exist for the dashboard rather than for general use.
| Endpoint | Purpose |
|---|---|
GET /api/me | The signed-in wallet, its nodes, totals, and any node the chain shows that the ledger has not recorded |
POST /api/nodes/sync | Register a node that was just minted, from its transaction hash or id |
There is no session route for withdrawing, because withdrawing does not go through this API at all. See Withdrawing.
Signing in
Two calls, and one wallet signature between them.
POST /api/auth/nonce // GET works too and returns the same thing
-> { "nonce": "…", "message": "…" }The nonce is also set in an httpOnly cookie. The signature is verified against that copy, so a nonce captured from the response body is useless in another browser. The message field is the exact string to pass to personal_sign; rebuilding it yourself risks drifting from what the server verifies.
POST /api/auth/verify
{ "address": "0x…", "signature": "0x…" }
-> { "address": "0x…" } plus a session cookieThe address in the body is not a claim of identity on its own. It is accepted only because the signature over this browser’s nonce recovers to it, and the nonce cookie is deleted as soon as it has been used, so a captured signature cannot be replayed. Every route after this one reads the address from the cookie, never from a body.
Signing in is not a transaction. It costs no gas, appears nowhere on chain, and grants no permission to move funds. It proves one thing: that you control the private key for that address.
POST /api/auth/logout
-> { "ok": true } // clears the session and any half-finished sign-inSigning out always succeeds. Ending a session that had already lapsed is not an error.
The cookie
- httpOnly, so page scripts cannot read it.
- Signed with a server secret. The value is the address, the expiry, and an HMAC over the two, so it cannot be forged or edited.
- SameSite=Lax, so another site cannot cause your browser to use it, and Secure in production.
- Expiring, seven days from sign-in. When it lapses, sign in again.
- Stateless. Nothing is stored server side, so there is no session table holding a list of who is signed in.
Requests carrying a session are answered with cache-control: private, no-store, so nothing about one wallet can be served to another from a shared cache.
What a session cannot do
A session cannot move your funds, and the reason is stronger than a permission check. Withdrawing is a direct call from the node owner’s own wallet to the factory. The contract compares the caller against node.owner and reverts if they differ, so the only thing that can withdraw is the key that owns the node. The server has no part in it: it signs nothing, approves nothing, and holds no key that could authorise a payout.
What a stolen session gets someone is a view of data that is mostly public anyway. Node ownership, balances and history can all be read from the contract by anyone. Revoke a session you do not trust with POST /api/auth/logout, then treat the wallet itself as the thing that actually needs protecting. See Withdrawing and Security model.
Operator keys
Two header secrets exist alongside the wallet session. Neither is part of the public API, neither is documented field by field, and no wallet signature grants either one: they are server configuration, not something a user can hold.
| Header | Gates | When it is not configured |
|---|---|---|
x-admin-key | Everything under /api/admin | 404, so an unconfigured admin surface does not advertise that it exists |
x-cron-key | /api/cron/payments and /api/cron/credit, the two scheduled passes that mint nodes and credit balances | /api/cron/payments answers 404, for the same reason as the admin surface. /api/cron/credit fails closed with a 401 instead: a route that spends real ETH must refuse everyone rather than open up when a variable is missing. |
/api/cron/payments also accepts the key as authorization: Bearer, because some schedulers can only set that header. /api/cron/credit reads x-cron-key only. Both keys are compared in constant time, and a wrong key is answered with 401 and no detail. /api/cron/health takes no key at all; it only reports.
Status codes you will see
| Status | Meaning on a session route |
|---|---|
| 200 | Signed in, request served |
| 400 | The body was malformed: not JSON, not an object, a missing field, or an id or hash that does not parse |
| 401 | Not signed in, the session expired, the sign-in nonce had already been used or lapsed, or the signature did not recover to the address given. On /api/me this is a normal answer for a visitor who has not connected. |
| 403 | On /api/nodes/sync: the node exists but belongs to a different wallet |
| 404 | On /api/nodes/sync: no such node, or its mint transaction has not confirmed yet |
| 409 | On /api/nodes/sync: the chain no longer shows that node as yours, so nothing was recorded |
| 429 | Rate limited. Sign-in and /api/nodes/sync are limited more tightly than reads: 20 requests per minute per IP, against 120 for /api/me. |
The full table, including what each error means and whether retrying helps, is on Errors.