- Overview
- User Registration
- User Login
- JWT Token Management
- Token Refresh Mechanism
- Protected Routes
- Frontend Integration
- Security Considerations
FinAIlytics implements a secure JWT-based authentication system with:
- Email/password registration and login
- Access tokens (15-minute expiry)
- Refresh tokens (7-day expiry)
- HTTP-only cookies for secure token storage
- Automatic token refresh on expiration
User submits registration form
↓
Validate input (Zod schema)
↓
Check if email already exists
↓
Create new user with hashed password (bcrypt)
↓
Create default report settings
↓
Return user object (password omitted)
POST /api/auth/register
Request:
{
"name": "John Doe",
"email": "john@example.com",
"password": "password123"
}Validation Rules (Zod):
name: string, required, 1-255 charactersemail: valid email format, requiredpassword: string, minimum 4 characters
Response (201):
{
"message": "User registered successfully",
"data": {
"user": {
"_id": "507f1f77bcf86cd799439011",
"name": "John Doe",
"email": "john@example.com",
"profilePicture": null,
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
}
}
}Registration uses MongoDB transactions to ensure data integrity:
await session.withTransaction(async () => {
// Create user
await newUser.save({ session });
// Create report settings
await reportSetting.save({ session });
});Fallback: If MongoDB is not a replica set, it automatically falls back to non-transactional writes.
User submits credentials
↓
Validate input (Zod schema)
↓
Find user by email (lowercase)
↓
Compare password with bcrypt
↓
Generate JWT access token
↓
Return user + token + report settings
POST /api/auth/login
Request:
{
"email": "john@example.com",
"password": "password123"
}Response (200):
{
"message": "User logged in successfully",
"user": {
"_id": "507f1f77bcf86cd799439011",
"name": "John Doe",
"email": "john@example.com",
"profilePicture": null,
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
},
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"expiresAt": "2025-01-01T00:15:00.000Z",
"reportSetting": {
"_id": "...",
"frequency": "MONTHLY",
"isEnabled": true
}
}| Error | Status | Cause |
|---|---|---|
Email/password not found |
404 | User doesn't exist or wrong password |
User already exists |
401 | Duplicate email on register |
// utils/jwt.ts
export const signJwtToken = (payload: object) => {
const token = jwt.sign(payload, Env.JWT_SECRET, {
expiresIn: Env.JWT_EXPIRES_IN, // 15m
});
const decoded = jwt.decode(token) as JwtPayload;
return {
token,
expiresAt: new Date(decoded.exp! * 1000),
};
};Access Token:
- Algorithm: HS256
- Payload:
{ userId: string, iat: number, exp: number } - Expiry: 15 minutes
// config/passport.config.ts
passport.use(
'jwt',
new JwtStrategy(opts, async (payload, done) => {
try {
const user = await UserModel.findById(payload.userId);
if (!user) return done(null, false);
return done(null, user);
} catch (error) {
return done(error, false);
}
})
);
export const passportAuthenticateJwt = passport.authenticate('jwt', {
session: false,
});The frontend uses a custom hook to monitor token expiration:
// hooks/use-auth-expiration.ts
export const useAuthExpiration = () => {
const { expiresAt, logout } = useSelector((state: RootState) => state.auth);
useEffect(() => {
if (!expiresAt) return;
const checkExpiration = () => {
if (new Date() >= new Date(expiresAt)) {
logout(); // or trigger refresh
}
};
const interval = setInterval(checkExpiration, 60000); // Check every minute
return () => clearInterval(interval);
}, [expiresAt, logout]);
};All API requests automatically include the access token:
// app/api-client.ts
const baseQuery = fetchBaseQuery({
baseUrl: API_BASE,
credentials: "include", // Allows cookies
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.accessToken;
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
return headers;
},
});All protected routes use passportAuthenticateJwt middleware:
// index.ts
app.use(`${BASE_PATH}/user`, passportAuthenticateJwt, userRoutes);
app.use(`${BASE_PATH}/transaction`, passportAuthenticateJwt, transactionRoutes);
app.use(`${BASE_PATH}/report`, passportAuthenticateJwt, reportRoutes);
app.use(`${BASE_PATH}/analytics`, passportAuthenticateJwt, analyticsRoutes);// routes/index.tsx
<Route element={<ProtectedRoute />}>
<Route element={<AppLayout />}>
{protectedRoutePaths.map((route) => (
<Route key={route.path} path={route.path} element={route.element} />
))}
</Route>
</Route>// routes/protectedRoute.tsx
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = useSelector((state: RootState) => state.auth);
if (!isAuthenticated) {
return <Navigate to="/signin" replace />;
}
return <>{children}</>;
};// features/auth/authSlice.ts
const authSlice = createSlice({
name: 'auth',
initialState: {
user: null,
accessToken: null,
expiresAt: null,
isAuthenticated: false,
reportSetting: null,
},
reducers: {
setCredentials: (state, action) => {
state.user = action.payload.user;
state.accessToken = action.payload.accessToken;
state.expiresAt = action.payload.expiresAt;
state.reportSetting = action.payload.reportSetting;
state.isAuthenticated = true;
},
logout: (state) => {
state.user = null;
state.accessToken = null;
state.expiresAt = null;
state.isAuthenticated = false;
state.reportSetting = null;
},
},
});// features/auth/authAPI.ts
export const authApi = apiClient.injectEndpoints({
endpoints: (builder) => ({
login: builder.mutation({
query: (credentials) => ({
url: '/auth/login',
method: 'POST',
body: credentials,
}),
}),
register: builder.mutation({
query: (credentials) => ({
url: '/auth/register',
method: 'POST',
body: credentials,
}),
}),
}),
});- User enters credentials
- Call
useLoginMutation - On success, dispatch
setCredentialswith response data - Redirect to dashboard
- Hashing: bcrypt with salt
- Storage: Never stored in plain text
- Comparison: Uses bcrypt.compare()
// models/user.model.ts
userSchema.pre('save', async function (next) {
if (this.isModified('password')) {
this.password = await hashValue(this.password);
}
next();
});- Short-lived access tokens (15 min) - Limits damage if compromised
- Long-lived refresh tokens (7 days) - Stored in HTTP-only cookie
- Algorithm: HS256 - Symmetric signing
app.use(cors({
origin: Env.FRONTEND_ORIGIN, // Only allowed origin
credentials: true, // Allow cookies
}));- Environment variables for all secrets
- HTTPS in production
- Secure cookies settings
- Input validation with Zod
- Rate limiting recommended for production
┌──────────────────────────────────────────────────────────────────────┐
│ REGISTRATION FLOW │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ Client Server Database │
│ │ │ │ │
│ ├─── POST /auth/register ──► │ │
│ │ │ │ │
│ │ Validate input │ │
│ │ │ │ │
│ │ Check existing │ │
│ │ │ │ │
│ │ Hash password (bcrypt) │ │
│ │ │ │ │
│ │ Create user ──────────────► │ │
│ │ │ │ │
│ │ Create report settings ──────────► │
│ │ │ │ │
│ │◄──── 201 Created ───────┤ │ │
│ │ │ │ │
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ LOGIN FLOW │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ Client Server Database │
│ │ │ │ │
│ ├─── POST /auth/login ────► │ │
│ │ │ │ │
│ │ Validate input │ │
│ │ │ │ │
│ │ Find user by email ─────────────►│ │
│ │ │ │ │
│ │ Compare password ───────────────►│ │
│ │ │ │ │
│ │ Generate JWT token │ │
│ │ │ │ │
│ │◄──── 200 OK + token ────┤ │ │
│ │ │ │ │
│ │ Store token in Redux │ │
│ │ │ │ │
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ PROTECTED REQUEST FLOW │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ Client Server Database │
│ │ │ │ │
│ ├─── GET /transaction/all ──► │ │
│ │ (with Bearer token) │ │ │
│ │ │ │ │
│ │ Verify JWT token │ │
│ │ │ │ │
│ │ Extract user ID │ │
│ │ │ │ │
│ │ Query transactions ─────────────►│ │
│ │ │ │ │
│ │◄──── 200 OK + data ─────┤ │ │
│ │ │ │ │
└──────────────────────────────────────────────────────────────────────┘
# Backend
JWT_SECRET=your_jwt_secret_key_min_32_chars
JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=your_jwt_refresh_secret_key
JWT_REFRESH_EXPIRES_IN=7d
# Frontend
VITE_API_URL=http://localhost:8000/api- Email addresses are normalized to lowercase
- Passwords must be at least 4 characters
- Tokens are validated on every protected request
- Failed authentication returns generic "Email/password not found" to prevent user enumeration
- Session data is not stored on server (stateless JWT)