Core
Auth
Authentication

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):

FieldPurpose
idEmbedded in JWT as userId
emailUnique login identifier
passwordHashbcrypt, 10 rounds
roleOWNER | STAFF | SUPER_ADMIN — drives RBAC
ownerIdFor 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

7. Env vars & config

VarRequiredPurposeWhat breaks
JWT_SECRETyesSigns/verifies access tokens. Process throws on boot if missing.Backend refuses to start
JWT_REFRESH_SECRETnoSeparate secret for refresh tokens; falls back to JWT_SECRETRefresh and access share a key — leak widens blast radius
JWT_EXPIRES_INnoAccess token TTL, default 24hToo long → stolen token stays valid; too short → refresh storm
REFRESH_TOKEN_EXPIRES_INnoRefresh token TTL, default 7dUsers logged out sooner / later
FRONTEND_URLyesUsed in welcome email links and CORSEmail links / cross-origin break

8. Gotchas & troubleshooting

  • Issue: every backend restart logs everyone out → Cause: refreshTokenStore is a plain in-memory Set (auth.ts:L69). All tracked refresh tokens vanish on process restart, so /refresh returns 401 and clients redirect to /login. → Fix: move to Redis or a RefreshToken Prisma model keyed by userId + jti with expiry.
  • Issue: CLAUDE.md says access tokens expire in 1 minute, code defaults to 24hCause: intent drifted from implementation; JWT_EXPIRES_IN is 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-limit is only applied per-router on /auth/register, /auth/login, /auth/password-reset and a few public catalog/checkout/payment routes; there is no global limiter. → Fix: add an app-level limiter in server.ts with per-route overrides.
  • Issue: two authenticateToken implementations exist → Cause: backend/middleware/auth.ts is an older copy; backend/lib/auth.ts is canonical and emits a TOKEN_EXPIRED code the FE interceptor relies on. → Fix: delete the middleware copy, re-export from lib/auth.ts.
  • Issue: impersonation survives admin session end → Cause: startImpersonation swaps 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 (purpose claim).
  • 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_SECRET has ever been in a committed .env, rotate it.

9. Extension points

  • Persistent refresh tokens: swap refreshTokenStore for 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 deviceId claim 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 /login and /login/verify, re-using AuthService.generateAccessToken.
  • SSO: plug a new route that verifies an external IdP assertion and then calls AuthService.generateAccessToken + generateRefreshToken with the same payload shape.

10. Related docs