End-to-end specification for email + password authentication, JWT issuance, token lifecycle, refresh-token rotation, and logout cleanup. This is the most-used auth path in the marketplace and underpins every protected API call and Socket.IO room subscription.
Actors
User (Buyer / Seller / Admin)– submits credentials via the frontend.
Frontend (Next.js)– the JWT sign-in view in frontend/src/auth/view/jwt/jwt-sign-in-view.tsx, which delegates to signInWithPassword() in frontend/src/auth/context/jwt/action.ts.
Backend (Express)–AuthController.login in backend/src/services/auth/authController.ts.
MongoDB–User collection (refresh tokens are appended to user.refreshTokens[]).
Redis– session store (sessionService.createSession) and login-attempt rate-limiter (rateLimitService.checkLoginAttempts).
Socket.IO– not directly involved in login, but the issued JWT is later used to join user-${userId} rooms (see Notification Flow and Chat Flow).
Preconditions
The user has already completed Registration Flow and their User.isEmailVerified === true.
The user's User.status === "active" (soft-deleted accounts have status "deleted").
Network connectivity is available (the frontend uses NetworkUtils.isOnline() from frontend/src/auth/utils/error-handler.ts).
localStorage is available — the frontend rejects the request early via StorageUtils.isAvailable() if storage is blocked.
Backend env vars JWT_SECRET, JWT_EXPIRES_IN, REFRESH_TOKEN_EXPIRES_IN are set (backend/src/services/auth/authService.ts:19-21).
Step-by-step narrative
User fills the sign-in form at /auth/jwt/sign-in and clicks "Sign in". The form is implemented in frontend/src/auth/view/jwt/jwt-sign-in-view.tsx.
Client-side guards: signInWithPassword() (action.ts:32-116) verifies the browser is online and localStorage is writable; otherwise it throws a typed AuthErrorHandler error.
HTTP request: The frontend POSTs { email, password } to POST /api/auth/login (resolved by endpoints.auth.login in frontend/src/lib/axios.ts). An AbortController is armed with a 60-second timeout.
Validation middleware runs loginValidation (backend/src/services/auth/authValidation.ts) — wires into Express via authRoutes.ts:22.
Rate limiting: rateLimitService.checkLoginAttempts(email, 5, 15*60) is called (authController.ts:173). Five failures within 15 minutes returns 429 TOO_MANY_ATTEMPTS. Counters live in Redis so they survive restarts.
User lookup: User.findOne({ email, status: "active" }).select("+password") — password is select: false by default in the schema and must be explicitly projected.
Password comparison: authService.comparePassword() invokes bcrypt.compare() (cost factor 12 — see authService.ts:102-105). Constant-time per bcrypt's design.
Email-verification gate: If !user.isEmailVerified, returns 403 EMAIL_NOT_VERIFIED with needsVerification: true. The frontend intercepts this in action.ts:104-111 and redirects to /auth/jwt/verify?email=....
Reset attempts: On success, rateLimitService.resetLoginAttempts(email) wipes the Redis counter.
Last-login stamp: user.lastLoginAt = new Date() is saved.
Token issuance:
Access token — authService.generateToken() builds a JWT with { id, email, role, isEmailVerified, iat }, signed with HS256, issuer: 'marketplace-backend', audience: 'marketplace-users', default expiresIn from config.
Refresh token — authService.generateRefreshToken() issues a separate JWT with { id, type: 'refresh' } and a longer TTL.
Refresh-token persistence: The new refresh token is appended to the User.refreshTokens array (authController.ts:230-231). This array is the server-side allow-list — only tokens present here can be used to mint new access tokens.
Redis session: sessionService.createSession(accessToken, userId, email, role, ip, userAgent, 86400) stores a 24h session record keyed by the access token (authController.ts:235-247). Failures are logged but do not block login.
Response: 200 OK with { user: user.toJSON(), tokens: { accessToken, refreshToken } }. toJSON() strips password, refreshTokens, and verification codes (see User model toJSON transform).
Client-side storage: The frontend writes both tokens to localStorage via StorageUtils.safeSet() — keys accessToken and refreshToken (action.ts:77-82).
[!warning] Token storage is localStorage, not cookies
Tokens are persisted in window.localStorage. This is intentional (the API is a separate origin and the app is fully SPA-like), but it means tokens are reachable from any script running on the page. Mitigations in place: strict CSP via helmet, no third-party scripts in the auth views, and short access-token TTL with refresh rotation. There are no httpOnly auth cookies.
Axios interceptor (frontend/src/lib/axios.ts) attaches Authorization: Bearer ${accessToken} to every subsequent request and, on 401/403, automatically calls the refresh flow described below.
Socket.IO bootstrap: After login, the dashboard layout connects to Socket.IO and emits join-user-room, join-buyer-room/join-seller-room based on user.role. See backend/src/app.ts:83-126.
Sequence diagram
sequenceDiagram
autonumber
actor U as User
participant FE as Frontend (Next.js)
participant BE as Backend (Express)
participant DB as MongoDB
participant R as Redis
participant IO as Socket.IO
U->>FE: Enter email + password, click Sign in
FE->>FE: NetworkUtils.isOnline() / StorageUtils.isAvailable()
FE->>BE: POST /api/auth/login { email, password }
BE->>R: rateLimitService.checkLoginAttempts(email)
R-->>BE: { allowed: true, remaining }
BE->>DB: User.findOne({ email, status: "active" }).select("+password")
DB-->>BE: user document
BE->>BE: bcrypt.compare(password, user.password)
alt password invalid
BE-->>FE: 401 Invalid credentials
else email not verified
BE-->>FE: 403 EMAIL_NOT_VERIFIED
FE-->>U: Redirect /auth/jwt/verify
else success
BE->>R: rateLimitService.resetLoginAttempts(email)
BE->>DB: set user.lastLoginAt = now
BE->>DB: save new refresh token
BE->>BE: generateToken(authUser) / generateRefreshToken(authUser)
BE->>R: sessionService.createSession(accessToken, ...)
BE-->>FE: 200 { user, tokens: { accessToken, refreshToken } }
FE->>FE: localStorage.setItem('accessToken' / 'refreshToken')
FE->>IO: socket.emit('join-user-room', userId)
FE-->>U: Redirect to /dashboard
end
users collection: lastLoginAt updated; refreshTokens array gains one entry per successful login or refresh.
No write at all on a failed attempt (only Redis counter increments).
On changePassword / resetPassword: refreshTokens is reset to [] (forces re-login on every device — authController.ts:600 and :685).
Socket events emitted
Login itself emits nothing. After the response, the frontend emits the client-side events join-user-room, join-buyer-room or join-seller-room to subscribe to targeted notifications.
Side effects
Redis session: 24-hour key holding { userId, email, role, ip, userAgent }. Used for forced logout (admin can call sessionService.deleteSession(token)).
Redis rate-limit counter: TTL 15 min, reset on success.
No email is sent on a normal login (no "new sign-in" notification today — opportunity for future enhancement).
Sentry: any unexpected exception bubbles to Sentry.setupExpressErrorHandler (app.ts:351).
Refresh-token flow
The access token is short-lived. When a protected request returns 401 TOKEN_INVALID or 403, the axios interceptor calls:
POST /api/auth/refresh-token with { refreshToken } from localStorage.
Backend authController.refreshToken (:263-313) verifies the token via verifyRefreshToken, checks it is still present in user.refreshTokens[], then issues a brand-new access and refresh token.
The old refresh token is removed from the array and the new one is pushed — implementing refresh-token rotation. A leaked-but-stale token therefore becomes invalid the moment the legitimate user refreshes.
The new pair is written back to localStorage and the original failed request is retried.
sequenceDiagram
autonumber
participant FE as Frontend axios
participant BE as Backend
participant DB as MongoDB
FE->>BE: GET /api/marketplace/... (Bearer access)
BE-->>FE: 401 TOKEN_INVALID
FE->>BE: POST /api/auth/refresh-token { refreshToken }
BE->>BE: verifyRefreshToken(refreshToken)
BE->>DB: User.findById(decoded.id)
BE->>DB: ensure refresh token is in user.refreshTokens