DEV Community

Cover image for How I Built a Full-Stack Auth System with JWT, Email Verification & Admin Panel (MERN + TypeScript)
Favour Omotosho
Favour Omotosho

Posted on

How I Built a Full-Stack Auth System with JWT, Email Verification & Admin Panel (MERN + TypeScript)

Most authentication tutorials show you basic login/logout and call it done. But what about email verification? Password reset? Token refresh? Admin panels? What about actually deploying it?

This is different.

I spent the last week building a production-ready authentication system that handles all these features—the kind you'd actually deploy to real users. And I'm going to show you exactly how I did it, step-by-step, with all the gotchas and "aha!" moments included.

What We're Building

By the end of this post, you'll understand how to build:

  • 🔐 JWT-based auth with access + refresh tokens (the right way)
  • ✉️ Email verification (because anyone can type fake@email.com)
  • 🔄 Password reset (your users will forget their passwords, trust me)
  • 👑 Admin panel with user management
  • 🔒 Role-based access control (users vs admins)
  • 🚀 Deployed and actually working (not just localhost:3000)

Live Demo: frontend URL

GitHub: Github Repo

Let's dive in! 🚀


Table of Contents

  1. Why This Architecture?
  2. Tech Stack Breakdown
  3. Backend: The Foundation
  4. Access + Refresh Tokens Explained
  5. Email Verification System
  6. Frontend: React + TypeScript
  7. Auto Token Refresh (The Magic)
  8. Admin Panel & RBAC
  9. Deployment Guide
  10. Common Pitfalls (And How I Fixed Them)
  11. What I Learned
  12. Next Steps

Why This Architecture?

The Problem with Basic Auth

When I started learning auth, every tutorial showed me this:

// "Complete" auth tutorial:
app.post('/login', (req, res) => {
  const token = jwt.sign({ userId: user._id }, SECRET, { expiresIn: '7d' });
  res.json({ token });
});
Enter fullscreen mode Exit fullscreen mode

And I thought: "Cool, I'm done!"

But then reality hit:

  1. Security issue: If someone steals that token, they have 7 days of access
  2. No logout: The token is valid until it expires (logout button is a lie)
  3. No email verification: user@fake.com is apparently valid
  4. No password reset: Users are stuck if they forget passwords
  5. No admin features: How do I manage users?

So I decided to build it properly. Here's what I learned.

The Solution: Access + Refresh Tokens

Instead of one long-lived token, I use two tokens:

Access Token (15 minutes):

  • Used for API requests
  • Stored in localStorage
  • Short-lived = less risk if stolen

