---
title: Authentication Flow
tags: [flow, auth, jwt, login, security]
related_models: ["[[User]]", "[[TempVerification]]"]
related_apis: ["[[Auth API]]", "POST /api/auth/login", "POST /api/auth/refresh-token", "POST /api/auth/logout"]
---

> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)

> [!caution] Audit note — last reviewed 2026-05-29
> Several discrepancies between frontend code, backend code, and this document were identified and corrected in this revision. See inline ⚠️ markers for known bugs and mismatches.

# Authentication Flow

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

1. **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`.
2. **Client-side guards**: `signInWithPassword()` (`action.ts:32-116`) verifies the browser is online and `localStorage` is writable; otherwise it throws a typed `AuthErrorHandler` error.
3. **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.
4. **Validation middleware** runs `loginValidation` (`backend/src/services/auth/authValidation.ts`) — wires into Express via `authRoutes.ts:22`.
5. **Cloudflare Turnstile CAPTCHA gate** (`captchaGate` middleware, commit `b8edbbf`): Before the rate-limiter runs, `captchaGate` checks the in-memory failure counter for the caller's IP. If that IP has accumulated **3 or more failed login attempts** within 15 minutes, a valid `cf-turnstile-response` token must be present in the request body. Without it the endpoint returns `429 { captchaRequired: true }`. If `TURNSTILE_SECRET_KEY` is not set (local dev), the gate is skipped. On CAPTCHA pass, the middleware calls Cloudflare's `siteverify` endpoint to validate the token before proceeding.
5a. **Rate limiting**: `rateLimitService.checkLoginAttempts(email, 5, 15*60)` is called (`authController.ts:173`). The counter is incremented on **every login attempt** (before password comparison), not only on failures. Once 5 total attempts accumulate within a 15-minute window, the endpoint returns `429 TOO_MANY_ATTEMPTS`. The counter is reset upon a fully successful login (step 9). Counters live in Redis so they survive restarts.
6. **User lookup**: `User.findOne({ email, status: "active" }).select("+password")` — `password` is `select: false` by default in the schema and must be explicitly projected.
7. **Password comparison**: `authService.comparePassword()` invokes `bcrypt.compare()` (cost factor 12 — see `authService.ts:102-105`). Constant-time per bcrypt's design.
8. **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=...`.
9. **Reset attempts**: On success, `rateLimitService.resetLoginAttempts(email)` wipes the Redis counter.
10. **Last-login stamp**: `user.lastLoginAt = new Date()` is saved.
11. **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.
12. **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.
13. **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.
14. **Response**: `200 OK` with `{ user: user.toJSON(), tokens: { accessToken, refreshToken } }`. `toJSON()` strips `password`, `refreshTokens`, and verification codes (see User model `toJSON` transform).
15. **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**.

16. **Axios interceptor** (`frontend/src/lib/axios.ts`) attaches `Authorization: Bearer ${accessToken}` to every subsequent request. On a `401` response, the interceptor automatically triggers the refresh flow described below. A `403` response (e.g., `EMAIL_NOT_VERIFIED`) is **not** retried via refresh — it is surfaced directly to the caller. The interceptor only checks `status === 401` (`axios.ts:105`); 403 responses are not handled by the interceptor and propagate as errors.
17. **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

```mermaid
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
```

## API calls

| Method | Endpoint | Source |
|---|---|---|
| `POST` | `/api/auth/login` | `backend/src/services/auth/authRoutes.ts:22` → `authController.login` |
| `POST` | `/api/auth/telegram` | `authRoutes.ts` → `authController.telegramAuth` |
| `POST` | `/api/auth/refresh-token` | `authRoutes.ts:24-27` → `authController.refreshToken` |
| `POST` | `/api/auth/logout` | `authRoutes.ts:68` → `authController.logout` (protected) |
| `GET` | `/api/auth/profile` | `authRoutes.ts:69` → `authController.getProfile` |
| `DELETE` | `/api/auth/account` | `authRoutes.ts:86-89` → `authController.deleteAccount` (requires `password` in body, runs `deleteAccountValidation`) |

## Telegram first-class auth flow

Telegram is now a peer auth provider alongside email/password, Google, and passkeys.

1. The Telegram Mini App shell reads raw signed launch data from `window.Telegram.WebApp.initData`; browser login can submit a Telegram Login Widget payload.
2. The frontend posts the raw signed payload to `POST /api/auth/telegram`; it never trusts `initDataUnsafe` for authentication.
3. Backend verifies the Telegram signature:
   - Mini App uses `verifyMiniAppInitData()` with the Telegram WebAppData HMAC algorithm.
   - Login Widget uses `verifyTelegramLoginWidget()` with the Telegram Login Widget HMAC algorithm.
