Authentication
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.
Two login flows share this contract: password (register, verify-email, login, forgot-password, reset-password) and OAuth (Google, GitHub, LinkedIn). Both converge at one place: accountsService.provisionAfterVerification, which creates the personal account and owner membership. Without verification, there is no account.
Once verified, the session uses two tokens. An auth_token is a 15-minute stateless JWT in an HttpOnly cookie (frontend never reads it). A refresh_token is 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.
Verify-before-account
Section titled “Verify-before-account”Password signup splits across two endpoints. POST /auth/register writes only the pending user row, the argon2id 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.” The endpoint is enumeration-safe: an already-registered email gets the identical 200 response (no 409, no distinguishable body) — the account owner receives a “you already have an account” notice email with sign-in and reset links instead of a duplicate account. 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. The pre-verification password credential is retired so whoever originally entered that password cannot use it after promotion.
- Existing already-verified user adding another OAuth provider: link added;
provisionAfterVerificationis 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.
How a request gets authenticated
Section titled “How a request gets authenticated”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).
How refresh works
Section titled “How refresh works”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
API->>DB: check retired-token lineage; delete family on known replay
API-->>B: 401
end
Each successful rotation records retired hashes in auth.session_retired_tokens. Replaying a known retired token deletes its refresh family and records AUTH_REFRESH_REPLAY; the current token in that family can no longer refresh. The previous-token slot remains a rolling-deploy fallback. Logout deletes the current refresh session; password reset deletes all refresh sessions for that user.
Replay does not itself revoke already-issued access JWTs. They can remain valid for the rest of their 15-minute lifetime unless separately revoked. See the security upgrade runbook for the migration, backfill and mixed-writer limits.
Key decisions
Section titled “Key decisions”JWT lives in HttpOnly cookies, not localStorage, so XSS cannot read the access token. SameSite is strict in prod, lax in dev, for CSRF protection without breaking same-origin dev. The access JWT is short-lived (15 minutes), keeping normal API requests cheap. Refresh is stateful, living in auth.sessions keyed by a hash of an opaque token; the API can revoke one session or all sessions for a user.
One user can hold a password and N OAuth links (two tables: users and user_auth_providers), no nullable password column. OAuth state lives in Valkey and is read-and-deleted. A separate browser cookie holds a binding nonce whose hash is stored with the state. The callback requires both single-use state and the matching initiating-browser nonce.
No account exists without a verified email. /register writes only the pending user row. The personal account and owner membership are created only at verify-email time, or inline at the OAuth callback when the IdP asserts verified email. Abandoned signups don’t leave orphan tenant rows.
On /login, the password check order is: dummy-verify on lookup miss, then argon2id verify, then check email_verified_at. Legacy bcrypt hashes still verify (so existing users aren’t locked out) and get rehashed into argon2id transparently on the next successful login. Constant-shape work on every attempt means an attacker who doesn’t know the password can’t enumerate pending users by trying to login with unverified addresses.
The OAuth round-trip
Section titled “The OAuth round-trip”sequenceDiagram
participant SPA
participant API
participant Valkey
participant IdP
SPA->>API: GET /auth/oauth/:provider
API->>Valkey: SETEX oauth:state:<nonce> {codeVerifier, binding hash} (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->>API: require matching browser binding nonce
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 and browser-binding hash. Reading it consumes it; a second callback with the same state finds nothing. A missing hash, missing cookie nonce or mismatch is rejected before provider exchange. The 10-minute TTL bounds the flow.
The diagram’s session-issuance path is for users without MFA. An MFA-enabled OAuth user receives an HttpOnly challenge cookie and is redirected to /login?mfa=required; session cookies are issued only after successful factor verification. See MFA.
Using auth in routes
Section titled “Using auth in routes”A protected route uses createAuthMiddleware() to get a typed user on the context:
new Elysia().use(createAuthMiddleware()).get("/me", ({ user }) => ({ user }));Unauthenticated callers get a categorized 401 (tokenExpired, invalidToken, missingCookie). The UI client retries with a guarded /auth/refresh when it sees a 401, then retries the original request. If refresh fails, the SPA 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, and timestamps. Email-verification and password-reset tokens follow the same rule: raw token only goes to the user, hash goes to Postgres.
Adding an OAuth provider
Section titled “Adding an OAuth provider”- Add it to
OAUTH_PROVIDERSand the env-key map inoauth.manifest.ts. - Drop a provider module in
src/lib/oauth/providers/using Arctic’s class for that IdP. - 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.
Lint coverage
Section titled “Lint coverage”@boring-stack-pkg/eslint-plugin-jwt-cookies checks cookie attributes and JWT verify call sites. @boring-stack-pkg/eslint-plugin-oauth-security enforces state and PKCE invariants on the callback path. See Lint as the contract for why these matter.
Source
Section titled “Source”src/api/auth/ and src/lib/oauth/: routes, services, OAuth state store, and providers.
Related
Section titled “Related”- Env validator; enforces the OAuth-credentials-when-enabled invariant.
- Audit log; every auth event writes an audit row.