Frontend (Next.js)
Backend (NestJS)
Database (PostgreSQL)
External API
Error / Rejection
Success
Warning / Pending
🛒 Buyer
Feature 01 — Buyer Signup Updated
Multi-stage buyer registration. Email is the primary unique identifier. Phone uniqueness is enforced as a secondary layer after email OTP verification is confirmed. The phone number check uses 3-case logic: Case A (same email, unverified phone — skip to OTP), Case B (different email owns this phone — field-level rejection), Case C (free phone — hash password & store profile, send OTP). All OTPs expire in exactly 60 seconds. Profile name, phone, and Argon2id password hash are persisted before the SMS OTP is dispatched, enabling frictionless resume on retry.
Next.js
NestJS AuthService
PostgreSQL
Cloudflare Turnstile
BullMQ + Resend + Fast2SMS
Redis
flowchart TD
classDef fe fill:#dbeafe,stroke:#3b82f6,color:#1e40af,font-size:12px
classDef be fill:#dcfce7,stroke:#16a34a,color:#166534,font-size:12px
classDef db fill:#fef9c3,stroke:#ca8a04,color:#78350f,font-size:12px
classDef ext fill:#f3e8ff,stroke:#9333ea,color:#4c1d95,font-size:12px
classDef err fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,font-size:12px
classDef ok fill:#d1fae5,stroke:#059669,color:#064e3b,font-size:12px
classDef warn fill:#fff7ed,stroke:#ea580c,color:#7c2d12,font-size:12px
subgraph STAGE1 [Stage 1 — Email Verification]
A([Buyer opens signup page]):::fe
A --> CF{{Cloudflare Turnstile}}:::ext
CF -->|Invalid| CFE["Bot check failed — stop"]:::err
CF -->|Valid| EM[/"Enter email address"/]:::fe
EM --> INIT["POST /api/v1/auth/buyer/signup/initiate
body: email + turnstileToken"]:::be
INIT --> RETIREDCHK[("Check retired_credentials
email permanently retired?")]:::db
RETIREDCHK -->|Retired| RET410["HTTP 410 Gone
Credential permanently retired
Cannot create new account"]:::err
RETIREDCHK -->|Clear| EMCHK[("Query Users WHERE
email = input AND isDeleted = false")]:::db
EMCHK -->|New user| CU["Create User record
isEmailVerified = false
isPhoneVerified = false
role = BUYER · status = PENDING"]:::db
EMCHK -->|Exists unverified| RESEND_E["Upsert Otp record
channel=email · purpose=VERIFICATION_OTP
Reset attempts · rate-limit enforced"]:::be
EMCHK -->|Email verified phone pending| SKIPPHONE["Return 200 COMPLETE_PHONE
Skip to profile + phone form"]:::warn
EMCHK -->|Fully active account| RLOGIN["Return 409 AUTH_EMAIL_EXISTS
Email already registered — log in"]:::err
CU --> EMAILOTP["NotificationsService dispatch email otp
BullMQ notifications queue: send-notification
Generate 6-digit OTP
Hash OTP with SHA-256
Save to Otp · channel=email · purpose=VERIFICATION_OTP · TTL 60 seconds
Rate limit: 5 resends per hour"]:::be
RESEND_E --> EMAILOTP
EMAILOTP --> OTPPAGE[/"Enter 6-digit email OTP
Resend button disabled for 60s"/]:::fe
OTPPAGE --> VFYEMAIL["POST /api/v1/auth/buyer/signup/verify-email
body: email + otp"]:::be
VFYEMAIL --> OTPLOAD[("Load Otp record
identity=email · channel=email · purpose=VERIFICATION_OTP
Compare SHA-256 hash
Check expiresAt · check attempts")]:::db
OTPLOAD -->|Expired after 60s| OTPEXP["OTP expired
Click Resend OTP for a new code"]:::err
OTPLOAD -->|Attempts exceeded 5| OTPMAX["Too many attempts
Request new OTP via Resend"]:::err
OTPLOAD -->|Hash mismatch| OTPWRONG["Incorrect OTP
Remaining attempts shown"]:::err
OTPLOAD -->|Valid match| EMVERIFIED["Set isEmailVerified = true
Delete Otp record"]:::db
end
subgraph STAGE2 [Stage 2 — Profile and Phone Submission]
EMVERIFIED --> PROFILEFORM
SKIPPHONE --> PROFILEFORM
PROFILEFORM[/"Enter: Phone Number
Profile Name · Password · Confirm Password"/]:::fe
PROFILEFORM --> PHONESUBMIT["POST /api/v1/auth/buyer/signup/complete
Zod: phone 10-digit starts 6-9
Zod: password strength
Zod: confirm password match"]:::be
PHONESUBMIT --> PHONE3CASE{"Phone 3-Case Check"}:::be
PHONE3CASE -->|Case A: same email owns phone unverified| CASEA["Return 200 VERIFY_PHONE_EXISTING
Name and password already stored
Show OTP screen only — no re-entry"]:::warn
PHONE3CASE -->|Case B: different email owns phone| CASEB["Return 409 AUTH_PHONE_EXISTS
Phone already registered
Highlight phone field only
All other fields retain values"]:::err
PHONE3CASE -->|Case C: phone is free| CASEC["Hash password with Argon2id
cost=4 · parallelism=2 · memory=65536
Store profileName + phone + passwordHash
Data saved BEFORE OTP is sent
Generate 6-digit phone OTP
Hash with SHA-256 · TTL 60s
NotificationsService dispatch sms otp
BullMQ notifications queue -> Fast2SMS
Return 200 VERIFY_PHONE resendAfter=60"]:::be
end
subgraph STAGE3 [Stage 3 — Phone OTP and Account Activation]
CASEA --> PHONEOTPPAGE
CASEC --> PHONEOTPPAGE
PHONEOTPPAGE[/"Enter 6-digit SMS OTP
Resend OTP button — disabled 60s"/]:::fe
PHONEOTPPAGE --> VFYPHONE["POST /api/v1/auth/buyer/signup/verify-phone
body: email + phone + otp"]:::be
VFYPHONE --> PHOTPLOAD[("Load Otp record
identity=phone · channel=phone · purpose=VERIFICATION_OTP
Hash compare SHA-256
Check 60s expiry · check attempts")]:::db
PHOTPLOAD -->|Expired| PHOTPEXP["OTP expired
Click Resend OTP for a new code"]:::err
PHOTPLOAD -->|Invalid| PHOTPERR["Incorrect OTP
Resend available after 60s cooldown"]:::err
PHOTPLOAD -->|Valid| ACTIVATE["Set isPhoneVerified = true
Set status = ACTIVE
Delete Otp record
Issue RS256 JWT — 15min TTL
Issue Refresh Token — 7d TTL
SHA-256 hash refresh token stored
Set httpOnly + Secure + SameSite=Strict"]:::be
ACTIVATE --> AUDITLOG[("Insert AuditLog
Event: BUYER_SIGNUP
IP + userAgent + timestamp")]:::db
AUDITLOG --> WELCOME["NotificationsService dispatch email welcome
BullMQ notifications queue: send-notification
via Resend API"]:::be
WELCOME --> SUCCESS(["Signup complete
Redirect to home or redirect_url
Sync guest cart and wishlist"]):::ok
end
🔒 Security Properties
Argon2id (cost=4, parallelism=2, memory=65536) · All OTPs hashed SHA-256 before storage — plaintext never persisted · Universal 60-second OTP TTL · Anti-enumeration: same response shape for new and existing emails · Cloudflare Turnstile on form entry · Redis rate-limit: 5 OTP sends/hour/phone · Retired credential check via dedicated table → HTTP 410
⚡ API Reference — Buyer Signup Endpoints
▼
REQUEST — Initiate Signup
{
"method": "POST",
"endpoint": "/api/v1/auth/buyer/signup/initiate",
"headers": { "Content-Type": "application/json" },
"body": {
"email": "buyer@gmail.com",
"turnstileToken": "cf-turnstile-response-token"
}
}
RESPONSE — New Email
{
"status": 200,
"body": {
"action": "VERIFY_EMAIL",
"resendAfter": 60
}
}
// Retired credential:
{
"status": 410,
"body": { "code": "CREDENTIAL_RETIRED" }
}
REQUEST — Complete Profile
{
"method": "POST",
"endpoint": "/api/v1/auth/buyer/signup/complete",
"body": {
"email": "buyer@gmail.com",
"phone": "9876543210",
"profileName": "Rahul Kumar",
"password": "SecurePass@123"
}
}
RESPONSE — Phone 3-Cases
// Case A — same email, phone unverified: { "status": 200, "code": "VERIFY_PHONE_EXISTING" } // Case B — different email owns phone: { "status": 409, "code": "AUTH_PHONE_EXISTS", "message": "Phone number already registered." } // Case C — phone is free: { "status": 200, "action": "VERIFY_PHONE", "resendAfter": 60 }
📋 Full Flow Description
▼
BUYER → Opens /signup → Solves Cloudflare Turnstile → Enters email → Clicks "Continue"
FRONTEND (Zod) → Validate email format IF invalid → show inline error → stop
→ POST /api/v1/auth/buyer/signup/initiate { email, turnstileToken }
BACKEND
→ Verify Turnstile token — IF invalid → 400 AUTH_TURNSTILE_FAILED → stop
→ Check retired_credentials table — IF email found → Return 410 Gone → stop
→ Query User WHERE email = input AND isDeleted = false
CASE A — Email does NOT exist (new user):
→ Create User { email, isEmailVerified: false, isPhoneVerified: false, status: PENDING, role: BUYER }
→ Generate 6-digit OTP → Hash SHA-256 → Save Otp { identity: email, channel: email, purpose: VERIFICATION_OTP, hash, expiresAt: +60s, attempts: 0 }
→ NotificationsService dispatch email otp → BullMQ notifications queue → Resend API sends HTML email
→ Return 200 { action: "VERIFY_EMAIL", resendAfter: 60 }
CASE B — Email exists, isEmailVerified = false:
→ Upsert Otp record (channel=email, purpose=VERIFICATION_OTP), reset attempts → Resend OTP (rate-limit 5/hr)
→ Return 200 { action: "VERIFY_EMAIL", hint: "OTP re-sent" }
CASE C — Email exists, isEmailVerified = true, isPhoneVerified = false:
→ Return 200 { action: "COMPLETE_PHONE" } → skip email step
CASE D — Email exists, fully active:
→ Return 409 AUTH_EMAIL_EXISTS → redirect to login
FRONTEND → /signup/verify-email → 6-digit OTP input
→ "Resend OTP" button disabled for 60s countdown
→ Buyer types OTP → POST /api/v1/auth/buyer/signup/verify-email { email, otp }
BACKEND
→ Load Otp record for email (channel=email, purpose=VERIFICATION_OTP)
IF expiresAt < NOW() → Return 400 { code: "OTP_EXPIRED", message: "Your OTP has expired. Please click Resend OTP to receive a new code." }
IF attempts ≥ 5 → Return 429 AUTH_OTP_LOCKED
→ Hash submitted OTP → constant-time compare with stored hash
IF mismatch → Increment attempts → Return 400 AUTH_OTP_INVALID
IF match → Set isEmailVerified = true → Delete Otp record → Return 200
FRONTEND → /signup/complete-profile → Shows 4 fields:
Phone Number (10-digit Indian mobile, starts 6–9)
Profile Name (min 2, max 50 chars)
Password (min 8 chars, 1 uppercase, 1 number)
Confirm Password (must match)
→ All Zod-validated client-side before submission
→ POST /api/v1/auth/buyer/signup/complete { email, phone, profileName, password }
BACKEND — Phone 3-Case Logic
CASE A — same email owns this phone AND isPhoneVerified = false:
→ profileName + password already stored in prior session
→ Return 200 { code: "VERIFY_PHONE_EXISTING" }
→ Frontend: show 6-digit OTP screen ONLY — do not show profile form again
CASE B — a different email owns this phone (verified OR unverified):
→ Return 409 { code: "AUTH_PHONE_EXISTS", message: "Phone number already registered. Please enter another phone number." }
→ Frontend: highlight phone field only — name and password fields retain their values
CASE C — phone number is free:
→ Hash password Argon2id (cost=4, parallelism=2, memory=65536)
→ Update User { profileName, phone, passwordHash } — DATA STORED BEFORE OTP IS SENT
→ Check Redis OTP rate-limit: max 5 sends/hr/phone
IF exceeded → Return 429 AUTH_OTP_RATE_LIMIT → stop
→ Generate 6-digit phone OTP → Hash SHA-256 → Save to Otp { identity: phone, channel: phone, purpose: VERIFICATION_OTP, TTL: 60s }
→ NotificationsService dispatch sms otp → BullMQ notifications queue → Fast2SMS
→ Return 200 { action: "VERIFY_PHONE", resendAfter: 60 }
FRONTEND → /signup/verify-phone → 6-digit OTP input + 60s resend timer
→ POST /api/v1/auth/buyer/signup/verify-phone { identifier, identifierType, otp }
BACKEND
→ Same OTP verification logic (SHA-256 compare, attempts check, 60s expiry)
IF expired → "Your OTP has expired. Please click Resend OTP to receive a new code."
IF match:
→ Set isPhoneVerified = true → status = ACTIVE → Delete Otp record
→ Create Session → Issue RS256 JWT (15min) + Refresh Token (7d)
→ Store refresh token as SHA-256 hash in Session table
→ Set httpOnly + Secure + SameSite=Strict cookies
→ NotificationsService dispatch email welcome → BullMQ notifications queue
→ Insert AuditLog { event: BUYER_SIGNUP, ip, userAgent, timestamp }
→ Return 201 { user: { id, profileName, email, phone, role: BUYER } }
✓ SIGNUP COMPLETE
→ Frontend stores non-sensitive user fields in Zustand
→ Socket.IO client connects → joins room user:{userId}
→ Sync guest cart (POST /api/v1/buyer/cart/sync with guest items)
→ Sync guest wishlist (POST /api/v1/buyer/wishlist/sync with guest items)
→ Redirect to /home (or stored redirect_url from sessionStorage)
🛒 Buyer
Feature 02 — Buyer Login Full Rebuild
Complete redesign. A single identifier input field accepts either email or phone. Client-side Zod detection routes the input type. The password screen always presents two links: "Forgot Password" and "Login with OTP". Path A (password) triggers mandatory OTP 2FA after correct credentials. Path B (OTP-only) is passwordless. Path C (forgot password) uses the same unified identifier entry with anti-enumeration (always returns 200). Account lockout: 5 failures → 30-minute Redis lock.
Next.js
NestJS AuthService
PostgreSQL
Redis (lockout)
Cloudflare Turnstile
Resend + Fast2SMS
flowchart TD
classDef fe fill:#dbeafe,stroke:#3b82f6,color:#1e40af,font-size:12px
classDef be fill:#dcfce7,stroke:#16a34a,color:#166534,font-size:12px
classDef db fill:#fef9c3,stroke:#ca8a04,color:#78350f,font-size:12px
classDef ext fill:#f3e8ff,stroke:#9333ea,color:#4c1d95,font-size:12px
classDef err fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,font-size:12px
classDef ok fill:#d1fae5,stroke:#059669,color:#064e3b,font-size:12px
classDef warn fill:#fff7ed,stroke:#ea580c,color:#7c2d12,font-size:12px
A([Buyer Opens /login Page]):::fe
A --> CF{{Cloudflare Turnstile}}:::ext
CF -->|Fail| CFE[Bot check failed — stop]:::err
CF -->|Pass| STEP1[/Step 1: Single input field
Placeholder: Enter email or phone number/]:::fe
STEP1 --> DETECT{Client-side Zod
Detection}:::fe
DETECT -->|Contains @ sign| EMAIL_VALID{Valid email format?}:::fe
DETECT -->|10-digit starts 6-9| PHONE_VALID[Store as phone
type: phone]:::fe
DETECT -->|Neither matches| INPUT_ERR[Inline error:
Enter a valid email address
or 10-digit Indian mobile number]:::err
EMAIL_VALID -->|Invalid format| INPUT_ERR
EMAIL_VALID -->|Valid| EMAIL_STORE[Store as email
type: email]:::fe
EMAIL_STORE --> PWDSCREEN
PHONE_VALID --> PWDSCREEN
PWDSCREEN[/Step 2: Password Screen
Label shows entered identifier
Masked password field with show/hide toggle
Link 1: Forgot Password
Link 2: Login with OTP/]:::fe
PWDSCREEN --> CHOICE{User action?}:::fe
CHOICE -->|Enters password and clicks Sign In| PATHA[Path A — Password + Mandatory 2FA]:::fe
CHOICE -->|Clicks Login with OTP| PATHB[Path B — OTP Only passwordless]:::fe
CHOICE -->|Clicks Forgot Password| PATHC[Path C — Password Reset]:::fe
subgraph PA [Path A — Password + Mandatory OTP 2FA]
PATHA --> PA1[POST /api/v1/auth/buyer/login
body: identifier + identifierType
+ password + turnstileToken]:::be
PA1 --> PA_LOCK[(Check Redis AUTH:LOCKOUT:identifier)]:::db
PA_LOCK -->|Locked 30min| PA_LOCKED[Error: Too many attempts
Account locked — try in 30 minutes
Offer Forgot Password]:::err
PA_LOCK -->|Clear| PA_FIND[(Find User by identifier
Check isDeleted = false
Check status not SUSPENDED)]:::db
PA_FIND -->|Not found| PA_FAIL[Return 401 generic
Increment fail counter
Never reveal which field wrong]:::err
PA_FIND -->|Found| PA_PWD[Argon2id password compare]:::be
PA_PWD -->|Wrong| PA_FAIL2[(Increment Redis AUTH:FAIL
At 5: set LOCKOUT TTL 1800s)]:::db
PA_FAIL2 --> PA_FAIL
PA_PWD -->|Correct| PA_CHECKS[(BuyerAuthController post-password checks:
phone login requires verified phone
email must be verified
profile/password/phone must be complete
Reset AUTH:FAIL counter)]:::db
PA_CHECKS -->|phone identifier unverified| PA_UNVER[400 AUTH_PHONE_UNVERIFIED
Prompt login with email to verify phone]:::err
PA_CHECKS -->|email not verified| PA_EMAIL_VERIFY[Return signup email OTP action
NotificationsService dispatch email otp]:::warn
PA_CHECKS -->|profile incomplete| PA_COMPLETE[Return COMPLETE_PROFILE
Frontend resumes signup completion]:::warn
PA_CHECKS -->|OK| PA_OTP[Generate 6-digit OTP
Hash SHA-256 — TTL 60s
Send to identifier channel]:::be
PA_OTP -->|email identifier| PA_EMAIL[NotificationsService dispatch email otp
BullMQ notifications queue: send-notification
Return 200 VERIFY_OTP
medium: email
maskedEmail: bu***@gmail.com]:::be
PA_OTP -->|phone identifier| PA_SMS[NotificationsService dispatch sms otp
BullMQ notifications queue: send-notification
Return 200 VERIFY_OTP
medium: sms
maskedPhone: ******3210]:::be
PA_EMAIL --> PA_OTP_IN
PA_SMS --> PA_OTP_IN
PA_OTP_IN[/Enter 6-digit OTP
60s resend timer
Resend OTP after countdown/]:::fe
PA_OTP_IN --> PA_VERIFY[POST /api/v1/auth/buyer/login/verify-otp]:::be
PA_VERIFY --> PA_VCHECK[(Verify OTP hash
Check 60s expiry
Check attempt count)]:::db
PA_VCHECK -->|Expired| PA_EXP[OTP expired
Click Resend OTP for a new code]:::err
PA_VCHECK -->|Invalid| PA_INV[OTP invalid — offer resend]:::err
PA_VCHECK -->|Valid| CREATE_SESSION[Create Session record
Issue RS256 JWT 15min
Issue Refresh Token 7d
Set httpOnly Secure SameSite=Strict cookies]:::be
end
subgraph PB [Path B — Login with OTP Passwordless]
PATHB --> PB1[POST /api/v1/auth/buyer/login/request-otp
body: identifier + identifierType + turnstileToken]:::be
PB1 --> PB_FIND[(Find User by identifier
Check active status)]:::db
PB_FIND -->|Not found| PB_SIGNUP[Return 200 REDIRECT_SIGNUP
Unknown identifier — prompt signup]:::warn
PB_FIND -->|Found| PB_RL[(Redis rate-limit check
5 OTP sends per hour per identifier)]:::db
PB_RL -->|Exceeded| PB_LIMIT[Return 429 OTP_RATE_LIMIT]:::err
PB_RL -->|OK| PB_OTP[Generate 6-digit OTP
Hash SHA-256 — TTL 60s
Send to identifier channel]:::be
PB_OTP --> PB_OTP_IN[/Enter 6-digit OTP
60s resend timer/]:::fe
PB_OTP_IN --> PB_VFY[POST /api/v1/auth/buyer/login/verify-otp]:::be
PB_VFY --> PB_CHK[(Verify OTP hash + expiry)]:::db
PB_CHK -->|Valid| CREATE_SESSION
PB_CHK -->|Invalid or expired| PB_ERR[OTP error — resend offered]:::err
end
subgraph PC [Path C — Forgot Password]
PATHC --> PC1[/Single input field shown
Enter your email or phone number
Same detection logic as Step 1/]:::fe
PC1 --> PC2[POST /api/v1/auth/buyer/forgot-password
body: identifier + identifierType]:::be
PC2 --> PC3[(Look up user account)]:::db
PC3 -->|Always returns 200 — anti-enumeration| PC4[IF user found:
Generate OTP → Hash SHA-256
TTL 60s — save Otp purpose=PASSWORD_RESET_OTP
NotificationsService dispatch identifier channel otp
BullMQ notifications queue: send-notification]:::be
PC4 --> PC5[Return 200 regardless of account existence
Frontend: Check your inbox or messages
Do not reveal whether account exists]:::fe
PC5 --> PC6[/Enter OTP + New Password/]:::fe
PC6 --> PC7[POST /api/v1/auth/buyer/reset-password
body: identifier + identifierType + otp + password]:::be
PC7 --> PC8[(Verify OTP — 60s TTL
Hash new password Argon2id
Invalidate ALL active sessions
Delete Otp reset record
Clear lockout keys)]:::db
PC8 -->|Success| PC9([Password reset — redirect to /login]):::ok
PC8 -->|Expired OTP| PC_EXP[Error: OTP expired
Request a new reset code]:::err
end
CREATE_SESSION --> LOGIN_OK([Login Successful
Sync guest cart + wishlist via current bulk APIs
Redirect to /home or redirect_url]):::ok
🔒 Security Properties
Generic error messages on all failure paths — never reveal which field is wrong · Redis lockout after 5 failures (30-min TTL) · 60-second OTP TTL universal · Path B rate-limited to 5 OTP sends/hour/identifier · Forgot password always returns 200 regardless of account existence (anti-enumeration) · RS256 asymmetric JWT (not HS256) · httpOnly + Secure + SameSite=Strict cookies — never localStorage
⚡ API Reference — Buyer Login Endpoints
▼
REQUEST — Password Login
{
"method": "POST",
"endpoint": "/api/v1/auth/buyer/login",
"body": {
"identifier": "buyer@gmail.com",
"identifierType": "email",
"password": "SecurePass@123",
"turnstileToken": "cf-token"
}
}
RESPONSE — OTP Required (2FA)
{
"status": 200,
"body": {
"action": "VERIFY_OTP",
"medium": "email",
"maskedEmail": "bu***@gmail.com"
}
}
REQUEST — OTP-Only Login
{
"method": "POST",
"endpoint": "/api/v1/auth/buyer/login/request-otp",
"body": {
"identifier": "9876543210",
"identifierType": "phone",
"turnstileToken": "cf-token"
}
}
RESPONSE — Locked / Success
// Locked account: { "status": 429, "code": "AUTH_ACCOUNT_LOCKED", "retryAfter": 1800 } // Successful login (after OTP): { "status": 200, "user": { "id": "uuid", "profileName": "Rahul", "email": "buyer@gmail.com", "role": "BUYER" } }
📋 Full Flow Description
▼
BUYER → Opens /login → Solves Cloudflare Turnstile
STEP 1 — UNIFIED IDENTIFIER INPUT
→ Single input field: placeholder "Enter email or phone number"
→ No tabs, no method selection — one unified entry point
FRONTEND (Zod client-side detection)
IF input contains "@" → validate email format → store { identifier, type: "email" }
IF input is 10-digit starting 6–9 → store { identifier, type: "phone" }
IF neither → inline error: "Enter a valid email address or 10-digit Indian mobile number."
STEP 2 — PASSWORD SCREEN
→ Label shows the entered identifier (e.g., buyer@gmail.com or 9876543210)
→ Password field (masked, with show/hide toggle)
→ Two links always visible: "Forgot Password" | "Login with OTP"
════════════════════════════════════ PATH A — PASSWORD + MANDATORY OTP 2FA
→ Buyer enters password → Clicks "Sign In"
→ POST /api/v1/auth/buyer/login { identifier, identifierType, password, turnstileToken }
BACKEND
→ Verify Turnstile
→ Check Redis AUTH:LOCKOUT:{identifier} — IF locked → 429 "Account locked. Try in 30 minutes."
→ Find User by identifier (email or phone)
IF not found → increment AUTH:FAIL:{identifier} → Return 401 "Incorrect email or password" (generic)
→ Argon2id compare
IF wrong → increment fail counter → at 5: set LOCKOUT TTL=1800s → Return 401 (same generic message)
IF correct → BuyerAuthController checks verification state before login OTP:
- phone identifier with unverified phone → 400 AUTH_PHONE_UNVERIFIED and stay on login
- email not verified → send signup email OTP and return verification action
- incomplete profile/phone/password → return COMPLETE_PROFILE
→ Reset AUTH:FAIL counter
→ Generate 6-digit OTP → Hash SHA-256 → Otp record purpose=LOGIN_OTP (TTL: 60s)
IF email identifier → NotificationsService dispatch email otp → BullMQ notifications queue → Resend API
→ Return 200 { action: "VERIFY_OTP", medium: "email", maskedEmail: "bu***@gmail.com" }
IF phone identifier → NotificationsService dispatch sms otp → BullMQ notifications queue → Fast2SMS
→ Return 200 { action: "VERIFY_OTP", medium: "sms", maskedPhone: "******3210" }
FRONTEND → Show OTP input + 60s resend timer
→ POST /api/v1/auth/buyer/login/verify-otp { identifier, identifierType, otp }
→ BACKEND: Verify OTP → Create Session → Issue RS256 tokens → Set cookies
→ ✓ LOGIN COMPLETE
════════════════════════════════════ PATH B — LOGIN WITH OTP (Passwordless)
→ Buyer clicks "Login with OTP" on password screen
→ OTP sent to the initially entered identifier (email or phone — already known)
→ POST /api/v1/auth/buyer/login/request-otp { identifier, identifierType, turnstileToken }
BACKEND
→ Find User by identifier
IF not found → Return 200 { action: "REDIRECT_SIGNUP" } (anti-enumeration — do not say "not found")
IF found → Check Redis rate-limit: max 5 OTP sends per hour per identifier
IF exceeded → Return 429 AUTH_OTP_RATE_LIMIT
→ Generate OTP (TTL 60s) → send to identifier channel
→ OTP verified → Session created → ✓ LOGIN COMPLETE
════════════════════════════════════ PATH C — FORGOT PASSWORD
→ Buyer clicks "Forgot Password" on password screen
→ Show single input field: "Enter your email or phone number"
→ Same Zod identifier detection logic as Step 1
→ POST /api/v1/auth/buyer/forgot-password { identifier, identifierType }
BACKEND
→ Look up user account (silently)
→ ALWAYS return 200 regardless of whether account exists (anti-enumeration protection)
IF user found: → Generate OTP → Hash SHA-256 → Otp record purpose=PASSWORD_RESET_OTP (TTL: 60s)
→ NotificationsService dispatch identifier channel otp through BullMQ notifications queue
→ Frontend: "Check your inbox or messages." (No account existence hint)
→ Buyer enters OTP + new password
→ POST /api/v1/auth/buyer/reset-password { identifier, identifierType, otp, password }
BACKEND: Verify OTP → Hash new password (Argon2id) → Update User
→ Invalidate ALL active Sessions for this user → Delete reset OTP
→ Return 200 → Frontend: "Password reset. Please log in." → Redirect /login
🏪 Seller
Feature 12 — Seller Signup Updated
Current seller onboarding follows the implemented controller routes: signup initiate → email OTP → phone OTP → authenticated PAN verification → Cashfree bank verification → trademark submission → admin review. Bank verification supports direct bank-account validation through Cashfree BAV or UPI reverse penny drop. If Cashfree returns a verified result,
SellerBankAccountDetails.isVerified becomes true and the seller can submit trademark. If the provider is pending, the seller refreshes status and admin can refresh, approve only when Cashfree is valid, or reject with a reason.
Next.js
NestJS
PostgreSQL
Cloudflare Turnstile
BullMQ + Resend + Fast2SMS
Admin Panel
flowchart TD
classDef fe fill:#dbeafe,stroke:#3b82f6,color:#1e40af,font-size:12px
classDef be fill:#dcfce7,stroke:#16a34a,color:#166534,font-size:12px
classDef db fill:#fef9c3,stroke:#ca8a04,color:#78350f,font-size:12px
classDef ext fill:#f3e8ff,stroke:#9333ea,color:#4c1d95,font-size:12px
classDef err fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,font-size:12px
classDef ok fill:#d1fae5,stroke:#059669,color:#064e3b,font-size:12px
classDef warn fill:#fff7ed,stroke:#ea580c,color:#7c2d12,font-size:12px
A([Seller Opens /seller/signup]):::fe
A --> CF{{Cloudflare Turnstile}}:::ext
CF -->|Fail| CFE[Bot check failed]:::err
subgraph S1 [Stage 1 — Profile Details]
CF -->|Pass| FORM[/Enter: Profile Name
Phone Number
Email Address
Password + Confirm Password
Shop Address/]:::fe
FORM --> SUBMIT[POST /api/v1/auth/seller/signup/initiate
Zod validation all fields
turnstileToken included]:::be
SUBMIT --> RETIREDCHK[(Check retired_credentials table
for email and phone)]:::db
RETIREDCHK -->|Retired| RET410[HTTP 410 Gone
Credential permanently retired]:::err
RETIREDCHK -->|Clear| DUPCHK[(Check email uniqueness
Check phone uniqueness)]:::db
DUPCHK -->|Email or phone taken| DUPCHK_ERR[Return 409 — field already registered]:::err
DUPCHK -->|Both unique| CREATE_USER[Create User: role = SELLER
userProfileStatus = PENDING
Hash password Argon2id now
Create SellerProfile record
Create SellerBankAccount stub]:::db
CREATE_USER --> EMAIL_OTP_Q[NotificationsService dispatch email otp
BullMQ notifications queue: send-notification
Generate OTP — Hash SHA-256 — TTL 60s]:::be
EMAIL_OTP_Q --> PHONE_OTP_Q[NotificationsService dispatch sms otp
BullMQ notifications queue: send-notification
Generate phone OTP — Hash SHA-256 — TTL 60s]:::be
end
subgraph S2 [Stage 2 — Email OTP Verification]
PHONE_OTP_Q --> EV_PAGE[/Enter Email OTP
6-digit code — 60s countdown/]:::fe
EV_PAGE --> VERIFY_E[POST /api/v1/auth/seller/signup/verify-email]:::be
VERIFY_E --> EV_CHK[(Verify OTP hash
Check 60s expiry
Check attempts)]:::db
EV_CHK -->|Invalid or expired| EV_ERR[OTP error — offer resend after 60s]:::err
EV_CHK -->|Valid| EV_OK[Set isEmailVerified = true
accountSetupPhase = EMAIL_VERIFIED
Send phone signup OTP]:::db
end
subgraph S3 [Stage 3 — Phone OTP Verification]
EV_OK --> PV_PAGE[/Enter Phone OTP
6-digit SMS code — 60s countdown/]:::fe
PV_PAGE --> VERIFY_P[POST /api/v1/auth/seller/signup/verify-phone]:::be
VERIFY_P --> PV_CHK[(Verify phone OTP hash
Check 60s expiry)]:::db
PV_CHK -->|Invalid or expired| PV_ERR[OTP error — offer resend]:::err
PV_CHK -->|Valid| PV_OK[Set isPhoneVerified = true
accountSetupPhase = PHONE_VERIFIED
Issue onboarding access and refresh cookies]:::db
end
subgraph S4 [Stage 4 — PAN + Cashfree Bank Verification]
PV_OK --> PAN_COLLECT[/Enter PAN Card Number
Requires seller JWT issued after phone OTP/]:::fe
PAN_COLLECT --> KYC_INLINE[POST /api/v1/auth/seller/signup/verify-pan
SellerPanVerificationService verifies PAN
Save kycRegisterName
accountSetupPhase = KYC_VERIFIED]:::be
KYC_INLINE -->|KYC failed — invalid PAN| KYC_INLINE_FAIL[Return error: Invalid PAN
Seller must retry with correct PAN]:::err
KYC_INLINE -->|KYC passed| METHOD{Choose verification method}:::fe
METHOD -->|BANK or BANK_DETAILS| BANK_FORM[/Account holder name
Account number
IFSC code/]:::fe
METHOD -->|UPI| UPI_START[/Start UPI verification
Cashfree reverse penny drop link/QR/]:::fe
BANK_FORM --> BANK_SUBMIT[POST /api/v1/auth/seller/signup/bank-details
primaryPaymentMethod = BANK_DETAILS or BANK
Encrypt account number
Call Cashfree verifyBankAccountSync]:::be
UPI_START --> UPI_SUBMIT[POST /api/v1/auth/seller/signup/bank-details
primaryPaymentMethod = UPI
Create Cashfree RPD request
Return QR and UPI deep links]:::be
BANK_SUBMIT --> BANK_RESULT{Cashfree BAV status}:::ext
UPI_SUBMIT --> UPI_WAIT[Seller pays Rs.1 from Cashfree link or QR
Then frontend calls status endpoint]:::fe
UPI_WAIT --> UPI_STATUS[GET /api/v1/auth/seller/signup/bank-verification/:verificationId/status
or /status/current]:::be
BANK_RESULT -->|VALID / ACCOUNT_IS_VALID| BANK_VERIFIED[(SellerBankAccountDetails.isVerified = true
bankVerificationStatus = VERIFIED
accountSetupPhase = BANK_VERIFIED)]:::db
BANK_RESULT -->|Pending / validation in progress| BANK_PENDING[bankVerificationStatus = VALIDATION_INITIATED
Frontend or admin refreshes current status]:::warn
BANK_RESULT -->|Invalid / duplicate / daily limit| BANK_FAIL[VALIDATION_FAILED or 429/409
Seller retries or contacts admin]:::err
UPI_STATUS -->|SUCCESS| BANK_VERIFIED
UPI_STATUS -->|CREATED / PENDING| UPI_PENDING[bankVerificationStatus = REVERSE_PENNY_DROP_PENDING
Refresh status after payment]:::warn
UPI_STATUS -->|FAILED / EXPIRED| BANK_FAIL
end
subgraph S5 [Stage 5 — Brand / Trademark Submission]
BANK_VERIFIED --> TM_FORM[/Enter Trademark:
Brand Name — required — uniqueness check
Logo/description are seller brand UI concerns
Auth route currently accepts trademark name/]:::fe
TM_FORM --> TM_SUBMIT[POST /api/v1/auth/seller/signup/trademark
Check retired brand names and TradMark uniqueness]:::be
TM_SUBMIT --> TM_UNIQ[(Check TradMark table
name case-insensitive)]:::db
TM_UNIQ -->|Name taken| TM_ERR[Return 409
Trademark name already registered
Try another name]:::err
TM_UNIQ -->|Unique| TM_CREATE[Create TradMark
tradeMarkStatus = PENDING_APPROVAL
Update User accountSetupPhase = TRADEMARK_SUBMITTED]:::db
TM_CREATE --> NOTIFY_ADMIN[NotificationsService dispatches
seller-trademark-submitted email
admin-new-trademark email]:::be
end
subgraph SIGNUP_COMPLETE [Signup Complete — Always]
NOTIFY_ADMIN --> SESSION_CREATE[Create Session record
Issue RS256 JWT — 15min
Issue Refresh Token — 7d
Set httpOnly cookies
Log AuditLog: SELLER_SIGNUP]:::be
SESSION_CREATE --> TRADEMARK_STATUS{seller.trademarkStatus?}:::fe
TRADEMARK_STATUS -->|TRADEMARK_PENDING| WAIT_PAGE[Redirect to /seller/verification-status
Shows: Progress tracker
Profile DONE Bank status Trademark PENDING
Trademark under review up to 48 hours
Cannot access dashboard yet]:::warn
TRADEMARK_STATUS -->|ACTIVE — admin approved| DASH([Redirect to /seller/dashboard
Can list products and receive orders
Payouts require verified bank status]):::ok
end
subgraph ADMIN_APPROVAL [Admin Approval Flow — Parallel]
ADMIN1[Admin logs into /admin/trademarks
Views pending submissions
Checks: uniqueness + validity]:::ext
ADMIN1 -->|APPROVE| APPROVE[PATCH /api/v1/admin/trademarks/:id/approve
Brand.status = ACTIVE
Seller.status = ACTIVE]:::be
APPROVE --> BANK_READY{Admin service refreshes Cashfree bank status}:::be
BANK_READY -->|Bank verified| APPROVE_ACTIVE[User status ACTIVE
accountSetupPhase = COMPLETED]:::ok
BANK_READY -->|Bank not verified| APPROVE_PENDING[Trademark active but user remains PENDING
accountSetupPhase = TRADEMARK_SUBMITTED]:::warn
APPROVE_ACTIVE --> APPROVE_NOTIFY[NotificationsService dispatches trademark-approved email]:::be
APPROVE_PENDING --> APPROVE_NOTIFY
ADMIN1 -->|REJECT| REJECT[PATCH /api/v1/admin/trademarks/:id/reject
body: rejectionReason
Brand.status = REJECTED
Seller.status = TRADEMARK_REJECTED]:::be
REJECT --> REJECT_NOTIFY[Queue: send-trademark-rejected-email
Seller can resubmit new trademark name]:::be
end
subgraph BANK_NOTE [Admin Bank Verification Controls]
BANK_PENDING --> ADMIN_BANK[GET /api/v1/admin/bank-verifications
GET /api/v1/admin/bank-verifications/:sellerId/status
PATCH approve only if refreshed Cashfree result is valid
PATCH reject requires reason]:::ext
UPI_PENDING --> ADMIN_BANK
end
Current Code: PAN First, Then Cashfree Bank Verification
Seller phone OTP issues onboarding cookies. PAN verification then runs through POST /api/v1/auth/seller/signup/verify-pan. Bank verification runs through POST /api/v1/auth/seller/signup/bank-details and supports BANK_DETAILS/BANK for Cashfree bank-account validation or UPI for Cashfree reverse penny drop. Trademark submission is POST /api/v1/auth/seller/signup/trademark. Admin can refresh bank status and can only approve bank verification when Cashfree returns a verified result.
⚡ API Reference — Seller Signup Endpoints
▼
REQUEST — Initiate Seller Signup
{
"method": "POST",
"endpoint": "/api/v1/auth/seller/signup/initiate",
"body": {
"profileName": "Rahul Manoj Kumar",
"phone": "9876543210",
"email": "seller@shop.com",
"password": "SecurePass@123",
"shopAddress": "123 Main St, Mumbai, MH 400001",
"turnstileToken": "cf-token"
}
}
RESPONSE — Signup Initiated
{
"status": 201,
"body": {
"action": "VERIFY_EMAIL_AND_PHONE",
"resendAfter": 60
}
}
REQUEST — Submit Trademark
{
"method": "POST",
"endpoint": "/api/v1/auth/seller/signup/trademark",
"body": {
"name": "MyBrandName"
}
}
RESPONSE — Trademark Submitted
{
"status": 201,
"body": {
"message": "Trademark submitted for review.",
"expectedBy": "2026-04-26T10:00:00Z",
"seller": {
"status": "TRADEMARK_PENDING"
}
}
}
📋 Full Flow Description
▼
STAGE 1 — PROFILE DETAILS
SELLER → Opens /seller/signup → Fills all required fields:
Profile Name (Display Name for sellers)
Phone Number (10-digit Indian mobile, unique)
Email Address (unique)
Password + Confirm Password
Shop Address (full: city, state, PIN)
→ Solves Cloudflare Turnstile → Clicks "Continue"
FRONTEND (Zod) → Validate all fields inline
IF invalid → inline errors → stop
→ POST /api/v1/auth/seller/signup/initiate { profileName, phone, email, shopAddress, password, turnstileToken }
BACKEND
→ Verify Turnstile
→ Check retired_credentials for email and phone
IF either retired → Return 410 Gone
→ Check email uniqueness → Check phone uniqueness
IF email or phone taken (verified) → Return 409
→ Hash password Argon2id immediately
→ Create User { role: SELLER, userProfileStatus: PENDING }
→ Create SellerProfile { userId, shopAddress }
→ Create SellerBankAccount stub { isVerified: false }
→ NotificationsService dispatch email otp through BullMQ notifications queue (TTL 60s)
→ NotificationsService dispatch sms otp through BullMQ notifications queue (TTL 60s)
→ Return 201 { action: "VERIFY_EMAIL_AND_PHONE" }
STAGE 2 — EMAIL OTP VERIFICATION
→ /seller/signup/verify-email → 6-digit OTP input (60s countdown)
→ POST /api/v1/auth/seller/signup/verify-email { email, otp }
BACKEND: hash compare → isEmailVerified = true → accountSetupPhase = EMAIL_VERIFIED → send phone signup OTP
STAGE 3 — PHONE OTP VERIFICATION
→ /seller/signup/verify-phone → 6-digit OTP via SMS (60s countdown)
→ POST /api/v1/auth/seller/signup/verify-phone { phone, otp }
BACKEND: hash compare → isPhoneVerified = true → accountSetupPhase = PHONE_VERIFIED → issue onboarding cookies → Return { action: "VERIFY_PAN" }
STAGE 4 — PAN + CASHFREE BANK VERIFICATION
→ /seller/signup/verify-pan → Seller submits PAN Card number
→ POST /api/v1/auth/seller/signup/verify-pan { pan }
BACKEND: SellerPanVerificationService verifies PAN, saves kycRegisterName, moves the flow to SUBMIT_BANK_DETAILS
→ /seller/signup/bank-details → Seller fills:
BANK_DETAILS/BANK method: accountHolderName, accountNumber, ifscCode
UPI method: creates a Cashfree reverse-penny-drop payment view and UPI links
→ POST /api/v1/auth/seller/signup/bank-details { primaryPaymentMethod, accountHolderName?, accountNumber?, ifscCode? }
BACKEND
→ BANK_DETAILS/BANK: encrypt account number with AES-256-CBC, fingerprint the account, enforce daily attempt limit, call Cashfree bank-account verification.
→ UPI: create Cashfree reverse penny drop, store verificationId/refId and deep links, wait for seller payment/status refresh.
→ GET /api/v1/auth/seller/signup/bank-verification/:verificationId/status or /status/current refreshes the saved Cashfree request
IF Cashfree returns verified/success → SellerBankAccountDetails.isVerified = true, bankVerificationStatus = VERIFIED, accountSetupPhase = BANK_VERIFIED → proceed to Stage 5
IF Cashfree returns pending → seller or admin refreshes later; admin can approve only after refresh confirms valid
IF failed/expired/duplicate/daily-limit → seller retries or contacts admin depending on the error
STAGE 5 — BRAND / TRADEMARK SUBMISSION
→ /seller/signup/trademark → Seller fills:
Brand / Trademark Name (required, checked for global uniqueness)
→ POST /api/v1/auth/seller/signup/trademark { name }
BACKEND
→ Check retired brand names and trademark name uniqueness — case-insensitive — across ALL sellers
IF name taken → Return 409 { message: "This brand name is already registered. Try another." }
→ Create TradMark { name, sellerId, tradeMarkStatus: PENDING_APPROVAL }
→ Update User accountSetupPhase = TRADEMARK_SUBMITTED
→ NotificationsService dispatches seller-trademark-submitted email
→ NotificationsService dispatches admin-new-trademark email
"Your trademark has been submitted for review. You will hear from us within 48 hours."
→ Return 201 { expectedBy: now+48h }
STAGE 6 — SESSION CREATION (ALWAYS)
→ Create Session → Issue RS256 JWT (15min) + Refresh Token (7d) → Set httpOnly cookies
→ Insert AuditLog { event: SELLER_SIGNUP }
FRONTEND ROUTING:
IF status = TRADEMARK_PENDING → /seller/verification-status
Shows: [Profile ✓] [Bank ✓] [Trademark ⏳]
"Your trademark is under review. We will email you the result."
IF status = ACTIVE (after admin approves) → /seller/dashboard
PAYOUT BEHAVIOUR:
→ Seller payout requests require SellerProfile.bankVerificationStatus = VERIFIED and a verified SellerBankAccountDetails row
→ Cashfree beneficiary is created/reused at payout request time
→ Payout status can be refreshed through /api/v1/seller/payouts/refresh
ADMIN APPROVAL FLOW:
→ Admin → /admin/trademarks → Views pending queue sorted by submittedAt
IF APPROVED:
→ PATCH /api/v1/admin/trademarks/:brandId/approve
→ TradMark.status = ACTIVE
→ Admin service refreshes seller bank status through Cashfree
→ If bank is verified: User.userProfileStatus = ACTIVE and accountSetupPhase = COMPLETED
→ If bank is not verified: trademark is active but user remains PENDING until bank verification is approved
→ NotificationsService dispatches trademark-approved email
IF REJECTED:
→ PATCH /api/v1/admin/trademarks/:brandId/reject { rejectionReason }
→ TradMark.status = REJECTED → User accountSetupPhase = BANK_VERIFIED
→ NotificationsService dispatches trademark-rejected email
→ Seller can resubmit a new trademark name (Stage 5 again)
Bank
Current Seller Bank Verification Pipeline Updated
This section now documents the actual code: PAN verification is handled by
SellerPanVerificationService, then SellerBankVerificationService starts a Cashfree verification request. Sellers can use direct bank-account verification (BANK_DETAILS/BANK) or Cashfree UPI reverse penny drop (UPI). The service stores Cashfree ids, provider status, encrypted account data, attempt history, and moves the user to BANK_VERIFIED only when Cashfree returns a verified/success result.
PAN Verification
NestJS
PostgreSQL
Cashfree Verification
Cashfree Payouts
Admin Panel
flowchart TD
classDef fe fill:#dbeafe,stroke:#3b82f6,color:#1e40af,font-size:12px
classDef be fill:#dcfce7,stroke:#16a34a,color:#166534,font-size:12px
classDef db fill:#fef9c3,stroke:#ca8a04,color:#78350f,font-size:12px
classDef ext fill:#f3e8ff,stroke:#9333ea,color:#4c1d95,font-size:12px
classDef err fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,font-size:12px
classDef ok fill:#d1fae5,stroke:#059669,color:#064e3b,font-size:12px
classDef warn fill:#fff7ed,stroke:#ea580c,color:#7c2d12,font-size:12px
classDef diamond fill:#fef3c7,stroke:#d97706,color:#78350f,font-size:12px
START([Seller has verified email and phone
Phone OTP issued onboarding cookies]):::fe
subgraph PAN [PAN Verification]
START --> PAN_FORM[/Seller enters PAN/]:::fe
PAN_FORM --> PAN_API[POST /api/v1/auth/seller/signup/verify-pan
SellerPanVerificationService]:::be
PAN_API -->|verified| PAN_SAVE[(sellerProfile.kycRegisterName saved
accountSetupPhase = KYC_VERIFIED)]:::db
PAN_API -->|failed| PAN_FAIL[Return PAN validation error]:::err
end
subgraph BANK [Cashfree Bank Verification]
PAN_SAVE --> METHOD{Seller chooses method}:::fe
METHOD -->|BANK or BANK_DETAILS| BAV_REQ[POST /api/v1/auth/seller/signup/bank-details
accountHolderName accountNumber ifscCode
Encrypt account number
Hash account fingerprint
Daily limit: 3 attempts per method]:::be
BAV_REQ --> BAV_PROVIDER[Cashfree verifyBankAccountSync
Saves reference_id user_id
providerStatus providerStatusCode]:::ext
BAV_PROVIDER -->|VALID or ACCOUNT_IS_VALID| BAV_OK[(isVerified = true
bankRegisterName saved
bankVerificationStatus = VERIFIED
accountSetupPhase = BANK_VERIFIED)]:::ok
BAV_PROVIDER -->|Pending / validation in progress| BAV_PENDING[(bankVerificationStatus = VALIDATION_INITIATED
seller/admin can refresh status)]:::warn
BAV_PROVIDER -->|Invalid / provider failed| BAV_FAIL[(bankVerificationStatus = VALIDATION_FAILED
failureReason saved)]:::err
METHOD -->|UPI| RPD_REQ[POST /api/v1/auth/seller/signup/bank-details
primaryPaymentMethod = UPI
Cashfree createReversePennyDrop]:::be
RPD_REQ --> RPD_VIEW[/Return verificationId refId
UPI link QR gpay phonepe paytm bhim
valid for 600 seconds/]:::fe
RPD_VIEW --> RPD_STATUS[GET /api/v1/auth/seller/signup/bank-verification/:verificationId/status
or /status/current]:::be
RPD_STATUS -->|SUCCESS| RPD_OK[(isVerified = true
upiRegisterName saved
bankVerificationStatus = VERIFIED
accountSetupPhase = BANK_VERIFIED)]:::ok
RPD_STATUS -->|CREATED or PENDING| RPD_PENDING[(bankVerificationStatus = REVERSE_PENNY_DROP_PENDING
Refresh later)]:::warn
RPD_STATUS -->|FAILED or EXPIRED| BAV_FAIL
end
subgraph ADMIN [Admin Verification Controls]
BAV_PENDING --> ADMIN_REFRESH[GET /api/v1/admin/bank-verifications/:sellerId/status
Refresh Cashfree result]:::ext
RPD_PENDING --> ADMIN_REFRESH
ADMIN_REFRESH -->|verified| ADMIN_APPROVE[PATCH /api/v1/admin/bank-verifications/:sellerId/approve
Only succeeds after Cashfree refresh says verified]:::ok
ADMIN_REFRESH -->|not verified| ADMIN_REJECT[PATCH /api/v1/admin/bank-verifications/:sellerId/reject
reason required]:::warn
end
subgraph NEXT [Next Seller Steps]
BAV_OK --> TM[POST /api/v1/auth/seller/signup/trademark]:::be
RPD_OK --> TM
ADMIN_APPROVE --> TM
TM --> ADMIN_TM[Admin approves trademark
If bankReady true user becomes ACTIVE
else remains PENDING until bank is verified]:::ext
ADMIN_TM --> PAYOUT[Seller payouts require
bankVerificationStatus VERIFIED
and verified bank account
Cashfree beneficiary is created during payout request]:::ok
end
Current Code Fields Written by Bank Verification
SellerProfile: kycRegisterName, bankVerificationStatus, bankRegisterName, upiRegisterName, technicalErrorMessageSellerBankAccountDetails: encrypted account number, account fingerprint, IFSC, Cashfree refs, provider status, verification response, failure reason,
isVerifiedUser:
accountSetupPhase becomes BANK_VERIFIED after a verified bank result, or COMPLETED if an active trademark already exists.
API Reference — Current Bank Verification Endpoints
▼
PAN Verification
{
"method": "POST",
"endpoint": "/api/v1/auth/seller/signup/verify-pan",
"body": { "pan": "ABCDE1234F" }
}
{
"success": true,
"name": "RAHUL MANOJ KUMAR",
"action": "SUBMIT_BANK_DETAILS"
}
Cashfree Bank Account Verification
{
"method": "POST",
"endpoint": "/api/v1/auth/seller/signup/bank-details",
"body": {
"primaryPaymentMethod": "BANK_DETAILS",
"accountHolderName": "Rahul Manoj Kumar",
"accountNumber": "765432123456789",
"ifscCode": "HDFC0000053"
}
}
{
"action": "SUBMIT_TRADEMARK | VERIFY_BANK_PENDING | RETRY_BANK_VERIFICATION",
"bankVerification": {
"method": "BANK_DETAILS",
"status": "valid | pending | failed",
"isVerified": true,
"bankVerificationStatus": "VERIFIED"
}
}
Cashfree UPI Reverse Penny Drop
{
"method": "POST",
"endpoint": "/api/v1/auth/seller/signup/bank-details",
"body": {
"primaryPaymentMethod": "UPI"
}
}
{
"action": "VERIFY_UPI_PAYMENT",
"bankVerification": {
"method": "UPI",
"verificationId": "IEMS-RPD-...",
"payment": {
"upi": "upi://pay?...",
"qrCode": "base64-or-url",
"validForSeconds": 600
}
}
}
Status and Admin Review
GET /api/v1/auth/seller/signup/bank-verification/status/current GET /api/v1/auth/seller/signup/bank-verification/:verificationId/status GET /api/v1/admin/bank-verifications GET /api/v1/admin/bank-verifications/:sellerId/status PATCH /api/v1/admin/bank-verifications/:sellerId/approve PATCH /api/v1/admin/bank-verifications/:sellerId/reject Admin approve refreshes Cashfree first and fails if the result is not verified. Reject requires a reason and records VALIDATION_FAILED.
Full Flow Description
▼
CURRENT SELLER BANK VERIFICATION
1. Seller completes email OTP and phone OTP.
2. Phone OTP verification issues onboarding cookies, so PAN and bank routes are authenticated seller routes.
3. Seller submits PAN through /api/v1/auth/seller/signup/verify-pan.
4. Seller submits bank verification through /api/v1/auth/seller/signup/bank-details.
BANK_DETAILS / BANK METHOD
→ Backend validates accountHolderName, accountNumber and ifscCode.
→ Account number is AES-encrypted; account + IFSC fingerprint prevents reuse.
→ Daily verification limit is 3 attempts per method per IST day.
→ Cashfree bank-account verification returns reference/user ids and account status.
IF status is VALID or ACCOUNT_IS_VALID → bankVerificationStatus = VERIFIED, isVerified = true, bankRegisterName saved.
IF status is pending/in progress → bankVerificationStatus = VALIDATION_INITIATED.
IF status is invalid/failed → bankVerificationStatus = VALIDATION_FAILED with failureReason.
UPI METHOD
→ Backend creates Cashfree reverse penny drop request and stores verificationId/refId.
→ Frontend shows UPI link, QR and app links for GPay/PhonePe/Paytm/BHIM when Cashfree returns them.
→ Seller pays through the link and clicks refresh.
→ /bank-verification/:verificationId/status or /status/current fetches the Cashfree status.
IF status is SUCCESS → bankVerificationStatus = VERIFIED, isVerified = true, upiRegisterName saved.
IF status is CREATED/PENDING → bankVerificationStatus = REVERSE_PENNY_DROP_PENDING.
IF status is FAILED/EXPIRED → bankVerificationStatus = VALIDATION_FAILED.
ADMIN BANK REVIEW
→ Admin lists sellers through /api/v1/admin/bank-verifications.
→ Admin refreshes a seller via /api/v1/admin/bank-verifications/:sellerId/status.
→ Admin approve calls the same refresh and succeeds only when Cashfree says verified.
→ Admin reject requires a reason and marks the bank verification failed.
TRADEMARK AND ACTIVATION
→ Seller submits trademark after bank verification with /api/v1/auth/seller/signup/trademark.
→ Admin trademark approval activates the user only if bankReady is true.
→ If trademark is approved while bank is still pending, trademark becomes ACTIVE but user remains PENDING until bank verification is completed.
PAYOUT CONNECTION
→ Seller payout request requires bankVerificationStatus = VERIFIED and a verified SellerBankAccountDetails row.
→ Cashfree beneficiary is created/reused at payout-request time.
→ Payout status is refreshed with /api/v1/seller/payouts/refresh.
🏪 Seller
Seller Login — Full Rebuild Updated
Complete redesign mirroring buyer login, with one critical difference: "Login with OTP" is explicitly NOT available for sellers. Sellers must always authenticate with a password followed by mandatory OTP 2FA. The single identifier input accepts email or phone. Post-login routing is determined by the seller's trademarkStatus: TRADEMARK_PENDING → verification-status page, TRADEMARK_REJECTED → resubmit page, ACTIVE → dashboard.
Next.js
NestJS AuthService
Redis
Cloudflare Turnstile
Resend + Fast2SMS
flowchart TD
classDef fe fill:#dbeafe,stroke:#3b82f6,color:#1e40af,font-size:12px
classDef be fill:#dcfce7,stroke:#16a34a,color:#166534,font-size:12px
classDef db fill:#fef9c3,stroke:#ca8a04,color:#78350f,font-size:12px
classDef ext fill:#f3e8ff,stroke:#9333ea,color:#4c1d95,font-size:12px
classDef err fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,font-size:12px
classDef ok fill:#d1fae5,stroke:#059669,color:#064e3b,font-size:12px
classDef warn fill:#fff7ed,stroke:#ea580c,color:#7c2d12,font-size:12px
A([Seller Opens /seller/login]):::fe
A --> CF{{Cloudflare Turnstile}}:::ext
CF -->|Fail| CFE[Bot check failed — stop]:::err
CF -->|Pass| STEP1[/Step 1: Single identifier input
Placeholder: Enter email or phone number/]:::fe
STEP1 --> DETECT{Client-side Zod
Detection}:::fe
DETECT -->|Contains @ valid email format| EMAIL_STORE[Store as email
identifierType: email]:::fe
DETECT -->|10-digit starts 6-9| PHONE_STORE[Store as phone
identifierType: phone]:::fe
DETECT -->|Neither| INPUT_ERR[Inline error:
Enter a valid email address
or 10-digit Indian mobile number]:::err
EMAIL_STORE --> PWDSCREEN
PHONE_STORE --> PWDSCREEN
PWDSCREEN[/Step 2: Password Screen
Label shows entered identifier
Masked password field with show/hide
ONLY ONE link: Forgot Password
Login with OTP is NOT available for sellers/]:::fe
PWDSCREEN --> CHOICE{User action}:::fe
CHOICE -->|Enters password and clicks Sign In| LOGIN_REQ[POST /api/v1/auth/seller/login
body: identifier + identifierType
+ password + turnstileToken]:::be
CHOICE -->|Clicks Forgot Password| FORGOT[Forgot Password flow]:::fe
LOGIN_REQ --> LOCKCHK[(Check Redis AUTH:LOCKOUT:identifier
role filter = SELLER)]:::db
LOCKCHK -->|Locked 30min| LOCKED[Return 429
Too many failed attempts
Try again in 30 minutes
Offer Forgot Password]:::err
LOCKCHK -->|Clear| FIND[(Find User by identifier
WHERE role = SELLER
AND isDeleted = false
AND status != SUSPENDED)]:::db
FIND -->|Not found| GENERIC_FAIL[Return 401 generic message
Increment AUTH:FAIL counter
Never reveal which field wrong]:::err
FIND -->|Found| PWD_VFY[Argon2id password compare]:::be
PWD_VFY -->|Wrong| INCR[(Increment AUTH:FAIL:identifier
At 5: set LOCKOUT TTL 1800s)]:::db
INCR --> GENERIC_FAIL
PWD_VFY -->|Correct| OTP_SEND[Generate 6-digit OTP
Hash SHA-256 — TTL 60s
Reset AUTH:FAIL counter]:::be
OTP_SEND -->|email identifier| EMAIL_OTP[NotificationsService dispatch email otp
BullMQ notifications queue: send-notification
Return 200 VERIFY_OTP
medium: email
maskedEmail shown]:::be
OTP_SEND -->|phone identifier| SMS_OTP[NotificationsService dispatch sms otp
BullMQ notifications queue: send-notification
Return 200 VERIFY_OTP
medium: sms
maskedPhone shown]:::be
EMAIL_OTP --> OTP_PAGE
SMS_OTP --> OTP_PAGE
OTP_PAGE[/Enter 6-digit OTP
60s resend timer
Cannot skip this step/]:::fe
OTP_PAGE --> VERIFY[(Verify OTP hash
Check 60s expiry
Check attempt count)]:::db
VERIFY -->|Expired| OTP_EXP[OTP expired
Click Resend OTP for a new code]:::err
VERIFY -->|Invalid| OTP_INV[OTP invalid — resend available]:::err
VERIFY -->|Valid| SESSION[Create Session record
Issue RS256 JWT 15min
Issue Refresh Token 7d
Set httpOnly Secure SameSite=Strict cookies
Log AuditLog: SELLER_LOGIN]:::be
SESSION --> STATUS_CHK{seller.trademarkStatus}:::fe
STATUS_CHK -->|TRADEMARK_PENDING| WAIT[Redirect to /seller/verification-status
Trademark review in progress
No dashboard access yet]:::warn
STATUS_CHK -->|TRADEMARK_REJECTED| RESUBMIT[Redirect to /seller/resubmit-trademark
Show rejection reason from admin]:::err
STATUS_CHK -->|SUSPENDED| SUSP[Return 403 ACCOUNT_SUSPENDED
Contact support message shown]:::err
STATUS_CHK -->|ACTIVE| DASH([Redirect to /seller/dashboard
Full dashboard access granted]):::ok
subgraph FORGOT_SELLER [Forgot Password — Seller]
FORGOT --> FP1[/Single input field
Enter your email or phone number
Same Zod detection as Step 1/]:::fe
FP1 --> FP2[POST /api/v1/auth/seller/forgot-password
body: identifier + identifierType]:::be
FP2 --> FP3[(Look up seller account
WHERE role = SELLER)]:::db
FP3 -->|Always returns 200 — anti-enumeration| FP4[IF found: Generate OTP
Hash SHA-256 — TTL 60s
NotificationsService dispatch identifier channel otp]:::be
FP4 --> FP5[Return 200
Frontend: Check your inbox or messages]:::fe
FP5 --> FP6[/Enter OTP + New Password/]:::fe
FP6 --> FP7[POST /api/v1/auth/seller/reset-password
body: identifier + identifierType + otp + password
Verify OTP — Hash Argon2id — Invalidate all sessions]:::be
FP7 --> FP8([Password reset success — redirect to /seller/login]):::ok
end
🔒 Key Difference from Buyer Login
Sellers do NOT have a "Login with OTP" (passwordless) option. The OTP 2FA step after correct password cannot be skipped or bypassed. This is a security requirement — sellers have financial privileges (product listings, payout requests) that require higher authentication assurance.
⚡ API Reference — Seller Login Endpoints
▼
REQUEST — Seller Login
{
"method": "POST",
"endpoint": "/api/v1/auth/seller/login",
"body": {
"identifier": "seller@shop.com",
"identifierType": "email",
"password": "SecurePass@123",
"turnstileToken": "cf-token"
}
}
RESPONSE — OTP Required
{
"status": 200,
"body": {
"action": "VERIFY_OTP",
"medium": "email",
"maskedEmail": "se***@shop.com"
}
}
RESPONSE — Login Success + Routing
// TRADEMARK_PENDING: { "status": 200, "seller": { "trademarkStatus": "TRADEMARK_PENDING", "redirect": "/seller/verification-status" } } // ACTIVE: { "status": 200, "seller": { "id": "uuid", "profileName": "Rahul", "trademarkStatus": "ACTIVE", "redirect": "/seller/dashboard" } }
RESPONSE — Error States
// Account locked: { "status": 429, "code": "AUTH_ACCOUNT_LOCKED", "retryAfter": 1800 } // Wrong credentials (generic): { "status": 401, "message": "Incorrect email or password." } // Suspended: { "status": 403, "code": "ACCOUNT_SUSPENDED" }
📋 Full Flow Description
▼
SELLER → Opens /seller/login → Solves Cloudflare Turnstile
STEP 1 — UNIFIED IDENTIFIER INPUT
→ Single input field: "Enter email or phone number"
→ Same Zod client-side detection as buyer login:
Contains "@" + valid format → email
10-digit starting 6–9 → phone
Neither → inline error
STEP 2 — PASSWORD SCREEN
→ Label shows entered identifier
→ Masked password field (show/hide toggle)
→ ONE link only: "Forgot Password"
→ "Login with OTP" is explicitly NOT present for sellers
STEP 3 — SUBMIT
→ POST /api/v1/auth/seller/login { identifier, identifierType, password, turnstileToken }
BACKEND
→ Verify Turnstile
→ Check Redis AUTH:LOCKOUT:{identifier}
IF locked → Return 429 "Too many failed attempts. Try again in 30 minutes."
→ Find User WHERE role = SELLER AND isDeleted = false
IF not found → increment AUTH:FAIL → Return 401 "Incorrect email or password" (generic)
→ Argon2id compare
IF wrong → increment AUTH:FAIL → at 5: set LOCKOUT TTL=1800s → Return 401 (same generic)
IF correct:
→ Reset AUTH:FAIL counter
→ Generate 6-digit OTP → Hash SHA-256 → TTL 60s
IF email → NotificationsService dispatch email otp → "OTP sent to ****@"
IF phone → NotificationsService dispatch sms otp → "OTP sent to your phone"
STEP 4 — MANDATORY OTP VERIFICATION (Cannot be skipped)
→ POST /api/v1/auth/seller/login/verify-otp { identifier, identifierType, otp }
BACKEND: Verify SHA-256 hash → check 60s TTL → check attempts
IF expired → "Your OTP has expired. Please click Resend OTP to receive a new code."
IF valid:
→ Create Session → Issue RS256 JWT (15min) + Refresh Token (7d) → Set httpOnly cookies
→ Insert AuditLog { event: SELLER_LOGIN, ip, userAgent }
→ Return 200 { seller: { id, profileName, trademarkStatus, bankVerified } }
POST-LOGIN ROUTING:
IF trademarkStatus = TRADEMARK_PENDING → redirect /seller/verification-status (no dashboard)
IF trademarkStatus = TRADEMARK_REJECTED → redirect /seller/resubmit-trademark (reason shown)
IF status = SUSPENDED → Return 403 ACCOUNT_SUSPENDED
IF status = ACTIVE → redirect /seller/dashboard
FORGOT PASSWORD (SELLER):
→ Single input field: "Enter your email or phone number"
→ Same Zod identifier detection
→ POST /api/v1/auth/seller/forgot-password { identifier, identifierType }
→ Always return 200 (anti-enumeration)
→ If found: OTP (TTL 60s) sent to identifier channel
→ Seller enters OTP + new password → reset → invalidate all sessions → redirect /seller/login
👤 Guest
Feature 04g — Guest Buyer Experience Updated
Guests can browse, search, and interact with the platform without any login. Cart lives in Zustand + sessionStorage; wishlist in Zustand + localStorage. Critical rule: zero POST/PUT/DELETE API calls for guest users. All mutations are local-state only. Upon login or signup, independent per-item API calls sync guest data — no merge API endpoint. Each call is single-purpose and failures do not abort others. The checkout gate preserves redirect_url in sessionStorage.
Next.js Frontend
NestJS API
PostgreSQL
Algolia
Zustand + localStorage
flowchart TD
classDef fe fill:#dbeafe,stroke:#3b82f6,color:#1e40af,font-size:12px
classDef be fill:#dcfce7,stroke:#16a34a,color:#166534,font-size:12px
classDef db fill:#fef9c3,stroke:#ca8a04,color:#78350f,font-size:12px
classDef ext fill:#f3e8ff,stroke:#9333ea,color:#4c1d95,font-size:12px
classDef err fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,font-size:12px
classDef ok fill:#d1fae5,stroke:#059669,color:#064e3b,font-size:12px
classDef warn fill:#fff7ed,stroke:#ea580c,color:#7c2d12,font-size:12px
A([Guest Arrives — No server-side session
Frontend assigns guest context]):::fe
subgraph BROWSE [Permitted — No Auth Required]
A --> B[View home page
Hero banner + categories + new arrivals
GET /api/v1/products public endpoint]:::fe
B --> C[View product listing pages
All filters active
GET /api/v1/products with query params]:::be
C --> D[(Query PostgreSQL
status = ACTIVE
isDeleted = false)]:::db
A --> E[Search via Algolia
300ms debounce
Returns 8 suggestions
title + tags + sku fields only]:::ext
E --> PDP[View product detail page
GET /api/v1/products/:slug
Images + description + bullets
Variants + seller info + Q&A + reviews]:::be
end
subgraph LOCAL_ONLY [Local State Only — Zero API Calls]
PDP --> ADD_CART[Add to Cart clicked]:::fe
ADD_CART --> CART_LOCAL[Add to Zustand cart store
Persist to sessionStorage
Zero backend interaction
Cart badge updates live]:::fe
PDP --> ADD_WISH[Add to Wishlist clicked]:::fe
ADD_WISH --> WISH_LOCAL[Add to Zustand wishlist store
Persist to localStorage
Zero backend interaction]:::fe
PDP --> NOTIFY_GUEST[Notify Me clicked
Out-of-stock product]:::fe
NOTIFY_GUEST --> NOTIFY_MSG[Show message: You are not logged in
Please log in to track this product
Store product slug in sessionStorage
Show Log In button — no API call]:::warn
end
subgraph BLOCKED [Blocked for Guests — Require Authentication]
BLOCK1[Proceed to Checkout — blocked]:::err
BLOCK2[Place an order — blocked]:::err
BLOCK3[Submit product review — blocked]:::err
BLOCK4[Ask or answer Q and A — blocked]:::err
BLOCK5[Track an order — blocked]:::err
BLOCK6[View order history — blocked]:::err
BLOCK7[Initiate return or refund — blocked]:::err
BLOCK8[Send message to seller — blocked]:::err
end
subgraph CHECKOUT_GATE [Checkout Authentication Gate]
CART_LOCAL --> CHECKOUT_BTN[Guest clicks Proceed to Checkout]:::fe
CHECKOUT_BTN --> AUTH_CHECK{Is user
authenticated?}:::fe
AUTH_CHECK -->|NO| GATE_MSG[Do NOT proceed to checkout
Show message: You are not logged in
Please sign in to complete your purchase
Your cart items will be preserved
Store redirect_url = /checkout in sessionStorage]:::warn
GATE_MSG --> LOGIN_REDIRECT[Redirect to /login or /signup]:::fe
AUTH_CHECK -->|YES| PROCEED_CHECKOUT[Proceed to checkout flow]:::ok
end
subgraph POST_LOGIN_SYNC [Post-Login Sync — Current Bulk Sync APIs]
LOGIN_REDIRECT --> COMPLETE_AUTH[User completes login or signup]:::be
COMPLETE_AUTH --> READ_GUEST[Frontend reads:
sessionStorage for cart items
localStorage for wishlist items]:::fe
READ_GUEST --> CART_SYNC[POST /api/v1/buyer/cart/sync
body: items array with productId + variantId + qty
Backend resolves each item
Existing DB item keeps max guestQty and dbQty
New item inserts row]:::be
CART_SYNC --> WISH_SYNC[POST /api/v1/buyer/wishlist/sync
body: items array with productId + optional variantId
Existing wishlist item is skipped
New item inserts up to max 100 items]:::be
WISH_SYNC --> CLEAR_LOCAL[Clear sessionStorage guest cart
Clear localStorage guest wishlist]:::fe
CLEAR_LOCAL --> HYDRATE[Hydrate Zustand cart from GET /api/v1/buyer/cart
Hydrate Zustand wishlist from GET /api/v1/buyer/wishlist]:::be
HYDRATE --> REDIRECT_CHECKOUT[Redirect to /checkout
or stored redirect_url from sessionStorage]:::ok
end
⚡ Critical Design Decision — No Merge API
No /cart/merge or /wishlist/merge endpoint exists. Post-login sync uses the current bulk endpoints from the codebase: POST /api/v1/buyer/cart/sync and POST /api/v1/buyer/wishlist/sync. Normal authenticated item changes still use the single-item cart and wishlist endpoints.
📋 Full Flow Description
▼
GUEST → Lands on /home → No server-side session → Frontend assigns guest context
GUEST CREDENTIAL CONTEXT:
→ Cart: Zustand store + sessionStorage (survives refresh; clears on browser close)
→ Wishlist: Zustand store + localStorage (persists across sessions)
→ No server-side session is created
CRITICAL RULE — ZERO POST APIs FOR GUEST USERS:
→ "Add to Cart" → local Zustand state only (sessionStorage)
→ "Add to Wishlist" → local Zustand state only (localStorage)
→ "Update Quantity" → local state only
→ "Remove from Cart" → local state only
→ "Toggle Wishlist" → local state only
→ No backend interaction of any kind until user is authenticated
PERMITTED WITHOUT LOGIN:
→ View home page (hero, categories, promotions, new arrivals)
→ View product listing pages (all filters and sorting)
→ View product detail pages (images, description, bullet points, variants, seller info, Q&A, reviews, A+ content)
→ Compare products
→ Search via Algolia (title / tags / SKU fields, case-insensitive)
→ Add to cart (local only)
→ Add to wishlist (local only)
→ Update cart quantity (local only)
→ Remove from cart (local only)
→ Toggle wishlist (local only)
BLOCKED WITHOUT LOGIN (Require Authentication):
→ Proceed to checkout → Checkout gate fires
→ Place an order
→ Track an order
→ Submit a product review
→ Ask or answer a product question
→ Click "Notify Me" on out-of-stock products (show login prompt)
→ Access saved addresses
→ View order history
→ Initiate return or refund
→ Send message to a seller
GUEST "NOTIFY ME" BEHAVIOUR:
→ Guest clicks "Notify Me" on out-of-stock product
→ Do NOT call any API
→ Do NOT auto-redirect
→ Show: "You are not logged in. Please log in to track the product stock."
→ Show "Log In" button
→ Store product slug in sessionStorage for return after login
→ After login: return user to product detail page → user clicks "Notify Me" again → API called
CHECKOUT AUTHENTICATION GATE:
→ Guest clicks "Proceed to Checkout"
→ IF NOT authenticated → Do NOT proceed to checkout
→ Show: "You are not logged in. Please sign in to complete your purchase. Your cart items will be preserved."
→ Store redirect_url = /checkout in sessionStorage
→ Redirect to /login or /signup
POST-LOGIN SYNC — CURRENT BULK SYNC CALLS (NO MERGE API):
After successful login or signup:
→ Frontend reads sessionStorage (cart) and localStorage (wishlist)
SYNC CART:
→ POST /api/v1/buyer/cart/sync { items: [{ productId, variantId?, qty }] }
→ Backend: if item already in DB cart → keep max(guestQty, dbQty); if new → insert
SYNC WISHLIST:
→ POST /api/v1/buyer/wishlist/sync { items: [{ productId, variantId? }] }
→ Backend: if already in wishlist → skip (no duplicate); if new → insert (max 100 items)
AFTER SYNC:
→ Clear sessionStorage guest cart
→ Clear localStorage guest wishlist
→ Hydrate Zustand cart from GET /api/v1/buyer/cart
→ Hydrate Zustand wishlist from GET /api/v1/buyer/wishlist
→ Redirect to /checkout (or stored redirect_url)
🔐 Security
Feature 03 — Account Deletion & Soft Deletion Updated
Current code soft-deletes users by setting
isDeleted=true and userProfileStatus=DELETED. Email and phone are retired in retired_credentials; seller brand names are retired too. Deletion requires an authenticated session and email OTP. After OTP verification, AccountDeletionService creates/updates a DeletionRequest as RESOLVED, soft-deletes the account, and revokes all sessions immediately.
Next.js
NestJS
PostgreSQL
RetiredCredentialService
TokenService revoke-all
flowchart TD
classDef fe fill:#dbeafe,stroke:#3b82f6,color:#1e40af,font-size:12px
classDef be fill:#dcfce7,stroke:#16a34a,color:#166534,font-size:12px
classDef db fill:#fef9c3,stroke:#ca8a04,color:#78350f,font-size:12px
classDef ext fill:#f3e8ff,stroke:#9333ea,color:#4c1d95,font-size:12px
classDef err fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,font-size:12px
classDef ok fill:#d1fae5,stroke:#059669,color:#064e3b,font-size:12px
classDef warn fill:#fff7ed,stroke:#ea580c,color:#7c2d12,font-size:12px
A([User initiates account deletion
from profile settings]):::fe
subgraph PREFLIGHT [Pre-Deletion Checks — Seller Only]
A -->|User role = SELLER| S_CHECK[(Check:
seller wallet available balance = 0
Cashfree beneficiary ids if present)]:::db
S_CHECK -->|Checks fail| S_BLOCK[Deletion blocked
Show which condition is unmet
Cannot delete until all conditions clear]:::err
S_CHECK -->|All clear| COMMON
end
A -->|User role = BUYER| COMMON
subgraph COMMON [Common Deletion Flow — All Users]
COMMON[User presented with:
Deletion reason — required — dropdown plus optional text
Privacy Policy warning prominently displayed:
Your email and phone will be permanently retired
They cannot be reused to create a new account]:::fe
COMMON --> REASON_SUBMIT[POST /api/v1/auth/buyer/delete/request
or POST /api/v1/auth/seller/delete/request]:::be
REASON_SUBMIT --> OTP_SEND[Generate OTP — Hash SHA-256 — TTL 60s
Send to verified email]:::be
OTP_SEND --> OTP_PAGE[/Enter 6-digit OTP
to confirm deletion intent/]:::fe
OTP_PAGE --> OTP_VFY[DELETE /api/v1/auth/buyer/delete/verify
or DELETE /api/v1/auth/seller/delete/verify
body: otp + reason + reasonDetail]:::be
OTP_VFY --> OTP_CHK[(Verify OTP hash — 60s TTL)]:::db
OTP_CHK -->|Invalid or expired| OTP_ERR[OTP error — offer resend]:::err
OTP_CHK -->|Valid| ADMIN_QUEUE[AccountDeletionService.initiateDeletion
DeletionRequest status = RESOLVED
Retire credentials
Soft delete user
Revoke all sessions]:::be
ADMIN_QUEUE --> USER_CONFIRM[Return 200
Message: account deleted
local auth state must be cleared]:::fe
end
subgraph ADMIN_FLOW [Admin Dashboard Visibility]
ADMIN1[Admin can list deletion records
GET /api/v1/admin/deletion-requests]:::ext
ADMIN1 --> SOFT_DELETE[Current delete flow already ran:
Set User.isDeleted = true
Invalidate ALL active Sessions immediately
Set status = DELETED]:::be
SOFT_DELETE --> RETIRE[Insert into retired_credentials:
email: permanently retired
phone: permanently retired]:::db
RETIRE --> NOTIFY_DELETED[Return deletion success response]:::be
end
subgraph SELLER_EXIT [Seller Exit — Additional Steps]
SOFT_DELETE --> SELLER_FINAL_CHK{Role = SELLER?}:::be
SELLER_FINAL_CHK -->|Yes| FINAL_SETTLE[Block deletion if wallet.available > 0]:::be
FINAL_SETTLE --> RAZORPAY_DEACT[Remove stored Cashfree beneficiary ids when provider supports it]:::ext
end
subgraph RETIRED_CRED [Retired Credential Enforcement — Always]
NEW_ATTEMPT[New signup or login attempt
using retired email or phone]:::fe
NEW_ATTEMPT --> RET_CHECK[(Check retired_credentials table)]:::db
RET_CHECK -->|Retired found| HTTP410[Return HTTP 410 Gone
Message: This email address was previously associated
with a deleted account on this platform
Per our Privacy Policy deleted credentials are
permanently retired and cannot be reused
Contact support if you believe this is an error]:::err
end
📋 Privacy Policy — One-Time-Use Credentials Rule
Email addresses and phone numbers are one-time-use identifiers on this platform. Once an account is deleted, the associated email and phone number are permanently retired and enforced at the database level via the retired_credentials table. This policy is disclosed at signup and shown as a warning before deletion is confirmed. HTTP 410 Gone is returned on any reuse attempt — no account can ever be created with a retired credential.
⚡ API Reference — Account Deletion Endpoints
▼
REQUEST — Initiate Deletion
{
"method": "POST",
"endpoint": "/api/v1/auth/buyer/delete/request"
// seller: /api/v1/auth/seller/delete/request
}
RESPONSE — OTP Sent
{
"status": 200,
"body": {
"action": "VERIFY_DELETION_OTP",
"resendAfter": 60,
"message": "Enter the OTP to confirm deletion."
}
}
RESPONSE — Retired Credential (410)
{
"status": 410,
"code": "CREDENTIAL_RETIRED",
"message": "This email address was previously
associated with a deleted account on
this platform. Per our Privacy Policy,
deleted credentials are permanently
retired and cannot be used to create
or access any account. If you believe
this is an error, please contact support."
}
RESPONSE — Deletion Completed
{
"status": 200,
"body": {
"success": true,
"message": "Account has been successfully deleted and credentials retired."
}
}
📋 Full Flow Description
▼
SOFT DELETION MODEL:
→ isDeleted = true — rows are NEVER physically removed from the database
→ All Prisma/ORM queries enforce: WHERE isDeleted = false at query level
→ Soft-deleted data is treated as completely non-existent for all operational purposes
SELLER PRE-DELETION REQUIREMENT IN CURRENT CODE:
→ Seller wallet.available must be 0
→ Cashfree beneficiary ids are removed when the payout provider supports removal
IF wallet balance exists → deletion blocked with clear message
ACCOUNT DELETION FLOW — ALL USERS:
→ User navigates to /settings/delete-account
→ Presented with: Privacy Policy warning about permanent credential retirement
→ Required: select deletion reason from dropdown + optional freeform detail
→ POST /api/v1/auth/buyer/delete/request or POST /api/v1/auth/seller/delete/request
BACKEND: Generate OTP (60s) → hash SHA-256 → send deletion OTP to verified email
→ Return 200 { action: "VERIFY_DELETION_OTP" }
→ User enters OTP → DELETE /api/v1/auth/buyer/delete/verify or DELETE /api/v1/auth/seller/delete/verify { otp, reason, reasonDetail }
BACKEND: Verify OTP hash + 60s TTL
IF valid → Upsert DeletionRequest { userId, reason, status: RESOLVED, resolvedAt }
→ Set User.isDeleted = true → Set userProfileStatus = DELETED
→ Invalidate ALL active Sessions immediately
→ Insert retired_credentials { email, phone, retiredAt, reason: ACCOUNT_DELETED }
→ For sellers, retire owned brand names too
→ Return 200 "Account has been successfully deleted and credentials retired."
RETIRED CREDENTIAL ENFORCEMENT:
→ Check on every signup initiation and login attempt
IF email or phone found in retired_credentials:
→ Return HTTP 410 Gone
→ Message: "This email address (or phone number) was previously associated with a deleted account on this platform. Per our Privacy Policy, deleted credentials are permanently retired and cannot be used to create or access any account. If you believe this is an error, please contact support."
SELLER DELETION LIMIT IN CURRENT CODE:
→ Seller deletion is blocked while sellerDigitalWallet.available > 0
→ The service removes stored Cashfree beneficiary ids through PayoutProviderService when possible
→ No settlement bypass or external fund-account deactivation route is implemented in the auth deletion service
Current Implementation
Current Auth Code Sync — Bootstrap Once, 401 Refresh, OTP Routes, Deletion
This panel is aligned with the current codebase. The frontend session bootstrap runs once from
AppProviders. Navigation does not call /auth/me or /auth/refresh
repeatedly. Axios refreshes only after a protected API call returns 401. Buyer supports password login
followed by OTP and passwordless login OTP. Seller login requires password + Turnstile before OTP.
Account deletion is OTP-gated and resolved immediately by the auth deletion service.
Next.js AppProviders
NestJS Auth Controllers
PostgreSQL Sessions
Redis OTP + lockout
sequenceDiagram
autonumber
participant Browser
participant App as AppProviders
participant Auth as Auth API
participant Axios
participant API as Protected API
Browser->>App: Mount frontend app
App->>App: Skip bootstrap on /auth pages
App->>Auth: GET /api/v1/auth/me
alt access cookie valid
Auth-->>App: user payload
else access expired
App->>Auth: POST /api/v1/auth/refresh
Auth-->>App: rotated HttpOnly cookie pair
App->>Auth: GET /api/v1/auth/me
Auth-->>App: user payload
end
Browser->>Axios: Navigate and call seller/buyer/admin API
Axios->>API: Original protected request
alt API returns 401
Axios->>Auth: POST /api/v1/auth/refresh
Auth-->>Axios: rotated cookie pair
Axios->>API: Retry original request once
else API succeeds
API-->>Axios: response data
end
🔌 Current Auth Route Map
▼
COMMON SESSION ROUTES
GET /api/v1/auth/me POST /api/v1/auth/refresh POST /api/v1/auth/logout POST /api/v1/auth/logout-all POST /api/v1/auth/change-password Frontend rule: AppProviders hydrates once. Axios refreshes only on protected 401.
BUYER OTP ROUTES
POST /api/v1/auth/buyer/signup/initiate POST /api/v1/auth/buyer/signup/resend-otp POST /api/v1/auth/buyer/signup/verify-email POST /api/v1/auth/buyer/signup/complete POST /api/v1/auth/buyer/signup/verify-phone POST /api/v1/auth/buyer/login POST /api/v1/auth/buyer/login/request-otp POST /api/v1/auth/buyer/login/resend-otp POST /api/v1/auth/buyer/login/verify-otp POST /api/v1/auth/buyer/forgot-password POST /api/v1/auth/buyer/reset-password
SELLER OTP + ONBOARDING ROUTES
POST /api/v1/auth/seller/signup/initiate POST /api/v1/auth/seller/signup/verify-email POST /api/v1/auth/seller/signup/verify-phone POST /api/v1/auth/seller/signup/verify-pan POST /api/v1/auth/seller/signup/bank-details POST /api/v1/auth/seller/signup/trademark POST /api/v1/auth/seller/login POST /api/v1/auth/seller/login/resend-otp POST /api/v1/auth/seller/login/verify-otp POST /api/v1/auth/seller/forgot-password POST /api/v1/auth/seller/reset-password
ACCOUNT DELETION ROUTES
POST /api/v1/auth/buyer/delete/request DELETE /api/v1/auth/buyer/delete/verify POST /api/v1/auth/seller/delete/request DELETE /api/v1/auth/seller/delete/verify Current auth service: DeletionRequest is recorded as RESOLVED during OTP verification. Admin deletion routes exist for dashboard visibility/actions, but buyer/seller deletion is resolved immediately after successful OTP verification.
📋 Current Working Rules
▼
AUTH TRAFFIC RULE
The frontend must not call /auth/me or /auth/refresh on every page navigation. AppProviders hydrates once. Axios handles the refresh path only when a protected API returns 401. Local development currently uses NEXT_PUBLIC_API_BASE_URL=http://localhost:4000/api/v1 so the browser calls the backend directly instead of proxying through :3000.
LOGIN RULE
Buyer password login validates identifier + password + Turnstile before OTP, and buyer passwordless login uses /auth/buyer/login/request-otp. Seller login validates identifier + password + Turnstile before OTP. If password or identifier is wrong, the user remains on the password screen. OTP is not sent for failed password login.
PASSWORD RULE
Forgot password uses role-specific OTP routes. Logged-in buyer and seller profile pages call /api/v1/auth/change-password; successful reset/change revokes all sessions.
SELLER ONBOARDING RULE
Seller signup flow is: email OTP → phone OTP → issue onboarding cookies → PAN → bank verification → trademark → admin approval/status screen.
DELETION RULE
Buyer and seller account deletion requires an authenticated session plus email OTP. Current code soft-deletes immediately after OTP verification, records DeletionRequest as RESOLVED, retires credentials, and revokes all sessions.
🔑 Token Lifecycle
Access & Refresh Token Lifecycle New
Current implementation of the authentication token system. The platform uses two tokens issued together at every successful login or signup: a short-lived Access Token (RS256 JWT, 15-minute TTL) for authorising API requests, and a long-lived Refresh Token (UUID v4, 7-day TTL, stored as SHA-256 hash in the Sessions table) for silent renewal. Tokens are delivered via
httpOnly + SameSite=Strict cookies, with Secure enabled in production. Refresh tokens rotate on every use. Sessions are revoked by logout, logout-all, account deletion, and suspicious rotated-refresh reuse. The frontend hydrates once and the Axios interceptor silently refreshes only after a protected request returns 401.
Next.js (Axios interceptor)
NestJS AuthService
Sessions table (PostgreSQL)
Sessions table revocation
RS256 key pair (private/public)
flowchart TD
classDef fe fill:#dbeafe,stroke:#3b82f6,color:#1e40af,font-size:12px
classDef be fill:#dcfce7,stroke:#16a34a,color:#166534,font-size:12px
classDef db fill:#fef9c3,stroke:#ca8a04,color:#78350f,font-size:12px
classDef ext fill:#f3e8ff,stroke:#9333ea,color:#4c1d95,font-size:12px
classDef err fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,font-size:12px
classDef ok fill:#d1fae5,stroke:#059669,color:#064e3b,font-size:12px
classDef warn fill:#fff7ed,stroke:#ea580c,color:#7c2d12,font-size:12px
subgraph ISSUANCE [Token Issuance on Every Successful Login or Signup]
A([Auth success — login or signup OTP verified]):::ok
A --> ISSUE["Generate Access Token
RS256 JWT · signed with RSA private key
Claims: sub · role · sessionId · iat · exp
TTL 15 minutes
Set-Cookie: access_token
httpOnly · Secure in production · SameSite=Strict
Path=/ · Max-Age=900"]:::be
ISSUE --> ISSUE2["Generate Refresh Token
UUID v4 — cryptographically random
TTL 7 days — hash SHA-256 before storing
Set-Cookie: refresh_token
httpOnly · Secure in production · SameSite=Strict
Path=/api/v1/auth/refresh · Max-Age=604800"]:::be
ISSUE2 --> SESSION_SAVE[("Insert Sessions row
id: UUID primary key
userId: FK to Users
refreshTokenHash: SHA-256 of UUID
expiresAt: now plus 7 days
ipAddress · userAgent
isRevoked: false")]:::db
end
subgraph API_USE [Every Authenticated API Request]
REQ([Frontend makes any API call]):::fe
REQ --> COOKIE_SENT["Browser auto-attaches access_token cookie
httpOnly — JS cannot read it
XSS cannot steal the token"]:::fe
COOKIE_SENT --> JWT_VERIFY["NestJS JwtAuthGuard
Verify RS256 signature via RSA public key
Check expiry · check sub · check role
Extract userId · role · sessionId"]:::be
JWT_VERIFY -->|Valid JWT| API_OK([Request proceeds to handler]):::ok
JWT_VERIFY -->|JWT expired after 15 min| REFRESH_FLOW["Return 401 AUTH_TOKEN_EXPIRED"]:::err
end
subgraph SILENT_REFRESH [Silent Refresh — No User Interaction Required]
REFRESH_FLOW --> INTERCEPTOR["Axios response interceptor catches 401
Sets isRefreshing flag to true
Queues all parallel in-flight requests
Calls POST /api/v1/auth/refresh automatically"]:::fe
INTERCEPTOR --> REFRESH_REQ["POST /api/v1/auth/refresh
Browser auto-sends refresh_token cookie
Path-restricted — sent only to this endpoint"]:::be
REFRESH_REQ --> RT_LOAD[("Sessions table lookup
Match SHA-256 hash of incoming token
Check isRevoked is false
Check expiresAt is after now")]:::db
RT_LOAD -->|Not found · revoked · or expired| RT_FAIL["Return 401 AUTH_SESSION_EXPIRED
Clear both cookies via Max-Age=0
Frontend redirects user to /login
All queued requests rejected with error"]:::err
RT_LOAD -->|Valid session found| ROTATION["TOKEN ROTATION — mandatory
Generate NEW access token RS256 · 15 min
Generate NEW refresh token UUID v4 · 7 days
Hash new refresh token with SHA-256
Update Sessions row with new hash and expiry
Old refresh token UUID is now invalid"]:::be
ROTATION --> RETRY["Set-Cookie both new tokens
Return 200 to interceptor
Interceptor retries original request
All queued requests retried in order
isRefreshing reset to false"]:::ok
end
subgraph LOGOUT [Logout Flows]
LOGOUT_SINGLE["POST /api/v1/auth/logout
Reads sessionId from JWT claims"]:::be
LOGOUT_SINGLE --> REVOKE_ONE[("UPDATE Sessions
SET isRevoked = true
WHERE id = sessionId")]:::db
REVOKE_ONE --> CLEAR_ONE["Clear both cookies — Max-Age=0
Return 200 Logged out"]:::ok
LOGOUT_ALL["POST /api/v1/auth/logout-all
Used for manual all-device sign-out and account-deletion cleanup"]:::be
LOGOUT_ALL --> REVOKE_ALL[("UPDATE Sessions
SET isRevoked = true
WHERE userId matches
All devices · all sessions invalidated")]:::db
REVOKE_ALL --> CLEAR_BOTH["Clear both cookies — Max-Age=0
Return 200 with count of sessions revoked"]:::ok
end
subgraph TOKEN_INSPECT [Access Token Structure — RS256 JWT]
JWT_HEADER["Header
alg: RS256
typ: JWT"]:::ext
JWT_PAYLOAD["Payload claims
sub: user UUID
role: BUYER or SELLER
sessionId: session UUID
iat: issued-at Unix timestamp
exp: iat plus 900 seconds"]:::ext
JWT_SIG["Signature
RSASSA-PKCS1-v1_5 with SHA-256
Signed by RSA private key 2048-bit
Verified by RSA public key only
No shared secret — microservice-safe"]:::ext
JWT_HEADER --> JWT_PAYLOAD --> JWT_SIG
end
subgraph REVOCATION_EVENTS [Automatic Full Revocation Triggers]
EV1["Manual Logout All
POST /api/v1/auth/logout-all
All sessions revoked across all devices"]:::warn
EV2["Account Deletion approved by admin
All Sessions set isRevoked to true
Both cookies cleared immediately"]:::warn
EV3["Suspended user login
Password validator blocks suspended accounts
Existing sessions should be revoked by admin action when suspension is added"]:::warn
EV4["Stolen rotated token detected
Old rotated refresh token presented again
Entire session family revoked as precaution"]:::warn
end
🔑 Token Architecture Summary
Access Token: RS256 JWT · 15 min TTL · httpOnly cookie (Path=/) · Verified with RSA public key — no shared secret · Claims: sub, role, sessionId, iat, expRefresh Token: UUID v4 · 7 day TTL · SHA-256 hash stored in Sessions table · httpOnly cookie (Path=/api/v1/auth/refresh — path-restricted) · Rotated on every use
Silent refresh: Axios interceptor catches 401 → auto-calls /api/v1/auth/refresh → gets new token pair → retries original request — zero user interaction
Token rotation: Every refresh generates a new refresh token and invalidates the old one — prevents replay attacks even if a token is stolen
Never in: Response body · localStorage · sessionStorage · JavaScript-accessible cookies
⚡ API Reference — Token Lifecycle Endpoints
▼
RESPONSE — Token Issuance (login / signup)
// Cookies set on every successful auth: Set-Cookie: access_token=eyJhbGci... HttpOnly; Secure in production; SameSite=Strict Path=/api; Max-Age=900 Set-Cookie: refresh_token=550e8400-e29b-41d4... HttpOnly; Secure in production; SameSite=Strict Path=/api/v1/auth/refresh; Max-Age=604800 // JWT claims (decoded — never sent to FE): { "sub": "user-uuid-123", "role": "BUYER", "sessionId": "session-uuid-456", "iat": 1745582400, "exp": 1745583300 }
REQUEST / RESPONSE — Silent Token Refresh
// Frontend Axios interceptor fires on 401: { POST /api/v1/auth/refresh // No body needed — refresh_token cookie auto-sent // Browser only sends it to this exact path } // Success (200) — new token pair issued: Set-Cookie: access_token=eyJhbGci... (new) Set-Cookie: refresh_token=a8098c1a... (new, old revoked) { "status": 200, "message": "Token refreshed" } // Failure (401) — session expired or revoked: { "status": 401, "code": "AUTH_SESSION_EXPIRED", "message": "Session expired. Please log in again." } // Both cookies cleared → redirect /login
REQUEST — Logout (single session)
{
POST /api/v1/auth/logout
// access_token cookie auto-sent — no body
// sessionId extracted from JWT claims
}
// Response:
{
"status": 200,
"message": "Logged out successfully."
}
// Sessions.isRevoked = true for this session
// Both cookies cleared (Max-Age=0)
REQUEST — Logout All Sessions
{
POST /api/v1/auth/logout-all
// User-triggered all-device logout
}
// Response:
{
"status": 200,
"sessionsRevoked": 3,
"message": "All sessions revoked."
}
// All Sessions rows for userId set isRevoked=true
// All devices must re-authenticate
📋 Full Implementation Specification
▼
TOKEN ISSUANCE — Fires on: Signup completion, Login OTP verified, Token refresh success
DATABASE SCHEMA — Sessions table:
id → UUID v4 — primary key
userId → FK → Users.id
refreshTokenHash → SHA-256(refreshToken) — plaintext NEVER stored
expiresAt → NOW + 7 days
createdAt → NOW
ipAddress → request IP (INET type in PostgreSQL)
userAgent → request User-Agent header (truncated to 512 chars)
isRevoked → Boolean default false — index on (userId, isRevoked)
ACCESS TOKEN STRUCTURE:
→ Algorithm: RS256 (RSASSA-PKCS1-v1_5 with SHA-256)
→ Key pair: 2048-bit RSA — private key signs, public key verifies
→ Private key stored as environment secret — NEVER committed to git
→ Public key used by JwtAuthGuard — can be safely distributed
→ Why RS256 over HS256: no shared secret — microservices can verify without access to private key
JWT Header: { "alg": "RS256", "typ": "JWT" }
JWT Payload:
{
sub: "user-uuid-123" // userId — primary user identifier
role: "BUYER" | "SELLER" // For route guard enforcement
sessionId: "session-uuid-456" // For targeted single-session revocation
iat: 1745582400 // Issued at (Unix timestamp)
exp: 1745583300 // Expiry = iat + 900 (15 minutes)
}
NOTE: No sensitive data (email, phone, name) in JWT payload — only IDs and role.
REFRESH TOKEN:
→ UUID v4 generated server-side (crypto.randomUUID())
→ Hashed with SHA-256 before storing in Sessions table
→ Plaintext UUID sent to client via cookie only — never stored anywhere else
→ Cookie path restricted to /api/v1/auth/refresh — browser only sends it to that exact URL
COOKIE ATTRIBUTES (both tokens):
→ HttpOnly: true — inaccessible to JavaScript — XSS cannot steal tokens
→ Secure: true in production, false in local development — controlled by NODE_ENV
→ SameSite: Strict — CSRF protection — not sent on cross-site requests
→ access_token: Path=/ — sent with API requests automatically
→ refresh_token: Path=/api/v1/auth/refresh — sent ONLY to the refresh endpoint
SILENT REFRESH FLOW (Axios request interceptor pattern):
FRONTEND
→ Every API call automatically includes access_token cookie (browser behaviour)
→ Backend JwtAuthGuard validates JWT: signature → expiry → userId active
IF JWT expired (401 AUTH_TOKEN_EXPIRED):
→ Axios response interceptor catches the 401
→ Check: is already refreshing? → IF YES → queue this request → wait for new token
→ IF NO: set isRefreshing = true
→ Call POST /api/v1/auth/refresh (refresh_token cookie auto-sent)
BACKEND /auth/refresh:
→ Read refresh_token cookie
→ Hash it with SHA-256
→ Query Sessions: WHERE refreshTokenHash = hash AND isRevoked = false AND expiresAt > NOW
IF not found, revoked, or expired:
→ Return 401 AUTH_SESSION_EXPIRED
→ Frontend: clear both cookies → redirect /login → flush all queued requests with error
IF valid session:
TOKEN ROTATION (mandatory — prevents refresh token replay attacks):
→ Generate NEW Access Token (RS256, 15 min)
→ Generate NEW Refresh Token (UUID v4)
→ Hash new refresh token with SHA-256
→ UPDATE Sessions SET refreshTokenHash = new hash, expiresAt = NOW + 7d, isRevoked = false
→ Old refresh token UUID is now invalidated (its hash no longer matches DB)
→ Set-Cookie both new tokens with same httpOnly/Secure/SameSite attributes
→ Return 200
→ Axios interceptor: set isRefreshing = false
→ Retry the original failed request (new access_token cookie now set)
→ Resolve all queued parallel requests (they retry in order)
LOGOUT — SINGLE SESSION:
→ User clicks Logout
→ FRONTEND: POST /api/v1/auth/logout (access_token cookie auto-sent)
→ BACKEND: extract sessionId from JWT claims
→ UPDATE Sessions SET isRevoked = true WHERE id = sessionId
→ Set-Cookie: access_token= ; Max-Age=0 (clears cookie)
→ Set-Cookie: refresh_token= ; Max-Age=0 (clears cookie)
→ Return 200
LOGOUT — ALL SESSIONS:
→ POST /api/v1/auth/logout-all
→ BACKEND: extract userId from JWT
→ UPDATE Sessions SET isRevoked = true WHERE userId = userId
→ Clear both cookies on current device
→ All other devices: next API call returns 401 → refresh attempt fails → redirected to /login
AUTOMATIC FULL REVOCATION TRIGGERS:
✓ Manual Logout All → POST /api/v1/auth/logout-all revokes every session for the current user
✓ Account Deletion (admin approved) → all Sessions.isRevoked = true immediately
✓ Account Suspension by admin → PATCH /admin/users/:id/suspend → all sessions revoked
✓ Refresh token theft detection → if old (rotated) refresh token is presented → revoke ENTIRE session family (all sessions for that user) as a security measure
IMPLEMENTATION NOTES:
→ RS256 key pair generation: openssl genrsa -out private.pem 2048 → openssl rsa -in private.pem -pubout -out public.pem
→ Keys injected via environment variables JWT_PRIVATE_KEY_B64 + JWT_PUBLIC_KEY_B64
→ NestJS JwtModule: { algorithm: 'RS256', privateKey, publicKey, signOptions: { expiresIn: '15m' } }
→ NestJS JwtAuthGuard: custom guard extending PassportStrategy('jwt') — attached to all protected routes
→ Role guard: @Roles('SELLER') decorator → checks JWT claims.role before handler runs
→ Expired sessions are ignored by SessionService queries; no session cleanup worker exists in the current codebase