Authentication System Documentation
Overview
LandlordX uses a stateless JWT-based authentication system with refresh token rotation for secure, scalable user sessions. The frontend manages authentication state using Zustand for client state, TanStack Query for server state, and localStorage for persistent token storage.
Architecture
Four-Layer Design
The authentication system follows a clear separation of concerns:
┌─────────────────────┐
│ UI Components │ (React components, forms, pages)
│ (auth/login, etc) │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ React Query │ (TanStack Query hooks)
│ (useAuth.ts) │ • useVerifyOTP()
└──────────┬──────────┘ • useRefreshToken()
│ • useLogout()
┌──────────▼──────────┐
│ Auth Store │ (Zustand global state)
│ (auth.store.ts) │ • User state
└──────────┬──────────┘ • isAuthenticated flag
│
┌──────────▼──────────┐
│ Auth Service │ (API communication)
│ (auth.service.ts) │ • verifyOTP()
└──────────┬──────────┘ • refreshAccessToken()
│ • logout()
┌──────────▼──────────┐
│ Token Management │ (localStorage utilities)
│ (tokens.ts) │ • getRefreshToken()
└─────────────────────┘ • setAccessToken()
• clearTokens()Key Files
| File | Purpose | Responsibilities |
|---|---|---|
src/lib/auth/tokens.ts | Token storage utilities | localStorage CRUD operations, SSR-safe checks |
src/lib/api/auth.service.ts | API service layer | HTTP calls to auth endpoints (currently mocked) |
src/hooks/useAuth.ts | React Query hooks | TanStack Query mutations for auth operations |
src/hooks/useLogoutWithNavigation.ts | Logout with navigation | Combines logout mutation with router navigation |
src/lib/store/auth.store.ts | Global auth state | Zustand store with user, isAuthenticated flags |
src/components/guards/auth-guard.tsx | Auth route guard | Prevents authenticated users from accessing auth routes |
src/components/providers/query-provider.tsx | Query client provider | Wraps app with QueryClientProvider |
src/components/providers/session-provider.tsx | Session restoration wrapper | Uses useRefreshToken() hook on app mount |
src/app/layout.tsx | Root layout | Wraps app with QueryProvider and SessionProvider |
src/app/auth/layout.tsx | Auth layout | Wraps auth routes with AuthGuard |
src/app/(protected)/layout.tsx | Protected layout | Enforces authentication for protected routes |
Token Management
Token Types
-
Access Token: Short-lived JWT (e.g., 15 minutes) for API authorization
- Stored in
localStorageunderlandlordx_access_token - Sent in
Authorization: Bearer <token>header with API requests - Automatically refreshed when expired
- Stored in
-
Refresh Token: Long-lived token (e.g., 7 days) for obtaining new access tokens
- Stored in
localStorageunderlandlordx_refresh_token - Used only for calling
/api/auth/refreshendpoint - Rotated on each refresh (new refresh token returned)
- Stored in
Token Storage API
Located in src/lib/auth/tokens.ts:
// Store tokens
setAccessToken(token: string): void
setRefreshToken(token: string): void
// Retrieve tokens
getAccessToken(): string | null
getRefreshToken(): string | null
// Clear all tokens (logout)
clearTokens(): void
// Check authentication status
isAuthenticated(): boolean // Returns true if refresh token existsSSR Safety: All functions include typeof window !== "undefined" checks to prevent server-side errors.
Authentication Flow
1. Progressive Authentication Flow
LandlordX implements a 4-step progressive onboarding flow that automatically routes users based on their completion status:
Step 1: Login → Step 2: Verify OTP → Step 3: Create Profile → Step 4: DigiLocker → DashboardAutomatic Step Progression:
- After OTP verification, user state includes
hasCompletedProfileandhasVerifiedProfileflags AuthGuardchecks these flags and automatically routes to the next incomplete step- Users cannot skip steps or access routes they haven’t reached yet
Flow Diagram:
User enters phone → /auth/login
↓
Redirect to /auth/verify
↓
User enters OTP → POST /api/auth/verify
↓
Returns user with completion flags
↓
┌────────────────────────────┐
│ AuthGuard checks user state│
└────────────────────────────┘
↓
hasCompletedProfile?
│
NO ───────┼───────→ /auth/create-profile
│ ↓
│ User fills profile
│ ↓
│ hasCompletedProfile = true
│ ↓
YES ──────┘ hasVerifiedProfile?
│
NO ───────┼───────→ /auth/digilocker
│ ↓
│ User connects DigiLocker
│ ↓
│ hasVerifiedProfile = true
│ ↓
YES ──────┘ │
↓
/dashboardSee PROGRESSIVE_AUTH_FLOW.md for complete documentation.
Implementation:
- Login page (
src/app/auth/login/page.tsx): Phone number input with Zod validation (10 digits, starts with 6-9) - Verify page (
src/app/auth/verify/page.tsx): UsesuseVerifyOTP()hook, sets user state, relies on AuthGuard for routing - Create Profile page (
src/app/auth/create-profile/page.tsx): Updates user with name/email, setshasCompletedProfile = true - DigiLocker page (
src/app/auth/digilocker/page.tsx): Connects DigiLocker or skips, setshasVerifiedProfile = true - Auth Flow Utilities (
src/lib/auth/auth-flow.ts):getNextAuthStep()andcanAccessAuthStep()logic
2. Session Restoration Flow
On app mount or page reload:
App loads → QueryProvider + SessionProvider mount
↓
useEffect calls useRefreshToken() hook
↓
Check if isAuthenticated() (refresh token exists)
↓
YES → Call mutateAsync from useRefreshToken()
↓
Backend validates refresh token
↓
Returns { accessToken, refreshToken (rotated) }
↓
Store new tokens → setAccessToken(), setRefreshToken()
↓
Update auth state → isAuthenticated = true
↓
NO → clearUser() → Reset auth stateImplementation:
- QueryProvider (
src/components/providers/query-provider.tsx): Wraps entire app with QueryClientProvider - SessionProvider (
src/components/providers/session-provider.tsx): UsesuseRefreshToken()hook to restore session on mount - Root Layout (
src/app/layout.tsx): Includes<QueryProvider><SessionProvider>{children}</SessionProvider></QueryProvider>
3. Token Refresh Flow
When access token expires during API call:
API request fails with 401 Unauthorized
↓
Intercept error in Axios interceptor (TODO)
↓
Call mutateAsync from useRefreshToken()
↓
Retry original request with new access tokenCurrent Status: Mock refresh logic exists in auth.service.ts. Axios interceptor needs implementation.
4. Logout Flow
User clicks Logout → useLogoutWithNavigation()
↓
Call mutateAsync from useLogout()
↓
Call logout() service (optional API call)
↓
clearUser() → Calls clearTokens() internally
↓
Router navigates to /auth/loginImplementation:
- useLogoutWithNavigation (
src/hooks/useLogoutWithNavigation.ts): Combines TanStack Query mutation with navigation
React Query Hooks
Located in src/hooks/useAuth.ts:
Available Hooks
// OTP Verification
const { mutateAsync: verifyOTP, isPending } = useVerifyOTP();
const response = await verifyOTP({ phone: "+919876543210", otp: "123456" });
// Token Refresh
const { mutateAsync: refreshToken, isPending } = useRefreshToken();
const response = await refreshToken();
// Logout
const { mutateAsync: logout, isPending } = useLogout();
await logout();Why TanStack Query?
All API calls MUST go through TanStack Query to maintain consistency and leverage its powerful features:
- Automatic retries: Failed requests are retried once by default
- Loading states:
isPendingflag for UI feedback - Error handling: Structured error responses
- Caching: Prevents duplicate requests (configurable)
- Optimistic updates: Update UI before API response (future feature)
Zustand Auth Store
Located in src/lib/store/auth.store.ts:
Purpose: Manages client-side authentication state only. Does NOT handle API calls (delegated to React Query hooks).
State Shape
interface AuthState {
user: User | null; // Current authenticated user
isAuthenticated: boolean; // Auth status flag
isLoading: boolean; // Loading state
setUser: (user: User) => void; // Set user after login
clearUser: () => void; // Clear user and tokens
setLoading: (loading: boolean) => void; // Set loading state
}Usage in Components
// Get user data
const user = useAuthStore((state) => state.user);
// Check auth status
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
// Update user after login (used with React Query mutation)
const setUser = useAuthStore((state) => state.setUser);
const { mutateAsync: verifyOTP } = useVerifyOTP();
const response = await verifyOTP({ phone, otp });
setUser(response.user);
// Clear user and tokens
const clearUser = useAuthStore((state) => state.clearUser);
clearUser(); // Also calls clearTokens() internally
// Logout with navigation
const { handleLogout, isPending } = useLogoutWithNavigation();
await handleLogout(); // Calls logout API, clears state, navigates to /auth/loginMock API Implementation
All API calls in src/lib/api/auth.service.ts are currently mocked with simulated delays and hardcoded responses. These are called ONLY through TanStack Query hooks.
Current Mock Functions
-
verifyOTP(phone, otp):
- Simulates 1 second delay
- Returns mock user, access token, refresh token
- Automatically stores tokens in localStorage
- Called via:
useVerifyOTP()hook
-
refreshAccessToken():
- Simulates 500ms delay
- Generates new mock tokens
- Rotates refresh token (returns new one)
-
logout():
- Simulates 300ms delay
- Returns success response
Migration to Production API
Each function has a TODO comment marking the integration point:
// TODO: Replace with actual API call
// const response = await axios.post('/api/auth/verify', { phone, otp });Migration Steps:
- Replace mock logic with Axios POST calls
- Update response shape to match backend API contract
- Add error handling for network failures, invalid tokens, etc.
- Remove mock delays and console.log statements
- Update types if backend response differs from mock
Route Protection ✅
Route protection is fully implemented using layout-based guards to ensure users can only access appropriate routes based on their authentication status.
Implementation
Two-way protection:
- Protected Routes (
/dashboard, etc.): Require authentication - Auth Routes (
/auth/*): Redirect to dashboard if already authenticated
Protected Layout
Located in src/app/(protected)/layout.tsx:
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/lib/store/auth.store";
export default function ProtectedLayout({ children }) {
const router = useRouter();
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isLoading = useAuthStore((state) => state.isLoading);
// Redirect to login if not authenticated
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push("/auth/login");
}
}, [isAuthenticated, isLoading, router]);
// Show loading state while checking authentication
if (isLoading) {
return <div>Loading...</div>;
}
// Don't render protected content if not authenticated
if (!isAuthenticated) {
return null;
}
return <>{children}</>;
}Features:
- Checks
isAuthenticatedflag from Zustand store - Redirects unauthenticated users to
/auth/login - Shows loading state during session restoration
- Prevents flash of protected content
- Includes logout functionality in header dropdown
Auth Guard
Located in src/components/guards/auth-guard.tsx:
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/lib/store/auth.store";
export function AuthGuard({ children }) {
const router = useRouter();
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isLoading = useAuthStore((state) => state.isLoading);
// Redirect authenticated users to dashboard
useEffect(() => {
if (!isLoading && isAuthenticated) {
router.push("/dashboard");
}
}, [isAuthenticated, isLoading, router]);
// Show loading state
if (isLoading) {
return <div>Loading...</div>;
}
// If authenticated, don't render auth pages (will redirect)
if (isAuthenticated) {
return null;
}
return <>{children}</>;
}Usage in src/app/auth/layout.tsx:
import { AuthGuard } from "@/components/guards/auth-guard";
export default function AuthLayout({ children }) {
return <AuthGuard>{children}</AuthGuard>;
}Features:
- Prevents authenticated users from accessing auth routes
- Redirects to
/dashboardif user tries to access/auth/loginwhile logged in - Shows loading state during session check
- Works automatically for all routes under
/auth
Route Groups
The application uses Next.js route groups for organization:
src/app/
├── (protected)/ # Protected routes requiring authentication
│ ├── dashboard/
│ ├── tenants/
│ └── layout.tsx # ProtectedLayout with auth check
├── auth/ # Public auth routes
│ ├── login/
│ ├── verify/
│ ├── create-profile/
│ └── layout.tsx # AuthLayout with AuthGuard
└── layout.tsx # Root layout with providersUser Flow Examples
Scenario 1: Unauthenticated user tries to access dashboard
- User navigates to
/dashboard ProtectedLayoutchecksisAuthenticated→false- User redirected to
/auth/login
Scenario 2: Authenticated user tries to access login
- User navigates to
/auth/login AuthGuardchecksisAuthenticated→true- User redirected to
/dashboard
Scenario 3: User logs out
- User clicks “Logout” in header dropdown
useLogoutWithNavigation()calls logout APIclearUser()removes tokens and resets auth staterouter.push("/auth/login")redirects to login- User can now access auth routes again
Error Handling
Current Error Handling
- Console logging: All errors are logged to console for debugging
- TODO markers: Toast notifications planned but not implemented
Planned Improvements
- Toast Notifications: Replace
console.error()with user-facing toast messages - Error Types: Create custom error classes for auth failures (InvalidOTP, TokenExpired, NetworkError)
- Retry Logic: Implement exponential backoff for failed refresh attempts
- Fallback UI: Show error boundaries on auth failure instead of blank screens
Security Considerations
Current Implementation
✅ Tokens stored in localStorage: Simpler than cookies, vulnerable to XSS but mitigated by CSP and React’s XSS protection
✅ Refresh token rotation: Each refresh returns a new refresh token, limiting attack window
✅ SSR-safe token access: typeof window checks prevent server-side leaks
✅ Separate access/refresh tokens: Limits exposure if access token is compromised
Future Enhancements
⚠️ HTTPS only: Ensure tokens only sent over HTTPS in production ⚠️ Token expiry validation: Add JWT decode to check expiry client-side before API call ⚠️ CSRF protection: Add CSRF tokens if switching to cookie-based storage ⚠️ Rate limiting: Backend should rate-limit OTP requests to prevent abuse
Testing Strategy (TODO)
Unit Tests
- Test token CRUD operations in
tokens.ts(localStorage mocking) - Test Zustand store actions (setUser, clearUser, logout, restoreSession)
- Test mock API functions return correct shapes
Integration Tests
- Test full login flow (phone → OTP → profile → dashboard)
- Test session restoration on page reload
- Test logout clears state and redirects
E2E Tests (Playwright/Cypress)
- Test user can log in with valid OTP
- Test invalid OTP shows error message
- Test protected routes redirect to login when not authenticated
- Test logout clears session and redirects
Troubleshooting
”User not authenticated” on page reload
Symptom: User logs in successfully but loses auth state on refresh.
Cause: restoreSession() not being called on app mount.
Fix: Ensure SessionProvider wraps app in layout.tsx:
<SessionProvider>{children}</SessionProvider>Tokens not persisting
Symptom: getRefreshToken() returns null after setting it.
Cause: localStorage not available (SSR) or browser privacy settings.
Fix: Check typeof window !== "undefined" before accessing localStorage. Test in incognito mode to verify privacy settings.
Mock API not returning data
Symptom: verifyOTP() throws error or returns undefined.
Cause: Mock delay not awaited or response shape mismatch.
Fix: Ensure await is used when calling service functions. Check console for mock response logs.
API Contract Reference
POST /api/auth/verify
Request:
{
"phone": "+919876543210",
"otp": "123456"
}Response:
{
"success": true,
"user": {
"id": "user-123",
"phone": "+919876543210",
"name": "John Doe",
"email": "john@example.com"
},
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}POST /api/auth/refresh
Request:
{
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}Response:
{
"success": true,
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs..." // Rotated token
}POST /api/auth/logout
Request:
{
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}Response:
{
"success": true,
"message": "Logged out successfully"
}Migration Checklist
Before replacing mocks with production API:
- Backend API endpoints deployed and tested
- Frontend Axios client configured with base URL (
NEXT_PUBLIC_API_URL) - Error handling implemented for 401, 403, 500 errors
- Token refresh interceptor added to Axios instance
- Route guards implemented for protected pages
- Toast notifications replace console.log statements
- Remove all mock delays and
TODOcomments - Update types to match backend response shapes
- Test full auth flow end-to-end
- Verify session restoration works on page reload
- Test logout clears tokens and redirects correctly
Additional Resources
- Zustand Documentation: https://docs.pmnd.rs/zustand/getting-started/introduction
- JWT Best Practices: https://auth0.com/docs/secure/tokens/json-web-tokens
- Next.js Authentication: https://nextjs.org/docs/pages/building-your-application/routing/authenticating
- React Hook Form: https://react-hook-form.com/get-started