Skip to content
BoringStack
GitHub

Authentication

8 min read

Auth contract

Authentication follows one browser contract: HttpOnly cookies, server-side OAuth, verify-before-account signup, and DB-backed refresh sessions that can be rotated or revoked without exposing tokens to the UI.

15 min

access cookie

30 days

refresh session

0

tokens in frontend storage

Two login flows share that contract:

  • Password: register / verify-email / login / forgot-password / reset-password.
  • OAuth: Google, GitHub, LinkedIn. Server-side; the SPA never holds a client secret.

Both converge at the same point: accountsService.provisionAfterVerification, the single place that creates the personal account and owner membership. Without verification, there is no account, so abandoned signups leave no orphan tenant rows in the database.

Once verified, a session ends the same way regardless of which flow produced it:

  • auth_token: a 15-minute stateless JWT in an HttpOnly cookie. The frontend never reads or stores it.
  • refresh_token: a 30-day opaque HttpOnly cookie. The API stores only its HMAC hash in auth.sessions, rotates it on every refresh, and can revoke it on logout or password reset.

The model is hybrid: fast stateless access checks, stateful refresh sessions for revocation and safer long-lived login.

signup

No account before verification

Register writes a pending user and verification token. The tenant row appears only after email verification or verified OAuth.

browser

Frontend never stores tokens

The SPA relies on browser-managed HttpOnly cookies and the generated OpenAPI client.

session

Refresh is stateful

Long-lived login lives in auth.sessions as a hash, which can be rotated, revoked, or deleted on password reset.

Password signup splits across two endpoints. POST /auth/register writes only the pending user row, the bcrypt hash, and a single-use verification token. No app.accounts row, no membership, no session cookies. The response is a { message } envelope so the SPA can render “check your inbox at user@example.com.” POST /auth/verify-email flips users.email_verified_at, atomically calls provisionAfterVerification, and then issues the auth + refresh cookies. That’s where the user gets their account.

sequenceDiagram
  participant B as Browser
  participant API as API (Elysia)
  participant DB as Postgres
  participant Mail
  B->>API: POST /auth/register
  API->>DB: INSERT users (email_verified_at = NULL)
  API->>DB: INSERT user_auth_providers (password hash)
  API->>DB: INSERT email_verification_tokens
  API->>Mail: send verification link
  API-->>B: 200 message envelope (verification email sent)
  Note over B,API: NO cookies set. User cannot log in yet.
  B->>B: user clicks link in email
  B->>API: POST /auth/verify-email { token }
  API->>DB: UPDATE users SET email_verified_at = now()
  API->>DB: provisionAfterVerification then INSERT accounts + memberships
  API-->>B: 200 + auth_token + refresh_token (idempotent, double-click safe)

The OAuth flow lands at the same provisionAfterVerification call. Branches that converge there:

  • Brand-new OAuth signup with a provider-verified email: user created, provisioned, session issued.
  • Existing pending password-signup signing in with OAuth: user gets promoted to verified, the OAuth link is added, the account is provisioned. (This branch quietly fixed a pre-VBA bug where pending users could get OAuth-linked but never end up with an account.)
  • Existing already-verified user adding another OAuth provider: link added; provisionAfterVerification is idempotent (returns the existing account and membership).

OAuth refuses to issue a session when the IdP says emailVerified: false. The transaction rolls back with no user row, no provider link, and no half-state. The caller has to verify through the password flow first.

POST /auth/login with a still-pending user (correct password, email_verified_at is null) returns 403 EMAIL_NOT_VERIFIED. The check fires after the password verify so an attacker who doesn’t already know the password can’t enumerate which addresses are pending versus unknown. The UI surfaces a resend-verification CTA pinned to the email the user typed.

A daily cleanStalePendingUsersJob hard-deletes pending users older than 30 days (configurable). FK cascades drop the auth provider + verification token; audit.audit_log survives so the registration attempt remains traceable.

sequenceDiagram
  participant B as Browser
  participant API as API (Elysia)
  participant DB as Postgres
  B->>API: request with auth_token cookie
  API->>API: verify JWT (signature + exp)
  API->>DB: SELECT users WHERE id = <sub>
  DB-->>API: user row (or none, 401)
  API-->>B: response

createAuthMiddleware mounts this on protected route groups. Every handler in that group gets a typed user on its context. Token errors are categorized so the SPA can react cleanly (expired != malformed != missing).

