Authentication
Surplus uses passwordless authentication. There are no passwords to store, leak, or reset — users prove their identity with a one-time code sent to their email, and a signed session token keeps them signed in.
Sign-in flow
- Request a code. The user enters their email.
POST /auth/otp/requestgenerates a six-digit one-time code, stores it in Redis with a short expiry, and emails it through Resend. The code is the only secret, and it's never stored in the primary database. - Validate the code. The user enters the code.
POST /auth/otp/validatechecks it against Redis and, on success, issues a session token and sets it as an HttpOnly cookie. - Stay signed in. Subsequent requests carry the cookie automatically; the server verifies it on every request.
- Sign out.
POST /auth/logoutclears the cookie.
Enumeration resistance
The request-code endpoint returns the same successful response whether or not the email belongs to a real account. An attacker cannot use it to discover which emails are registered. A code is only actually generated and sent when the email matches an active user.
Rate limiting
The OTP endpoints are rate-limited in apps/server/src/helpers/rateLimit.ts to blunt brute-force and abuse:
- Requesting a code — 5 attempts per 15 minutes, keyed by email and IP.
- Validating a code — 10 attempts per 15 minutes, keyed by email.
Session tokens (JWT)
On successful sign-in the server issues a JWT, created in packages/services/src/auth/createToken.ts:
- Algorithm: HS256, signed with a server-only secret via
jose. - Payload: only the user's
emailanduserId— no roles or permissions are baked into the token. - Lifetime: 60 days.
Because permissions are deliberately not in the token, every request loads the user's current permission from the database. Suspending or changing a user's access takes effect immediately on their next request — there's no stale-token window.
The session cookie
The token is delivered in an HttpOnly cookie, configured in apps/server/src/routes/auth/utils.ts:
- HttpOnly — JavaScript cannot read it, which neutralizes token theft via cross-site scripting.
- Secure — set on staging and production (HTTPS), so the cookie is never sent over plain HTTP.
- SameSite=Lax — the cookie is not sent on cross-site subrequests, mitigating cross-site request forgery (CSRF).
- Max-Age: 60 days, matching the token lifetime so the cookie and token expire together.
- Environment-scoped name and domain — the cookie is named per environment (for example
se_surplus_auth_production) and scoped to the matching domain, so staging and production sessions never collide. In local development it's host-only and not marked Secure.
Verifying a request
On every authenticated request, the server (in apps/server/src/helpers/middleware.ts):
- reads the env-scoped cookie,
- verifies the JWT signature and expiry,
- loads the user row from the database (cached for the duration of that single request),
- rejects the request if the token is missing or invalid (
401), or if the account is suspended (403).
Authorization — what a verified user is allowed to do — is covered next in Authorization and access control.
Terms acceptance
Users must accept the current Terms of Use and Privacy Policy version (TERMS_VERSION in packages/utils) before using the app. The client enforces this with a gate, and a dedicated endpoint records acceptance. Bumping the version re-prompts everyone.