signup
No account before verification
Register writes a pending user and verification token. The tenant row appears only after email verification or verified OAuth.
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:
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
Register writes a pending user and verification token. The tenant row appears only after email verification or verified OAuth.
browser
The SPA relies on browser-managed HttpOnly cookies and the generated OpenAPI client.
session
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:
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.
XSS cannot read the access token because it is never exposed to JavaScript.
CSRF protection without breaking same-origin dev.
Normal API requests stay cheap: verify the cookie signature and expiry, then load the user row.
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.
The SPA relies on browser-managed cookies and the generated OpenAPI client.
Auth responses return user, not a bearer token.
One user can hold a password and N OAuth links; no nullable password column.
State must survive the cross-origin redirect; cookies on the IdP domain do not work.
Replay attacks fail by construction.
/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.
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-safeUnauthenticated 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_idtoken_hash (HMAC-SHA256 of the opaque refresh token, never the raw token)expires_atEmail-verification and password-reset tokens follow the same rule: raw token only goes to the user, hash goes to Postgres.
OAUTH_PROVIDERS and the env-key map in oauth.manifest.ts.src/lib/oauth/providers/ using Arctic’s class for that IdP.The lint plugins refuse to merge a provider that skips the state-consume or PKCE wire-up.
@boring-stack-pkg/eslint-plugin-jwt-cookies; cookie attributes and JWT verify call sites.@boring-stack-pkg/eslint-plugin-oauth-security; state + PKCE invariants on the callback path.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.