sequenceDiagram
  participant B as Browser
  participant API as API (Elysia)
  participant DB as Postgres
  B->>API: POST /auth/refresh with refresh_token cookie
  API->>API: HMAC(refresh_token)
  API->>DB: UPDATE auth.sessions SET token_hash=<new>, expires_at=<new> WHERE token_hash=<old> AND expires_at > now()
  alt matching live session
    DB-->>API: userId
    API->>DB: SELECT users WHERE id = userId
    API-->>B: set new auth_token + rotated refresh_token
  else missing / expired / replayed token
    DB-->>API: no row
    API-->>B: 401
  end

Refresh-token rotation means a replayed old refresh token no longer matches a row. Logout deletes the current refresh session. Password reset deletes all refresh sessions for that user.

JWT in HttpOnly cookie (not localStorage)

XSS cannot read the access token because it is never exposed to JavaScript.

SameSite=strict in prod, lax in dev

CSRF protection without breaking same-origin dev.

Short access JWT (15 minutes)

Normal API requests stay cheap: verify the cookie signature and expiry, then load the user row.

Stateful refresh sessions

Long-lived login lives in auth.sessions, keyed by a hash of an opaque token. The API can revoke one session or all sessions for a user.

Frontend never stores tokens

The SPA relies on browser-managed cookies and the generated OpenAPI client. Auth responses return user, not a bearer token.

Two tables: users and user_auth_providers

One user can hold a password and N OAuth links; no nullable password column.

OAuth state in Valkey, not in a cookie

State must survive the cross-origin redirect; cookies on the IdP domain do not work.

Read-and-delete state consumption

Replay attacks fail by construction.

No account exists without a verified email

/register writes only the pending user row. The personal account + owner membership get created only at verify-email time (or inline at the OAuth callback when the IdP asserts the email is verified). Abandoned signups don’t leave orphan tenant rows.

`EMAIL_NOT_VERIFIED` (403) fires only after correct password

Password order on /login is: dummy-verify on lookup miss, then bcrypt, then check email_verified_at. An attacker who doesn’t know the password can’t enumerate pending users.

sequenceDiagram
  participant SPA
  participant API
  participant Valkey
  participant IdP
  SPA->>API: GET /auth/oauth/:provider
  API->>Valkey: SETEX oauth:state:<nonce> {codeVerifier} (10m TTL)
  API-->>SPA: 302 redirect to IdP authorize URL (state + PKCE challenge)
  SPA->>IdP: user authenticates
  IdP-->>API: 302 /auth/oauth/:provider/callback?code&state
  API->>Valkey: GETDEL oauth:state nonce
  Note over API,Valkey: null means replay/expired/forged, 401
  API->>IdP: exchange code + codeVerifier
  IdP-->>API: profile
  API->>API: find-or-create user, create refresh session, sign access JWT
  API-->>SPA: set auth_token + refresh_token cookies
  API-->>SPA: 302 ${FRONTEND_URL}/oauth/success

The state record holds the PKCE code verifier. Reading it consumes it; a second callback with the same state finds nothing. The 10-minute TTL accommodates a slow IdP without leaving stale state lying around.

A protected route looks like:

new Elysia().use(createAuthMiddleware()).get("/me", ({ user }) => ({ user })); // user: IUser, type-safe

Unauthenticated callers get a categorized 401 (tokenExpired, invalidToken, missingCookie). The UI client tries one guarded /auth/refresh when it sees a 401, then retries the original request. If refresh fails, ProtectedRoute redirects to login.

The auth.sessions table stores:

  • user_id
  • token_hash (HMAC-SHA256 of the opaque refresh token, never the raw token)
  • expires_at
  • timestamps

Email-verification and password-reset tokens follow the same rule: raw token only goes to the user, hash goes to Postgres.

  1. Add it to OAUTH_PROVIDERS and the env-key map in oauth.manifest.ts.
  2. Drop a provider module in src/lib/oauth/providers/ using Arctic’s class for that IdP.
  3. Add the client-id/secret pair to the env schema with a cross-field invariant (“required when provider enabled”).

The lint plugins refuse to merge a provider that skips the state-consume or PKCE wire-up.

See Lint as the contract for why these matter.

src/api/auth/ and src/lib/oauth/ on GitHub; the routes, the services, the OAuth state store, and the providers.

  • Env validator; enforces the OAuth-credentials-when-enabled invariant.
  • Audit log; every auth event writes an audit row.