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
| Token | Type | TTL | Storage | Transport |
|---|---|---|---|---|
| Access token | HS256 JWT | 15 min | Redux state + localStorage | Authorization: Bearer <token> |
| Refresh token | Random 64-char hex | 7 days | HttpOnly cookie | Cookie (auto on /api/auth/*) |
| Signup token | HS256 JWT | 24 hr | Invite URL query param | Authorization: Bearer <token> |
Endpoints
| Method | Path | Auth required | Description |
|---|---|---|---|
| POST | /api/auth/login | None (rate limited) | Verify email + password; issue tokens |
| POST | /api/auth/create-user | Auth (admin) | Create user + send invite email |
| POST | /api/auth/set-password | Signup token | Set initial password; issue tokens |
| POST | /api/auth/change-password | Auth | Change password; rotate tokens |
| POST | /api/auth/refresh | None (rate limited) | Rotate refresh token; issue new access token |
| POST | /api/auth/logout | None | Revoke 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):
- Backend returns 401 and sets
Set-CookiewithMax-Age=0to clear the cookie - RTK Query’s
baseQueryWithReauthcallsclearToken()in Redux ProtectedRoutedetectstoken === nulland 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.
| Middleware | Applied to | What it does |
|---|---|---|
authMiddleware | Most /api/* routes | Verifies Bearer JWT; rejects signup tokens |
signupTokenMiddleware | /api/auth/set-password | Verifies signup JWT; rejects auth tokens |
authRateLimit | /api/auth/login, /api/auth/refresh | 10 req/min per IP |
requireRole('admin') | /api/auth/create-user | 403 unless role === ‘admin’ |
Security Properties
| Threat | Mitigation |
|---|---|
| XSS token theft | Refresh token is HttpOnly — inaccessible to JavaScript |
| CSRF | SameSite=Strict — cookie not sent on cross-origin requests |
| Refresh token reuse | Rotation: each token is single-use; reuse returns 401 |
| User enumeration | Login returns the same error for wrong email and wrong password |
| Brute force | Rate limit: 10 attempts/min per IP on login + refresh |
| Role escalation | Role is embedded in JWT and verified on each request |