Refresh Token (7 days):

  • Only used to get new access tokens
  • Stored in httpOnly cookie (JavaScript can't access it)
  • Can be invalidated in the database (true logout!)

Why this is genius:

Imagine you're at a hotel. The access token is your room key card. You use it all day to enter your room. But it expires every night at midnight.

The refresh token is your ID at the front desk. When your room key expires, you show your ID and get a new key. If you check out (logout), the front desk invalidates your ID—you can't get new keys anymore.

Visual representation:

User Login
    ↓
Backend generates:
├── Access Token (15min) → Sent in response body
└── Refresh Token (7d)   → Sent in httpOnly cookie
    ↓
User makes API call with Access Token
    ↓
Token expires after 15 min
    ↓
Frontend automatically calls /refresh endpoint
    ↓
Backend checks Refresh Token (in cookie)
    ↓
Backend sends new Access Token
    ↓
Frontend retries original request
    ↓
User never even noticed! ✨
Enter fullscreen mode Exit fullscreen mode

This gives us:

  • Security: Short-lived access tokens
  • Great UX: Auto token refresh (seamless)
  • True logout: Can invalidate refresh tokens in DB

Alright, let's build this thing!


Tech Stack Breakdown

Here's what I used and why:

Backend

Node.js + Express + TypeScript

  • Why TypeScript? It caught dozens of bugs before runtime. Seriously, once you try it, you can't go back.

MongoDB + Mongoose

  • NoSQL because our user schema is simple
  • Mongoose makes it feel like ORM magic

JWT (jsonwebtoken)

  • Industry standard for tokens
  • Stateless (no session storage needed)

Nodemailer

  • Send verification emails
  • Works with Gmail, SendGrid, etc.

bcrypt

  • Hash passwords (NEVER store plain text!)
  • Salting built-in

Frontend

React 18 + TypeScript

  • Component-based UI
  • TypeScript for type safety on frontend too

Vite

  • 10x faster than Create React App
  • Hot reload is instant ⚡

Tailwind CSS

  • Utility-first CSS
  • Build UIs crazy fast

shadcn/ui

  • Beautiful, accessible components
  • Copy code directly into your project (you own it!)

React Hook Form + Zod

  • Form state management
  • Schema validation
  • Perfect together

Axios

  • HTTP client
  • Interceptors for auto token refresh (we'll get to this!)

Why I Chose TypeScript

Let me show you a real example from my code:

// JavaScript - This compiles fine but crashes at runtime:
const user = { name: "John", email: "john@example.com" };
console.log(user.nmae); // Typo! Returns undefined, no error

// TypeScript - Catches it immediately:
const user: User = { name: "John", email: "john@example.com" };
console.log(user.nmae); 
// ❌ Error: Property 'nmae' does not exist on type 'User'
Enter fullscreen mode Exit fullscreen mode

TypeScript saved me hours of debugging. Worth the initial learning curve!


Backend: The Foundation

Project Structure

I spent time thinking about structure because messy code = future headaches.

server/
├── src/
│   ├── config/
│   │   ├── config.ts        # All env variables in one place
│   │   └── db.ts            # MongoDB connection
│   ├── models/
│   │   └── User.ts          # User schema
│   ├── controllers/
│   │   ├── authController.ts      # Login, register, etc.
│   │   ├── passwordController.ts  # Password reset
│   │   └── verificationController.ts  # Email verification
│   ├── middleware/
│   │   ├── auth.ts          # JWT verification
│   │   ├── checkSession.ts  # Session validation
|   |   ├── rateLimiters.ts  # Api Rate Limiter
│   │   └── errorHandler.ts  # Centralized error handling
│   ├── routes/
│   │   ├── auth.ts
│   │   └── admin.ts
│   ├── utils/
│   │   ├── token.ts         # Token generation
│   │   ├── email.ts         # Email utilities
│   │   └── response.ts      # Response formatters
│   └── server.ts
Enter fullscreen mode Exit fullscreen mode

Why this structure?

  • Separation of concerns: Each file has ONE job
  • Easy to find things: Need token logic? Check utils/token.ts
  • Scalable: Adding features doesn't mess up existing code

The User Model

Here's where TypeScript shines:

// src/models/User.ts
import mongoose, { Document, Schema } from 'mongoose';

export interface IUserDocument extends Document {
  name: string;
  email: string;
  password: string;
  role: 'user' | 'admin';  // ← Union type (can ONLY be these two)
  isVerified: boolean;
  refreshToken?: string;   // ← Optional (? mark)
  resetPasswordToken?: string;
  resetPasswordExpire?: Date;
  verificationToken?: string;
  verificationTokenExpire?: Date;
  createdAt: Date;
}

const UserSchema: Schema = new Schema({
  name: {
    type: String,
    required: [true, 'Please add a name'],
    trim: true
  },
  email: {
    type: String,
    required: [true, 'Please add an email'],
    unique: true,
    lowercase: true,
    match: [/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/, 'Invalid email']
  },
  password: {
    type: String,
    required: [true, 'Please add a password'],
    minlength: [8, 'Password must be at least 8 characters']
  },
  role: {
    type: String,
    enum: ['user', 'admin'],
    default: 'user'
  },
  isVerified: {
    type: Boolean,
    default: false
  },
  refreshToken: {
    type: String,
    default: null,
  },
  resetPasswordToken: {
    type: String,
    default: null,
  },
  resetPasswordExpire: {
    type: Date,
    default: null,
  },
  isVerified: {
    type: Boolean,
    default: false,
  },
  verificationToken: {
    type: String,
    default: null,
  },
  verificationTokenExpire: {
    type: Date,
    default: null,
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

export default mongoose.model<IUserDocument>('User', UserSchema);
Enter fullscreen mode Exit fullscreen mode

Key decisions:

  1. role: 'user' | 'admin' - TypeScript won't let me accidentally type 'superadmin' or 'moderator'. This prevents bugs.

  2. isVerified: boolean - Prevents unverified users from logging in (we'll check this)

  3. refreshToken?: string - Store current refresh token so we can invalidate it on logout

  4. Email validation regex - Catches invalid emails at database level


Access + Refresh Tokens

This is the core of secure authentication. Let me break it down completely.

Token Generation Utilities

First, I created utility functions:

// src/utils/token.ts
import jwt, { Secret } from 'jsonwebtoken';
import { config } from '../config/config';

export const generateAccessToken = (userId: string): string => {
  return jwt.sign(
    { id: userId }, 
    config.jwtSecret as Secret, 
    { expiresIn: '15m' }  // ← Short-lived!
  );
};

export const generateRefreshToken = (userId: string): string => {
  return jwt.sign(
    { id: userId }, 
    config.jwtRefreshSecret as Secret, 
    { expiresIn: '7d' }  // ← Long-lived
  );
};

export const generateTokens = (userId: string) => {
  return {
    accessToken: generateAccessToken(userId),
    refreshToken: generateRefreshToken(userId)
  };
};
Enter fullscreen mode Exit fullscreen mode

Why separate secrets?

If someone somehow gets your jwtSecret, they can only create access tokens (15min expiry). Your refresh tokens are safe with a different secret!

The Login Flow

Here's the complete login controller:

// src/controllers/authController.ts
export const login = async (req: Request, res: Response): Promise<void> => {
  try {
    const { email, password } = req.body;

    // 1. Find user
    const user = await User.findOne({ email });
    if (!user) {
      res.status(400).json({ message: 'Invalid credentials' });
      return;
    }

    // 2. Check if email is verified
    if (!user.isVerified) {
      res.status(403).json({ 
        message: 'Please verify your email before logging in',
        requiresVerification: true 
      });
      return;
    }

    // 3. Verify password
    const isMatch = await bcrypt.compare(password, user.password);
    if (!isMatch) {
      res.status(400).json({ message: 'Invalid credentials' });
      return;
    }

    // 4. Generate BOTH tokens
    const { accessToken, refreshToken } = generateTokens(user._id.toString());

    // 5. Store refresh token in database (for invalidation later)
    user.refreshToken = refreshToken;
    await user.save();

    // 6. Send refresh token as httpOnly cookie
    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,        // JavaScript can't access
      secure: process.env.NODE_ENV === 'production',  // HTTPS only in prod
      sameSite: 'strict',    // CSRF protection
      maxAge: 7 * 24 * 60 * 60 * 1000  // 7 days
    });

    // 7. Send access token in response body
    res.json({
      success: true,
      token: accessToken,  // ← Frontend stores this
      user: {
        id: user._id,
        name: user.name,
        email: user.email,
        role: user.role,
        isVerified: user.isVerified
      }
    });
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
};
Enter fullscreen mode Exit fullscreen mode

Let me explain each step:

Step 1-3: Standard validation (user exists, verified, password correct)

Step 4: Generate BOTH tokens at once

Step 5: Critical! Store refresh token in database. This lets us invalidate it later when user logs out.

Step 6: The magic—send refresh token as httpOnly cookie:

res.cookie('refreshToken', refreshToken, {
  httpOnly: true,  // ← THIS is key!
})
Enter fullscreen mode Exit fullscreen mode

Why httpOnly?

Regular cookies can be accessed by JavaScript:

document.cookie // Can read it!
Enter fullscreen mode Exit fullscreen mode

httpOnly cookies cannot:

document.cookie // Refresh token is NOT here!
Enter fullscreen mode Exit fullscreen mode

This prevents XSS attacks. If malicious JavaScript runs on your site, it can't steal the refresh token!

Step 7: Send access token in response body. Frontend will store this in localStorage (yes, localStorage is fine for access tokens because they're short-lived).

The Refresh Endpoint

When the access token expires, the frontend calls this:

export const refreshToken = async (req: Request, res: Response): Promise<void> => {
  try {
    // 1. Get refresh token from cookie (sent automatically!)
    const refreshToken = req.cookies.refreshToken;

    if (!refreshToken) {
      res.status(401).json({ message: 'Refresh token not found' });
      return;
    }

    // 2. Verify it's a valid JWT
    let decoded;
    try {
      decoded = jwt.verify(refreshToken, config.jwtRefreshSecret as Secret);
    } catch (error) {
      res.status(401).json({ message: 'Invalid refresh token' });
      return;
    }

    // 3. Check if user still exists and token matches database
    const user = await User.findById(decoded.id);
    if (!user || user.refreshToken !== refreshToken) {
      res.status(401).json({ message: 'Invalid refresh token' });
      return;
    }

    // 4. Generate NEW tokens (token rotation!)
    const newAccessToken = generateAccessToken(user._id.toString());
    const newRefreshToken = generateRefreshToken(user._id.toString());

    // 5. Update refresh token in database
    user.refreshToken = newRefreshToken;
    await user.save();

    // 6. Send new refresh token as cookie
    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000
    });

    // 7. Send new access token
    res.json({
      success: true,
      token: newAccessToken
    });
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
};
Enter fullscreen mode Exit fullscreen mode

Key points:

Step 1: Cookie is sent automatically by the browser! Frontend doesn't need to do anything special.

Step 3: We check that the refresh token in the database matches. This is how we can invalidate tokens!

Step 4-6: Token rotation—we generate NEW tokens and invalidate the old one. This limits the damage if a refresh token is somehow compromised.

The Logout Flow

Now here's why this whole system is worth it:

export const logout = async (req: Request, res: Response): Promise<void> => {
  try {
    if (!req.user) {
      res.status(401).json({ message: 'Not authorized' });
      return;
    }

    // Remove refresh token from database
    await User.findByIdAndUpdate(req.user.id, { refreshToken: null });

    // Clear the cookie
    res.clearCookie('refreshToken');

    res.json({
      success: true,
      message: 'Logged out successfully'
    });
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
};
Enter fullscreen mode Exit fullscreen mode

What happens:

  1. Remove refresh token from database
  2. Clear the cookie
  3. User is truly logged out

Even if they still have an access token (valid for max 15 more minutes), they can't get a new one because the refresh token is gone!

This is TRUE logout. Not fake "delete token from localStorage" logout.


Email Verification System

Okay, so users can register. But how do we know billgates@microsoft.com is actually Bill Gates?

We don't! But we can verify they have access to that email.

The Registration Flow

export const register = async (req: Request, res: Response): Promise<void> => {
  try {
    const { name, email, password } = req.body;

    // 1. Check if user already exists
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      res.status(400).json({ message: 'User already exists' });
      return;
    }

    // 2. Hash password
    const salt = await bcrypt.genSalt(10);
    const hashedPassword = await bcrypt.hash(password, salt);

    // 3. Generate verification token (expires in 24 hours)
    const verificationToken = generateVerificationToken(email);

    // 4. Create user (UNVERIFIED)
    const user = await User.create({
      name,
      email,
      password: hashedPassword,
      isVerified: false,  // ← Important!
      verificationToken,
      verificationTokenExpire: new Date(Date.now() + 24 * 60 * 60 * 1000)
    });

    // 5. Send verification email
    try {
      await sendVerificationEmail(email, verificationToken);
    } catch (error) {
      // Email failed, but user is created
      console.error('Verification email error:', error);
    }

    // 6. Response (DON'T log them in yet!)
    res.status(201).json({
      success: true,
      message: 'Registration successful! Please check your email to verify your account.',
      user: {
        id: user._id,
        name: user.name,
        email: user.email,
        isVerified: false
      }
    });
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
};
Enter fullscreen mode Exit fullscreen mode

Key points:

Step 4: User is created with isVerified: false. They exist in the database but can't log in yet.

Step 5: Send email with verification link. The link looks like:

https://yourapp.com/verify-email?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Enter fullscreen mode Exit fullscreen mode

Step 6: We DON'T log them in. They need to verify first!

The Verification Endpoint

When user clicks the link in their email:

export const verifyEmail = async (req: Request, res: Response): Promise<void> => {
  try {
    const { token } = req.body;

    // 1. Verify the JWT token
    let decoded;
    try {
      decoded = verifyVerificationToken(token);
    } catch (error) {
      res.status(400).json({ message: 'Invalid or expired verification token' });
      return;
    }

    // 2. Find user with this token (not expired)
    const user = await User.findOne({
      email: decoded.email,
      verificationToken: token,
      verificationTokenExpire: { $gt: Date.now() }  // Not expired
    });

    if (!user) {
      res.status(400).json({ message: 'Invalid or expired verification token' });
      return;
    }

    // 3. Check if already verified
    if (user.isVerified) {
      res.status(400).json({ message: 'Email already verified' });
      return;
    }

    // 4. Mark as verified!
    user.isVerified = true;
    user.verificationToken = undefined;
    user.verificationTokenExpire = undefined;
    await user.save();

    // 5. Send welcome email (optional but nice!)
    try {
      await sendWelcomeEmail(user.email, user.name);
    } catch (error) {
      // Don't fail if welcome email fails
      console.error('Welcome email error:', error);
    }

    res.json({
      success: true,
      message: 'Email verified successfully! You can now login.'
    });
  } catch (error) {
    res.status(500).json({ message: 'Server error' });
  }
};
Enter fullscreen mode Exit fullscreen mode

The frontend flow:

  1. User clicks email link → Redirected to /verify-email?token=...
  2. Frontend extracts token from URL
  3. Frontend sends POST /api/auth/verify-email with token
  4. Backend verifies and updates user

5. Frontend shows success message and redirects to login


Frontend: React + TypeScript

The backend is done. Now let's build a beautiful frontend that actually uses all these features!

Project Structure

I organized the frontend like this:

client/
├── src/
│   ├── components/
│   │   ├── ui/              # shadcn/ui components (auto-generated)
│   │   ├── auth/
│   │   │   ├── ProtectedRoute.tsx  # Requires login
│   │   │   └── AdminRoute.tsx      # Requires admin role
│   │   └── layout/
│   │       ├── Layout.tsx
│   │       └── Navbar.tsx
│   ├── pages/
│   │   ├── auth/
│   │   │   ├── Login.tsx
│   │   │   ├── Register.tsx
│   │   │   ├── VerifyEmail.tsx
│   │   │   ├── ForgotPassword.tsx
│   │   │   └── ResetPassword.tsx
│   │   ├── Dashboard.tsx
│   │   └── admin/
│   │       ├── AdminDashboard.tsx
│   │       └── UserManagement.tsx
│   ├── context/
│   │   └── AuthContext.tsx   # Global auth state
│   ├── services/
│   │   ├── api.ts            # Axios instance
│   │   ├── auth.service.ts   # Auth API calls
│   │   └── admin.service.ts  # Admin API calls
│   ├── types/
│   │   └── index.ts          # TypeScript types
│   ├── utils/
│   │   └── errorHandler.ts   # Error handling
│   └── App.tsx
Enter fullscreen mode Exit fullscreen mode

TypeScript Types (Frontend & Backend Match!)

One of the biggest advantages of TypeScript is that frontend and backend can share type definitions:

// src/types/index.ts
export interface User {
  id: string;
  name: string;
  email: string;
  role: 'user' | 'admin';
  isVerified: boolean;
}

export interface LoginCredentials {
  email: string;
  password: string;
}

export interface AuthResponse {
  success: boolean;
  token: string;
  user: User;
}
Enter fullscreen mode Exit fullscreen mode

Why this matters:

// Without types (JavaScript):
const user = await login(email, password);
console.log(user.nmae);  // Typo! No error until runtime

// With types (TypeScript):
const user: AuthResponse = await login(email, password);
console.log(user.nmae);  
// ❌ Error immediately: Property 'nmae' does not exist
Enter fullscreen mode Exit fullscreen mode

The Auth Context

I use React Context to share auth state across the entire app:

// src/context/AuthContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { User } from '../types';
import authService from '../services/auth.service';

interface AuthContextType {
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  loadUser: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  const isAuthenticated = !!user;

  // Load user on mount (if token exists)
  useEffect(() => {
    loadUser();
  }, []);

  const loadUser = async () => {
    try {
      if (authService.isLoggedIn()) {
        const userData = await authService.getCurrentUser();
        setUser(userData);
      }
    } catch (error) {
      localStorage.removeItem('token');
    } finally {
      setIsLoading(false);
    }
  };

  const login = async (email: string, password: string) => {
    const response = await authService.login({ email, password });
    setUser(response.user);
  };

  const logout = async () => {
    await authService.logout();
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ 
      user, 
      isAuthenticated, 
      isLoading, 
      login, 
      logout, 
      loadUser 
    }}>
      {children}
    </AuthContext.Provider>
  );
}

// Custom hook for easy access
export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}
Enter fullscreen mode Exit fullscreen mode

How to use it anywhere:

// In any component:
import { useAuth } from '@/hooks/useAuth';

function Dashboard() {
  const { user, logout } = useAuth();

  return (
    <div>
      <h1>Welcome, {user?.name}!</h1>
      <button onClick={logout}>Logout</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Clean, right? 🎯

Forms with React Hook Form + Zod

Instead of managing form state manually, I use React Hook Form with Zod validation:

// src/pages/auth/Login.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';

// Define validation schema
const loginSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(6, 'Password must be at least 6 characters'),
});

// TypeScript type from schema (automatic!)
type LoginFormData = z.infer<typeof loginSchema>;

export default function Login() {
  const { login } = useAuth();
  const navigate = useNavigate();

  // Initialize form with Zod validation
  const form = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  });

  const onSubmit = async (data: LoginFormData) => {
    try {
      await login(data.email, data.password);
      toast.success('Login successful!');
      navigate('/dashboard');
    } catch (error) {
      toast.error('Login failed');
    }
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input type="email" placeholder="john@example.com" {...field} />
              </FormControl>
              <FormMessage />  {/* Shows validation errors automatically! */}
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Password</FormLabel>
              <FormControl>
                <Input type="password" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit">Login</Button>
      </form>
    </Form>
  );
}
Enter fullscreen mode Exit fullscreen mode

What's happening:

  1. Zod schema defines validation rules
  2. z.infer creates TypeScript type automatically
  3. FormField handles state, validation, and errors
  4. FormMessage shows errors automatically

User experience:

  • Type invalid email → Error shows instantly
  • Password too short → Error shows instantly
  • All validation happens before API call

Authentication


Auto Token Refresh (The Magic)

This is where it all comes together. Remember how the access token expires every 15 minutes? The user should never notice.

Axios Interceptor

I set up Axios to automatically refresh tokens:

// src/services/api.ts
import axios, { AxiosInstance, AxiosError } from 'axios';

const api: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  withCredentials: true,  // Important! Sends cookies
});

