Authentication
How Eziseller issues, verifies, and revokes JWTs for dashboard users. Audience: new dev with Node/React experience.
1. Overview
Eziseller uses a two-token JWT scheme: a short-lived access token sent on every API call as a Bearer header, and a longer-lived refresh token used to mint new access tokens without re-prompting for credentials. Splitting the two lets us keep the access token's blast radius small (if leaked, it expires quickly) while still letting the user stay signed in for days. Access tokens are stateless (pure JWT verify), refresh tokens are tracked in an in-memory Set on the backend so logout / rotation can revoke them. Both tokens are stored client-side in cookies by js-cookie and attached via an axios request interceptor.
The system also powers impersonation for super-admins (see super-admin-portal.md) by cookie-swapping the active token pair.
2. Architecture
The frontend authApi (axios instance) injects the access token on every request and transparently calls /auth/refresh on a 401, retrying the original request once. On refresh failure it dispatches a session-expired event that AuthContext catches and redirects to /login.
3. Data model
Auth-relevant User fields (see schema.prisma:L20-L69):
| Field | Purpose |
|---|---|
id | Embedded in JWT as userId |
email | Unique login identifier |
passwordHash | bcrypt, 10 rounds |
role | OWNER | STAFF | SUPER_ADMIN — drives RBAC |
ownerId | For STAFF, points to their OWNER; embedded in JWT for tenant scoping |
Refresh tokens are not persisted — see Gotchas.
4. Key flows
4.1 Login
4.2 Token refresh (on 401)
Triggers: any non-auth request returning 401 while a refresh token cookie exists and _retry hasn't been set. The interceptor guards against infinite loops by skipping /login, /register, /refresh.
4.3 Logout / revoke
/logout requires a valid access token; if it fails, the frontend still clears cookies client-side.
5. Lifecycle
6. Key files
- backend/lib/auth.ts:L1-L100 —
AuthService(hash, sign, verify) +authenticateTokenmiddleware - backend/routes/auth.ts:L26-L66 — rate limiters (register/login/password-reset)
- backend/routes/auth.ts:L69 —
refreshTokenStore = new Set<string>() - backend/routes/auth.ts:L347-L410 —
/refreshwith rotation - backend/routes/auth.ts:L423-L435 —
/logout - backend/middleware/auth.ts:L9-L41 — older/duplicate middleware (prefer
lib/auth.tsversion) - src/contexts/AuthContext.tsx:L110-L186 — init,
session-expiredlistener - src/lib/auth.ts:L33-L74 — axios 401 interceptor + refresh retry
- src/lib/auth.ts:L101-L139 — impersonation cookie swap
7. Env vars & config
| Var | Required | Purpose | What breaks |
|---|---|---|---|
JWT_SECRET | yes | Signs/verifies access tokens. Process throws on boot if missing. | Backend refuses to start |
JWT_REFRESH_SECRET | no | Separate secret for refresh tokens; falls back to JWT_SECRET | Refresh and access share a key — leak widens blast radius |
JWT_EXPIRES_IN | no | Access token TTL, default 24h | Too long → stolen token stays valid; too short → refresh storm |
REFRESH_TOKEN_EXPIRES_IN | no | Refresh token TTL, default 7d | Users logged out sooner / later |
FRONTEND_URL | yes | Used in welcome email links and CORS | Email links / cross-origin break |
8. Gotchas & troubleshooting
- Issue: every backend restart logs everyone out → Cause:
refreshTokenStoreis a plain in-memorySet(auth.ts:L69). All tracked refresh tokens vanish on process restart, so/refreshreturns 401 and clients redirect to/login. → Fix: move to Redis or aRefreshTokenPrisma model keyed byuserId+jtiwith expiry. - Issue: CLAUDE.md says access tokens expire in 1 minute, code defaults to
24h→ Cause: intent drifted from implementation;JWT_EXPIRES_INis overridable. → Fix: decide the policy and set it explicitly in the deployed env. A 1m TTL is aggressive and causes a refresh roundtrip on nearly every request. - Issue: brute-force on non-auth routes isn't rate-limited → Cause:
express-rate-limitis only applied per-router on/auth/register,/auth/login,/auth/password-resetand a few public catalog/checkout/payment routes; there is no global limiter. → Fix: add an app-level limiter inserver.tswith per-route overrides. - Issue: two
authenticateTokenimplementations exist → Cause:backend/middleware/auth.tsis an older copy;backend/lib/auth.tsis canonical and emits aTOKEN_EXPIREDcode the FE interceptor relies on. → Fix: delete the middleware copy, re-export fromlib/auth.ts. - Issue: impersonation survives admin session end → Cause:
startImpersonationswaps cookies client-side without a server-side marker; SubscriptionGate block on impersonation is deferred (see admin pending memo). → Fix: track impersonation server-side on the access token (purposeclaim). - Security hardening (commit 7e74ee6): tightened validation bounds, fixed race conditions in order creation, and enforced pagination caps. No auth-secret rotation was included — if
JWT_SECREThas ever been in a committed.env, rotate it.
9. Extension points
- Persistent refresh tokens: swap
refreshTokenStorefor a Prisma model (RefreshToken { id, userId, tokenHash, expiresAt, revokedAt }) and hash tokens at rest. Keep the Set as a fast-path cache if needed. - Per-device sessions: add a
deviceIdclaim to the refresh JWT and a corresponding DB row so users can revoke one browser without killing all sessions. - MFA: stage a
purpose: 'mfa_pending'JWT between/loginand/login/verify, re-usingAuthService.generateAccessToken. - SSO: plug a new route that verifies an external IdP assertion and then calls
AuthService.generateAccessToken+generateRefreshTokenwith the same payload shape.
10. Related docs
- rbac.md — how
roleandownerIdgate routes and tenant-scope queries - subscription-gating.md — plan/trial checks layered on top of auth
- super-admin-portal.md — impersonation flow