4. Backend rejects stale `auth_date`, bot accounts, replayed Mini App `initData`, and blocked `TelegramLink` records.
5. If an active `TelegramLink` exists, backend signs in that Amanat user. If no link exists, backend creates a Telegram-only user with nullable email, `authProvider: "telegram"`, `telegramVerified: true`, then creates the link.
6. Backend issues the standard access token and refresh token pair. The frontend stores them in `localStorage` under `accessToken` and `refreshToken`, then hydrates the current session.
7. If `isNewUser=true`, the Mini App opens a lightweight onboarding overlay and points the user to account settings for optional email, language, currency, and wallet details.

High-risk actions are unchanged: escrow release, refund, dispute-sensitive, and wallet-sensitive operations still use the existing protected backend authorization and step-up gates. Telegram auth only establishes the user session.

## Passkey auth flow

The frontend `registerPasskey` and `authenticateWithPasskey` actions call passkey API endpoints. All passkey API calls are proxied directly to the Express backend via the `next.config.ts` rewrite rule (`/api/:path*` → backend). There are no Next.js route handler files (`route.ts`) for passkey paths — requests travel: browser → Next.js dev server (rewrite) → Express backend.

## Database writes

- **`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. Counter increments on every attempt regardless of outcome.
- **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`, the axios interceptor calls:

1. `POST /api/auth/refresh-token` with `{ refreshToken }` from `localStorage`.
2. 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.
3. 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.
4. The new pair is written back to `localStorage` and the original failed request is retried.

> [!note] 403 responses are not retried
> The interceptor only triggers token refresh for `status === 401` (`axios.ts:105`). A `403` (e.g., `EMAIL_NOT_VERIFIED`) is passed through directly to the caller without attempting a refresh.

> [!warning] Refresh-token sequence diagram is truncated
> The Mermaid diagram below is **incomplete** — it was truncated in the original source at the point where the backend checks that the refresh token exists in `user.refreshTokens`. The remaining steps (rotate tokens, persist, respond, retry original request) are described in prose above but are not yet rendered in the diagram.

```mermaid
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
    Note over BE,DB: (diagram truncated — remaining steps documented in prose above)
```

## Account management

### changePassword (API-only)

`POST /api/auth/change-password` exists on the backend and the `changePassword()` action is defined in `frontend/src/auth/context/jwt/action.ts`. However:

> [!warning] No frontend UI for change-password
> There is **no dashboard page** that renders a change-password form. The feature is **API-only** at this time. Users cannot change their password through the UI; a developer or direct API client must call the endpoint manually.

### deleteAccount

> [!bug] Account deletion frontend calls wrong endpoint
> The frontend `deleteAccount` action calls `DELETE /user/profile`, which does **not exist** on the backend. The real backend endpoint is `DELETE /api/auth/account` (`authRoutes.ts:86-89`), which requires a `password` field in the request body and runs `deleteAccountValidation` middleware. Until the frontend is updated to call the correct endpoint, account deletion will always fail with a 404 or routing error.

## Known issues summary

| Issue | Severity | Details |
|---|---|---|
| `deleteAccount` calls wrong endpoint | Bug | Frontend calls `DELETE /user/profile`; correct backend endpoint is `DELETE /api/auth/account` (requires `password` in body) |
| No change-password UI | Gap | `POST /api/auth/change-password` and `changePassword()` action exist but no dashboard page renders the form |
| Rate limiter counts all attempts | Clarification | Counter increments before password check — 5 total attempts (not 5 failures) triggers lockout |
| Axios interceptor 401-only | Clarification | Interceptor only auto-refreshes on `status === 401` (`axios.ts:105`); 403 errors propagate directly to caller |
| Refresh-token diagram truncated | Doc debt | Mermaid diagram cut off mid-flow; prose description is authoritative |

## Linked flows

- [[Registration Flow]] — prerequisite; user must be verified.
- [[Password Reset Flow]] — alternative credential recovery path.
- [[Notification Flow]] — uses the issued JWT for Socket.IO room subscriptions.
- [[Chat Flow]] — same JWT used for chat room access.

## Source files

- Backend: `backend/src/services/auth/authController.ts`
- Backend: `backend/src/services/auth/authService.ts`
- Backend: `backend/src/services/auth/authValidation.ts`
- Backend: `backend/src/services/auth/authRoutes.ts`
- Frontend: `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx`
- Frontend: `frontend/src/auth/context/jwt/action.ts`
- Frontend: `frontend/src/lib/axios.ts`
