Auth & the wallet
The project uses sign-in-with-wallet: there is no password, no email. Proving you own a wallet's secret key is your identity. This page explains the full flow and the JWT layer on top.
The core idea
A wallet is just an ed25519 keypair (secret + public) stored in the browser. The address is derived from the public key. So:
- The server cannot look up "your password" — there isn't one.
- To prove who you are, you prove you hold the secret key.
- Proving = signing a random challenge the server gives you.
- Once proven, the server issues a JWT so you don't have to sign every request.
The exact same keypair also signs transactions. One key, two uses. Frontend → see the frontend crypto page
The login flow, step by step
Why a challenge instead of just signing your address?
If the server asked you to sign the same string every time, an attacker who captured one signature could replay it forever. A fresh random challenge that's single-use and expires in 10 minutes kills replay attacks. The challenge is stored in Postgres with a used flag.
JWT — the session layer
JWT = JSON Web Token. A signed blob of JSON that says "this address is authenticated". Two tokens are issued:
| Token | TTL | Used for |
|---|---|---|
| access | 1 hour | every protected API call (header) |
| refresh | 7 days | POST /auth/refresh to get a new pair |
1#[derive(Debug, Clone, Serialize, Deserialize)]2pub struct Claims {3pub sub: String, // address ("subject")4pub typ: String, // "access" | "refresh"5pub iat: i64, // issued-at6pub exp: i64, // expiry7}
The JWT is signed with HS256 (a shared secret from JWT_SECRET). The middleware verifies the signature, checks the type is "access", and reads sub — the address — which it places in the request for handlers to use.
1pub async fn require_auth(2State(state): State<AppState>,3mut req: Request,4next: Next,5) -> Result<Response, Response> {6let header = req.headers().get(AUTHORIZATION) ... unwrap_or("");7let token = header.strip_prefix("Bearer ")8.ok_or_else(|| 401 "missing bearer token")?;9match verify_token(&state.cfg.jwt_secret, token, "access") {10Ok(sub) => { req.extensions_mut().insert(AuthedAddress(sub)); Ok(next.run(req).await) }11Err(_) => Err(401 "invalid or expired token"),12}13}
The verification function
This one function is the heart of the auth and transaction system. It checks two things: the claimed address must be the derivation of the supplied public key, and the signature must validate under that key:
1pub fn verify_signed_by(pubkey_hex: &str, sig_hex: &str, msg: &[u8], claimed_address: &str) -> bool {2let pk = hex::decode(pubkey_hex)...;3let sig = hex::decode(sig_hex)...;4derive_address(&pk) == claimed_address && verify(&pk, msg, &sig)5}
Other middleware
A strict allowlist from ALLOWED_ORIGIN — never a wildcard. Only the configured frontend origins can call the API. Denies cross-origin requests by default if empty.
In-memory sliding window per client IP (default 240 req/min). Returns 429 when exceeded. Uses CF-Connecting-IP if behind Cloudflare.
Where this is used
- Frontend → Sign-in page — the UI that drives steps 1–5.
- Database → auth_challenges table — where challenges live.
- Backend → Transactions — the same key signs transfers.