Authentication Flow

The authentication system uses a two-token strategy: a short-lived JWT access token (15 min) and a long-lived HttpOnly cookie refresh token (7 days). New users are invited by admins — there is no self-registration.

Actors and tokens

TokenTypeTTLStorageTransport
Access tokenHS256 JWT15 minRedux state + localStorageAuthorization: Bearer <token>
Refresh tokenRandom 64-char hex7 daysHttpOnly cookieCookie (auto on /api/auth/*)
Signup tokenHS256 JWT24 hrInvite URL query paramAuthorization: Bearer <token>

Endpoints

MethodPathAuth requiredDescription
POST/api/auth/loginNone (rate limited)Verify email + password; issue tokens
POST/api/auth/create-userAuth (admin)Create user + send invite email
POST/api/auth/set-passwordSignup tokenSet initial password; issue tokens
POST/api/auth/change-passwordAuthChange password; rotate tokens
POST/api/auth/refreshNone (rate limited)Rotate refresh token; issue new access token
POST/api/auth/logoutNoneRevoke refresh token; clear cookie

Login Flow

Client                          Backend                     Database
  │                                │                            │
  │  POST /api/auth/login          │                            │
  │  { email, password }           │                            │
  │ ─────────────────────────────▶ │                            │
  │                                │  SELECT user BY email      │
  │                                │ ──────────────────────────▶│
  │                                │  ◀── user row (with hash) ─│
  │                                │                            │
  │                                │  Bun.password.verify()     │
  │                                │  signAuthToken() → JWT     │
  │                                │  generateRefreshToken()    │
  │                                │  hashRefreshToken()        │
  │                                │  INSERT refresh_tokens     │
  │                                │ ──────────────────────────▶│
  │                                │                            │
  │  200 { token: "<JWT>" }        │                            │
  │  Set-Cookie: refresh_token=... │                            │
  │ ◀───────────────────────────── │                            │
  │                                │                            │
  │  Redux: setToken(token)        │                            │

Invite / Signup Flow

Admin                           Backend                     New User
  │                                │                            │
  │  POST /api/auth/create-user    │                            │
  │  { email, name }               │                            │
  │ ─────────────────────────────▶ │                            │
  │                                │  INSERT users              │
  │                                │  signSignupToken() → JWT   │
  │                                │  sendMail(signupLink)      │
  │  201 { signupLink }            │                            │
  │ ◀───────────────────────────── │                            │
  │                                │                            │
  │                                │    Email: signup link      │
  │                                │ ─────────────────────────▶ │
  │                                │                            │
  │                                │  GET /set-password?token=X │
  │                                │ ◀──────────────────────────│
  │                                │                            │
  │                                │  POST /api/auth/set-password
  │                                │  Authorization: Bearer <signup JWT>
  │                                │ ◀──────────────────────────│
  │                                │  signupTokenMiddleware validates │
  │                                │  Bun.password.hash()       │
  │                                │  UPDATE users.password     │
  │                                │  signAuthToken() + refresh │
  │                                │  200 { token } + cookie   │
  │                                │ ──────────────────────────▶│

Token Refresh Flow (automatic, via RTK Query)

Frontend (RTK Query)             Backend
  │                                │
  │  GET /api/user  (expired JWT)  │
  │ ─────────────────────────────▶ │
  │  ◀── 401 { unauthorized }───── │
  │                                │
  │  POST /api/auth/refresh        │
  │  (refresh cookie sent auto)    │
  │ ─────────────────────────────▶ │
  │                                │  hashRefreshToken()
  │                                │  SELECT refresh_tokens WHERE hash=?
  │                                │  Check expiry
  │                                │  DELETE old token (rotation)
  │                                │  signAuthToken() + new refresh
  │                                │  INSERT new refresh_tokens
  │  200 { token: "<new JWT>" }    │
  │  Set-Cookie: refresh_token=... │
  │ ◀───────────────────────────── │
  │                                │
  │  Redux: setToken(newToken)     │
  │  Retry original GET /api/user  │
  │ ─────────────────────────────▶ │

If the refresh also fails (expired cookie, token not found):

  1. Backend returns 401 and sets Set-Cookie with Max-Age=0 to clear the cookie
  2. RTK Query’s baseQueryWithReauth calls clearToken() in Redux
  3. ProtectedRoute detects token === null and redirects to /login

Change Password Flow

Client (authenticated)          Backend
  │                                │
  │  POST /api/auth/change-password│
  │  { currentPassword, newPassword} │
  │  Authorization: Bearer <JWT>   │
  │ ─────────────────────────────▶ │
  │                                │  authMiddleware → verify JWT
  │                                │  getById(sub) → user
  │                                │  Bun.password.verify(current, hash)
  │                                │  Bun.password.hash(newPassword)
  │                                │  UPDATE users.password
  │                                │  signAuthToken() (new JWT)
  │                                │  generateRefreshToken() + hash
  │                                │  INSERT refresh_tokens
  │  200 { token }                 │
  │  Set-Cookie: refresh_token=... │
  │ ◀───────────────────────────── │

Middleware Chain

Routes use withMiddleware(...fns)(handler):

withMiddleware(authMiddleware)((req, ctx) =>
  controller.changePassword(req, ctx),
);

withMiddleware runs each middleware in sequence. A middleware returns null to pass through or a Response to short-circuit the chain.

MiddlewareApplied toWhat it does
authMiddlewareMost /api/* routesVerifies Bearer JWT; rejects signup tokens
signupTokenMiddleware/api/auth/set-passwordVerifies signup JWT; rejects auth tokens
authRateLimit/api/auth/login, /api/auth/refresh10 req/min per IP
requireRole('admin')/api/auth/create-user403 unless role === ‘admin’

Security Properties

ThreatMitigation
XSS token theftRefresh token is HttpOnly — inaccessible to JavaScript
CSRFSameSite=Strict — cookie not sent on cross-origin requests
Refresh token reuseRotation: each token is single-use; reuse returns 401
User enumerationLogin returns the same error for wrong email and wrong password
Brute forceRate limit: 10 attempts/min per IP on login + refresh
Role escalationRole is embedded in JWT and verified on each request