// REQUEST INTERCEPTOR: Add token to every request
api.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token');
    if (token && config.headers) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// RESPONSE INTERCEPTOR: Auto-refresh on 401
api.interceptors.response.use(
  (response) => response,  // If success, just return

  async (error: AxiosError) => {
    const originalRequest = error.config;

    // URLs that should NOT trigger refresh
    const excludedUrls = ['/auth/login', '/auth/register', '/auth/refresh'];
    const shouldExclude = excludedUrls.some(url => 
      originalRequest?.url?.includes(url)
    );

    // If 401 and not excluded
    if (error.response?.status === 401 && !shouldExclude && originalRequest) {
      try {
        // Call refresh endpoint (refresh token sent automatically in cookie!)
        const { data } = await axios.post(
          `${import.meta.env.VITE_API_URL}/auth/refresh`,
          {},
          { withCredentials: true }
        );

        // Save new access token
        localStorage.setItem('token', data.token);

        // Retry original request with new token
        if (originalRequest.headers) {
          originalRequest.headers.Authorization = `Bearer ${data.token}`;
        }
        return api(originalRequest);
      } catch (refreshError) {
        // Refresh failed - logout user
        localStorage.removeItem('token');
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

export default api;
Enter fullscreen mode Exit fullscreen mode

Here's what happens:

  1. User makes API call → Access token expired → 401 error
  2. Interceptor catches 401
  3. Calls /auth/refresh (refresh token sent automatically via cookie)
  4. Backend sends new access token
  5. Interceptor saves new token
  6. Retries original request with new token
  7. Original request succeeds
  8. User never noticed anything!

Why excluded URLs?

We exclude /auth/login and /auth/register because:

  • 401 on login = wrong password (NOT expired token)
  • We don't want to try refreshing when there's no token yet

The user experience:

User working on dashboard for 20 minutes
    ↓
Access token expires (minute 15)
    ↓
User clicks "View Profile"
    ↓
Request fails with 401
    ↓
Interceptor auto-refreshes token
    ↓
Request retries and succeeds
    ↓
Profile loads
    ↓
User has no idea anything happened! 🎉
Enter fullscreen mode Exit fullscreen mode

This is way better UX than forcing users to re-login every 15 minutes!


Admin Panel & RBAC

Role-Based Access Control (RBAC) means users can only access what they're allowed to.

Protected Route Component

First, I created a component that requires authentication:

// src/components/auth/ProtectedRoute.tsx
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';

export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const { isAuthenticated, isLoading } = useAuth();

  // Still loading - show spinner
  if (isLoading) {
    return <div>Loading...</div>;
  }

  // Not authenticated - redirect to login
  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }

  // Authenticated - show the page
  return <>{children}</>;
}
Enter fullscreen mode Exit fullscreen mode

Usage:

<Route
  path="/dashboard"
  element={
    <ProtectedRoute>
      <Dashboard />
    </ProtectedRoute>
  }
/>
Enter fullscreen mode Exit fullscreen mode

Now if someone tries to access /dashboard without logging in, they're redirected to /login.

Admin-Only Route

But what about admin-only pages?

// src/components/auth/AdminRoute.tsx
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';

export default function AdminRoute({ children }: { children: React.ReactNode }) {
  const { user, isAuthenticated, isLoading } = useAuth();

  if (isLoading) {
    return <div>Loading...</div>;
  }

  // Not authenticated - redirect to login
  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }

  // Not admin - redirect to dashboard (forbidden)
  if (user?.role !== 'admin') {
    return <Navigate to="/dashboard" replace />;
  }

  // Is admin - show the page
  return <>{children}</>;
}
Enter fullscreen mode Exit fullscreen mode

Usage:

<Route
  path="/admin"
  element={
    <AdminRoute>
      <AdminDashboard />
    </AdminRoute>
  }
/>
Enter fullscreen mode Exit fullscreen mode

Now regular users can't access admin pages! 🛡️

Admin Dashboard

The admin dashboard shows user statistics:

// src/pages/admin/AdminDashboard.tsx
export default function AdminDashboard() {
  const [stats, setStats] = useState(null);

  useEffect(() => {
    loadStats();
  }, []);

  const loadStats = async () => {
    const data = await adminService.getUserStats();
    setStats(data);
  };

  return (
    <div>
      <h1>Admin Dashboard</h1>

      <div className="grid grid-cols-4 gap-6">
        <StatCard 
          title="Total Users" 
          value={stats?.totalUsers} 
          icon={<Users />} 
        />
        <StatCard 
          title="Admins" 
          value={stats?.adminUsers} 
          icon={<Shield />} 
        />
        <StatCard 
          title="Regular Users" 
          value={stats?.regularUsers} 
          icon={<UserCheck />} 
        />
        <StatCard 
          title="Recent Sign-ups" 
          value={stats?.recentUsers} 
          icon={<TrendingUp />} 
        />
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Ui

User Management Table

The real power is managing users:

// src/pages/admin/UserManagement.tsx
export default function UserManagement() {
  const [users, setUsers] = useState([]);
  const [selectedUser, setSelectedUser] = useState(null);

  const handleUpdateRole = async (userId, newRole) => {
    await adminService.updateUserRole(userId, newRole);
    toast.success('Role updated!');
    loadUsers();  // Refresh list
  };

  const handleDeleteUser = async (userId) => {
    if (confirm('Are you sure?')) {
      await adminService.deleteUser(userId);
      toast.success('User deleted');
      loadUsers();
    }
  };

  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Name</TableHead>
          <TableHead>Email</TableHead>
          <TableHead>Role</TableHead>
          <TableHead>Status</TableHead>
          <TableHead>Actions</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {users.map(user => (
          <TableRow key={user.id}>
            <TableCell>{user.name}</TableCell>
            <TableCell>{user.email}</TableCell>
            <TableCell>
              <Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
                {user.role}
              </Badge>
            </TableCell>
            <TableCell>
              <Badge variant={user.isVerified ? 'default' : 'destructive'}>
                {user.isVerified ? 'Verified' : 'Unverified'}
              </Badge>
            </TableCell>
            <TableCell>
              <Button onClick={() => openRoleDialog(user)}>
                Change Role
              </Button>
              <Button onClick={() => handleDeleteUser(user.id)} variant="destructive">
                Delete
              </Button>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}
Enter fullscreen mode Exit fullscreen mode

User Table

Features:

  • ✅ Search/filter users
  • ✅ Change user roles (user ↔ admin)
  • ✅ Delete users
  • ✅ See verification status
  • ✅ Responsive design

Deployment Guide

Alright, time to ship this thing! 🚀

Backend Deployment (Railway/Render)

I chose Render because it's simple and has a free tier.

Steps:

  1. Push code to GitHub
   git add .
   git commit -m "Ready for deployment"
   git push origin main
Enter fullscreen mode Exit fullscreen mode
  1. Create Render project

    • Go to Render.app
    • "New Project" → "Deploy from GitHub"
    • Select your repo
  2. Add environment variables

   MONGO_URL=mongodb+srv://...
   JWT_SECRET=your-secret-here
   JWT_REFRESH_SECRET=your-other-secret-here
   NODE_ENV=production
   CLIENT_URL=https://your-frontend.vercel.app
Enter fullscreen mode Exit fullscreen mode
  1. Generate production secrets
   node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Enter fullscreen mode Exit fullscreen mode
  1. Deploy! Render auto-deploys from GitHub. Your API is live at:
   https://your-app.up.render.app
Enter fullscreen mode Exit fullscreen mode

MongoDB Atlas Setup:

  1. Go to mongodb.com/cloud/atlas
  2. Create cluster (free M0 tier)
  3. Create database user
  4. Whitelist Railway IPs (or allow all: 0.0.0.0/0)
  5. Copy connection string to MONGO_URL

Frontend Deployment (Vercel)

Steps:

  1. Add environment variable Create .env in client folder:
   VITE_API_URL=https://your-backend.railway.app/api
Enter fullscreen mode Exit fullscreen mode
  1. Push to GitHub
   git add .
   git commit -m "Add production API URL"
   git push
Enter fullscreen mode Exit fullscreen mode
  1. Deploy on Vercel
    • Go to vercel.com
    • "New Project" → Import from GitHub
    • Select your repo
    • Important: Set root directory to client
    • Add environment variable:
     VITE_API_URL=https://your-backend.railway.app/api
Enter fullscreen mode Exit fullscreen mode
  • Deploy!
  1. Your app is live!
   https://your-app.vercel.app
Enter fullscreen mode Exit fullscreen mode

Testing Production

Critical tests:

  1. ✅ Register new user
  2. ✅ Check email for verification (use real email or Ethereal)
  3. ✅ Verify email
  4. ✅ Login
  5. ✅ Navigate around (check cookies in DevTools)
  6. ✅ Wait 16 minutes and make a request (token should auto-refresh)
  7. ✅ Logout
  8. ✅ Try accessing dashboard (should redirect to login)

CORS Issues?

Make sure your backend allows your frontend:

// Backend server.ts
app.use(cors({
  origin: 'https://your-app.vercel.app',  // ← Your Vercel URL
  credentials: true  // ← Important for cookies!
}));
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls (And How I Fixed Them)

Let me save you some debugging time:

1. Cookies Not Sent with Requests

Problem: Refresh token not sent to backend

Symptoms:

POST /auth/refresh
Response: 401 "Refresh token not found"
Enter fullscreen mode Exit fullscreen mode

Solution:

Frontend:

axios.create({
  withCredentials: true  // ← Add this!
});
Enter fullscreen mode Exit fullscreen mode

Backend:

app.use(cors({
  credentials: true  // ← Add this!
}));
Enter fullscreen mode Exit fullscreen mode

2. Infinite Token Refresh Loop

Problem: Interceptor keeps trying to refresh

Symptoms: Console filled with /auth/refresh requests

Solution: Exclude auth endpoints from auto-refresh:

const excludedUrls = ['/auth/login', '/auth/register', '/auth/refresh'];
const shouldExclude = excludedUrls.some(url => originalRequest?.url?.includes(url));

if (error.response?.status === 401 && !shouldExclude) {
  // Only refresh if NOT an auth endpoint
}
Enter fullscreen mode Exit fullscreen mode

3. CORS Errors in Production

Problem: Requests blocked by CORS policy

Symptoms:

Access to XMLHttpRequest blocked by CORS policy
Enter fullscreen mode Exit fullscreen mode

Solution: Backend must allow your frontend origin:

app.use(cors({
  origin: process.env.CLIENT_URL,  // Not '*' in production!
  credentials: true
}));
Enter fullscreen mode Exit fullscreen mode

Environment variable:

CLIENT_URL=https://your-app.vercel.app
Enter fullscreen mode Exit fullscreen mode

4. Email Not Sending

Problem: Verification emails not arriving

Solution for development:

Use Ethereal Email (fake SMTP for testing):

// Create account at ethereal.email
const transporter = nodemailer.createTransport({
  host: 'smtp.ethereal.email',
  port: 587,
  auth: {
    user: 'your-ethereal-email@ethereal.email',
    pass: 'your-ethereal-password'
  }
});
Enter fullscreen mode Exit fullscreen mode

Emails don't actually send, but you get a preview URL in console!

Solution for production:

Use a real email service:

  • SendGrid (12,000 emails/month free)
  • Mailgun (5,000 emails/month free)
  • AWS SES (62,000 emails/month free)

5. TypeScript Build Errors

Problem: npm run build fails with type errors

Solution: Fix types before deploying!

# Check types locally
npm run type-check

# Fix all type errors before pushing
Enter fullscreen mode Exit fullscreen mode

Common fixes:

// Problem: Type 'string | undefined' not assignable
const url = process.env.VITE_API_URL;  // Could be undefined

// Solution: Provide fallback
const url = process.env.VITE_API_URL || 'http://localhost:5000/api';
Enter fullscreen mode Exit fullscreen mode

What I Learned

Building this was a journey. Here are my key takeaways:

1. TypeScript Is Worth It

Initial reaction: "Ugh, so much boilerplate!"

After building: "How did I ever work without this?!"

TypeScript caught dozens of bugs before runtime. The auto-complete in VS Code became my best friend. Yes, there's a learning curve, but it pays off fast.

My rule now: TypeScript for anything bigger than a todo app.

2. Security Is Layers

I learned that security isn't one thing—it's layers:

  • ✅ Password hashing (bcrypt)
  • ✅ httpOnly cookies (XSS protection)
  • ✅ Short-lived tokens (limit exposure)
  • ✅ Token rotation (limit reuse)
  • ✅ Email verification (confirm identity)
  • ✅ CORS (prevent unauthorized origins)
  • ✅ Input validation (prevent injection)

Each layer adds protection. None are perfect alone.

3. UX Matters More Than I Thought

Bad UX: "Your session expired. Please login again."

User: leaves site

Good UX: Token auto-refreshes silently

User: doesn't even notice

That interceptor took 30 minutes to build but completely changed the user experience.

4. Don't Reinvent Auth (But Learn It Once)

After building this, I understand why people use Auth0, Supabase, or Clerk.

BUT I'm glad I built it once because:

  • I understand how it works
  • I can debug issues
  • I can customize everything
  • I can explain it in interviews

My advice: Build it once to learn. Then use a service for production apps.

5. shadcn/ui Is a Game-Changer

Instead of installing a huge component library:

npm install @mui/material  # 200KB+
Enter fullscreen mode Exit fullscreen mode

shadcn/ui copies components into your project:

npx shadcn-ui add button  # Copies button.tsx to your code
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • ✅ You own the code (can modify anything)
  • ✅ Only bundle what you use
  • ✅ Built on Radix (accessible by default)
  • ✅ Tailwind styling (easy to customize)

I'm using this for every project now.


Next Steps

Want to take this further? Here are some ideas:

Improvements to Make

  1. OAuth Integration

    • Login with Google
    • Login with GitHub
    • Much better UX than email/password
  2. Two-Factor Authentication (2FA)

    • TOTP (Google Authenticator)
    • SMS codes
    • Backup codes
  3. Rate Limiting

    • Prevent brute force attacks
    • Limit failed login attempts
    • IP-based throttling
  4. Session Management

    • See all active sessions
    • Logout from all devices
    • Device fingerprinting
  5. Audit Logs

    • Track who did what
    • IP addresses
    • Login history

Resources to Learn More

Authentication:

TypeScript:

React Patterns:


Conclusion

We built a production-ready authentication system from scratch:

✅ JWT access + refresh tokens

✅ Email verification

✅ Password reset

✅ Admin panel

✅ Role-based access control

✅ TypeScript everywhere

✅ Modern React frontend

✅ Deployed and live

This isn't a tutorial project. This is real code you could deploy for actual users today.

If you made it this far, you're awesome! 🎉

Check out the live demo: https://client-mu-ebon.vercel.app/

Star the repo: https://github.com/Favourof/client

Questions? Drop them in the comments! I read and respond to all of them.

Found this helpful? Share it with someone learning MERN stack!


Connect With Me

I'm building in public and sharing my journey:

Follow along as I build more projects and write more deep dives like this! 🚀


Tags: #MERN #Authentication #TypeScript #React #MongoDB #NodeJS #WebDev #FullStack #Tutorial

Top comments (0)