DreamerDreamer

Authentication

Dreamer supports two ways to sign in — email/password, and "Continue with GitHub" — that converge on the same session system underneath. This page covers both, plus the token/session model both of them produce.

Two token types, two different jobs

Access tokenRefresh token
FormatSigned JWT (HS256){sessionId}.{secret}, opaque to the client
Lifetime15 minutes (ACCESS_TOKEN_TTL)7 days (REFRESH_TOKEN_TTL_DAYS)
Where it livesIn memory on the frontend, sent as Authorization: BearerAn httpOnly cookie, scoped to /api/auth only
Stored server-side?No — verifying it is just a signature checkYes — one UserSession row per device
What it's forAuthorizing every normal API requestGetting a new access token when the old one expires

The split exists because these two things want opposite properties. An access token needs to be checked on every single request without hitting the database — so it's stateless, a pure signature verification. A refresh token needs to be revocable ("sign out this one device," "sign out everywhere after a password change") — which a stateless JWT fundamentally can't do without a separate blocklist, so it's backed by a real database row instead.

How a refresh token is actually stored

The raw token handed to the browser is {sessionId}.{secret} — but only bcrypt(secret) is ever written to UserSession.tokenHash, never the secret itself. This means a database leak alone doesn't hand out working sessions — comparing a presented token requires knowing the raw secret too, which is why the sessionId prefix matters: it lets /refresh do one indexed lookup by ID and then bcrypt-compare against exactly that one row, instead of bcrypt-comparing against every session in the table (bcrypt is deliberately slow — that's fine for one comparison per request, not for N).

Rotation, on every refresh

POST /api/auth/refresh doesn't just issue a new access token — it deletes the old session row and creates a brand new one, atomically. The refresh token in the cookie you just used is now permanently invalid, even if a copy of it leaked. This is what makes stealing a refresh token a race against the legitimate user's own next page load, not a standing, indefinite backdoor.

Email + password

Standard shape — POST /register, POST /login — with two details worth understanding:

Constant-time login, on purpose. login() runs a bcrypt comparison unconditionally, even when the email doesn't exist in the database at all:

const isValid = await verifyPassword(input.password, user?.passwordHash ?? DUMMY_PASSWORD_HASH);

Without this, a request for a non-existent email returns measurably faster than one for a real email with a wrong password (no bcrypt call happened at all) — a timing side-channel that lets an attacker enumerate which emails have accounts, purely from response latency. Running the same expensive comparison against a hash of a string that will never be a real password closes that gap.

Passwords are capped at 72 characters — not an arbitrary product decision, bcrypt silently truncates anything beyond 72 bytes and processes the rest as if it weren't there. The validation schema enforces this explicitly rather than letting a user set a 200-character password that bcrypt quietly treats as its first 72 characters.

GitHub OAuth

1. GET /api/auth/github
      → generates a random `state`, stores it in a short-lived httpOnly cookie
      → redirects to GitHub's consent screen

2. User approves on GitHub

3. GET /api/auth/github/callback?code=...&state=...
      → verifies `state` matches the cookie (CSRF protection — see below)
      → exchanges `code` for a GitHub access token
      → fetches the GitHub profile + primary VERIFIED email
      → find-or-create-or-link a User (see below)
      → sets the refresh token cookie
      → redirects to {FRONTEND_URL}/auth/callback  (NOT with a token in the URL)

4. Frontend's /auth/callback page immediately calls POST /api/auth/refresh
      → reads the httpOnly cookie just set, returns a fresh access token
        straight into memory

Why the access token never appears in the redirect URL. URLs end up in browser history and server access logs — putting a bearer token there means it lives on well past the moment it was needed. Landing on a callback page that immediately trades the httpOnly cookie for a token avoids ever putting a credential somewhere it'll be logged.

The state cookie is SameSite=Lax, not Strict. This is deliberate, not an oversight — GitHub's redirect back to the callback URL is a cross-site top-level navigation. A Strict cookie would not be sent on that request at all, which would break the CSRF check the state parameter exists to perform. Lax is the correct setting for a cookie that specifically needs to survive exactly one cross-site redirect.

1. profile.id already linked to a User      → log that user in
2. no link, but a VERIFIED email matches
   an existing password-based account        → link GitHub to it, log in
3. neither                                    → create a new account

Step 2 only ever triggers on a verified email (fetchPrimaryVerifiedGithubEmail explicitly filters for primary && verified). This is the line that prevents account takeover: GitHub lets a user set any email as a profile field regardless of whether they own it, but the /user/emails endpoint's verified flag can't be spoofed the same way — it reflects GitHub's own verification, not something the OAuth app controls. Auto-linking on an unverified email would mean anyone could claim someone else's existing account just by typing their email into a GitHub profile.

A GitHub user with no public/verified email at all still gets an account — email is NOT NULL UNIQUE on the User table, so one is synthesized: {githubId}+{login}@users.noreply.github.com, guaranteed unique by construction (GitHub's own numeric user ID is in it).

Middleware: requireAuth

Every protected route runs through one middleware that does exactly one job — verify the Authorization: Bearer <token> header, attach { id, email } to req.user, and call next(). The one thing worth noticing is that it distinguishes why verification failed:

if (err instanceof jwt.TokenExpiredError) {
  return next(new UnauthorizedError('Access token expired', 'TOKEN_EXPIRED'));
}
return next(new UnauthorizedError('Invalid access token', 'INVALID_TOKEN'));

The frontend's fetch/axios interceptor checks for exactly the TOKEN_EXPIRED code to decide whether it's safe to attempt a silent /refresh and retry — vs. INVALID_TOKEN (a malformed or tampered token), which goes straight to the login page instead. Collapsing both into one generic 401 would mean a routine, expected token expiry looks identical to an actual security-relevant failure, and the frontend couldn't tell them apart to react differently.

GitHub tokens at rest

A user's GitHub access token (needed for repo listing, cloning private repos, and reading files during framework detection) is stored encrypted, not plaintext, via lib/crypto.ts's encryptForStorage/decryptFromStorage (AES, keyed by ENCRYPTION_KEY — see SELF-HOSTING.md for how that key gets generated). Every place that reads it back out (build-config.controller.ts, deployment.service.ts for private-repo clones) decrypts it fresh, on demand — it's never held decrypted longer than the single operation that needs it.

Sessions UI

GET /api/auth/sessions lists every active UserSession for the current user (device/IP/last-used, never the token itself), and DELETE /api/auth/sessions/:sessionId revokes one — this is what powers a "log out this device" list in account settings. POST /api/auth/logout-all revokes every session at once, used after a password change so an attacker who had a valid session before the password was changed gets kicked out too.

Endpoint reference

MethodPathAuth requiredPurpose
POST/api/auth/registerNoCreate an email/password account
POST/api/auth/loginNoEmail/password login
POST/api/auth/refreshNo (cookie)Exchange refresh cookie for a new access token
POST/api/auth/logoutNo (cookie)Revoke the current session
POST/api/auth/logout-allYesRevoke every session for this user
GET/api/auth/meYesCurrent user's profile
GET/api/auth/sessionsYesList active sessions
DELETE/api/auth/sessions/:idYesRevoke one session
POST/api/auth/change-passwordYesChange password (requires current password if one is set)
GET/api/auth/githubNoStart the GitHub OAuth flow
GET/api/auth/github/callbackNoGitHub redirects here

All login/register/refresh routes are rate-limited (loginRateLimiter/registerRateLimiter/refreshRateLimiter) — see middleware/rate-limiter.middleware.ts.