Surplus logo
Surplus Docsby Sharing Excess

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

  1. Request a code. The user enters their email. POST /auth/otp/request generates 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.
  2. Validate the code. The user enters the code. POST /auth/otp/validate checks it against Redis and, on success, issues a session token and sets it as an HttpOnly cookie.
  3. Stay signed in. Subsequent requests carry the cookie automatically; the server verifies it on every request.
  4. Sign out. POST /auth/logout clears 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 email and userId — 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 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):

  1. reads the env-scoped cookie,
  2. verifies the JWT signature and expiry,
  3. loads the user row from the database (cached for the duration of that single request),
  4. 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.