Skip to main content

πŸ” Authentication Flow in MERN Stack

Authentication is where every full stack developer's skills get tested at once: databases, hashing, middleware, HTTP headers, React state, and security judgment. This lesson walks the complete journey of a login β€” from a hashed password in MongoDB to a JWT in the browser to a guarded React route β€” and calls out the security decisions that actually matter.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Distinguish authentication from authorization, and session- from token-based auth
  • Hash and verify passwords securely with bcrypt
  • Issue and verify JSON Web Tokens (JWT) in an Express API
  • Protect API routes with authentication middleware and role checks
  • Manage auth state in React with a context provider and guard routes
  • Reason about token storage trade-offs (localStorage vs. httpOnly cookies) and common attacks

Estimated Time: 60–75 minutes  β€’  Difficulty: Advanced

Hands-on: Trace a login end to end, then implement a protected /me route and a React route guard.

In This Lesson

Authentication vs. Authorization

These two words are constantly confused, but the distinction is simple and it shapes your whole design:

πŸ“– Two different questions

Authentication asks "Who are you?" β€” proving identity, usually with an email and password.

Authorization asks "What are you allowed to do?" β€” deciding whether an authenticated user may reach a resource (e.g. only admins can delete users).

You authenticate first, then authorize. A robust auth system protects user data, gates restricted features, enables personalization, and keeps you compliant with data-protection rules. In a MERN app the standard approach is token-based: the server verifies credentials once and hands back a signed JWT that the client presents on every later request.

flowchart TD A[User submits credentials] --> B{Valid?} B -->|Yes| C[Server signs a JWT] B -->|No| D[401 Invalid credentials] C --> E[Client stores the token] E --> F[Token sent on future requests] F --> G{Token valid?} G -->|Yes| H[Return protected resource] G -->|No| I[401 Please log in again]

Sessions vs. Tokens (JWT)

Two broad strategies exist. Understanding the trade-off explains why single-page apps lean on tokens.

Session-basedToken-based (JWT)
Where state livesOn the server (session store)Nowhere β€” the token is self-contained
Client holdsA session ID in a cookieA signed token
Scaling across serversNeeds a shared session storeStateless β€” scales easily
Best fitServer-rendered appsSPAs and APIs
Main riskCSRFXSS if stored in localStorage
πŸ’‘ Analogy: A server session is like a hotel that keeps your details at the front desk and gives you a key card tied to their records β€” they look you up each time. A JWT is like an all-access convention badge: the badge itself carries your name and access level, signed so it can't be forged, and no one needs to phone the office to check it.

What a JWT actually is

A JWT is three base64url-encoded parts joined by dots: header.payload.signature. The header names the algorithm, the payload carries claims (like the user id and an expiry), and the signature is what the server computes with its secret to guarantee the token wasn't altered.

eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjY1YSIsImV4cCI6MTcwMH0.3xR_dQ7...signature

⚠️ A JWT is signed, not encrypted

Anyone can decode a JWT's payload and read it β€” paste one into jwt.io to see. The signature only proves it wasn't tampered with. Never put secrets (passwords, card numbers) in a JWT payload, and never trust a token whose signature you haven't verified.

Storing Passwords Safely

The first rule of authentication: never store plain-text passwords. If your database leaks, every account must remain safe. You achieve that by storing a one-way hash produced by bcrypt, which is deliberately slow and salts each password so identical passwords hash differently.

Mongoose lets you hash automatically in a pre('save') hook, so controllers never touch raw passwords:

// server/models/User.js
import mongoose from 'mongoose';
import bcrypt from 'bcryptjs';

const userSchema = new mongoose.Schema(
  {
    name: { type: String, required: true, trim: true, maxlength: 50 },
    email: {
      type: String,
      required: true,
      unique: true,
      lowercase: true,
      trim: true
    },
    password: {
      type: String,
      required: true,
      minlength: 6,
      select: false          // never returned by queries unless asked
    },
    role: { type: String, enum: ['user', 'admin'], default: 'user' }
  },
  { timestamps: true }
);

// Hash the password before saving β€” only if it changed
userSchema.pre('save', async function (next) {
  if (!this.isModified('password')) return next();
  const salt = await bcrypt.genSalt(10);
  this.password = await bcrypt.hash(this.password, salt);
  next();
});

// Instance method to verify a login attempt
userSchema.methods.matchPassword = function (candidate) {
  return bcrypt.compare(candidate, this.password);
};

export default mongoose.model('User', userSchema);

πŸ’‘ Two safeguards worth noticing

select: false keeps the hash out of ordinary query results, so you can't accidentally leak it in an API response. The isModified('password') guard means editing a user's name won't re-hash (and thus break) their existing password.

Issuing & Verifying JWTs

On a successful login, the server signs a token with jsonwebtoken and returns it. The token encodes just enough to identify the user β€” typically their id β€” plus an expiry.

// server/controllers/authController.js
import jwt from 'jsonwebtoken';
import User from '../models/User.js';

const signToken = (id) =>
  jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn: '1d' });

// POST /api/auth/register
export const register = async (req, res) => {
  try {
    const { name, email, password } = req.body;

    if (await User.findOne({ email })) {
      return res.status(400).json({ message: 'Email already registered' });
    }
    const user = await User.create({ name, email, password });

    res.status(201).json({
      token: signToken(user._id),
      user: { id: user._id, name: user.name, email: user.email, role: user.role }
    });
  } catch (err) {
    res.status(500).json({ message: 'Server error during registration' });
  }
};

// POST /api/auth/login
export const login = async (req, res) => {
  try {
    const { email, password } = req.body;
    if (!email || !password) {
      return res.status(400).json({ message: 'Email and password required' });
    }

    // password has select:false, so ask for it explicitly
    const user = await User.findOne({ email }).select('+password');

    // Same generic message whether the email or password is wrong
    if (!user || !(await user.matchPassword(password))) {
      return res.status(401).json({ message: 'Invalid credentials' });
    }

    res.json({
      token: signToken(user._id),
      user: { id: user._id, name: user.name, email: user.email, role: user.role }
    });
  } catch (err) {
    res.status(500).json({ message: 'Server error during login' });
  }
};

⚠️ Don't leak which field was wrong

Return the same "Invalid credentials" message for an unknown email and a wrong password. Telling an attacker "that email exists but the password is wrong" hands them a way to enumerate valid accounts.

The signing secret and expiry belong in environment variables, never in source:

# server/.env
JWT_SECRET=a_long_random_string_kept_out_of_git
MONGODB_URI=mongodb://localhost:27017/mern-auth
CLIENT_URL=http://localhost:5173

Protecting Routes

Middleware is where authentication meets authorization. A protect middleware verifies the token and attaches the user to the request; an authorize middleware then checks their role.

// server/middleware/auth.js
import jwt from 'jsonwebtoken';
import User from '../models/User.js';

// Authentication: is there a valid token?
export const protect = async (req, res, next) => {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ message: 'Not authorized, no token' });
  }
  try {
    const token = header.split(' ')[1];
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = await User.findById(decoded.id);   // available downstream
    if (!req.user) {
      return res.status(401).json({ message: 'User no longer exists' });
    }
    next();
  } catch (err) {
    res.status(401).json({ message: 'Not authorized, token failed' });
  }
};

// Authorization: does the user have the right role?
export const authorize = (...roles) => (req, res, next) => {
  if (!roles.includes(req.user.role)) {
    return res
      .status(403)
      .json({ message: `Role '${req.user.role}' is not allowed here` });
  }
  next();
};

Wire them into routes. Notice how middleware stacks: a request must pass protect before it even reaches authorize.

// server/routes/auth.js
import express from 'express';
import { register, login } from '../controllers/authController.js';
import { protect, authorize } from '../middleware/auth.js';

const router = express.Router();

router.post('/register', register);
router.post('/login', login);

// Any logged-in user
router.get('/me', protect, (req, res) => {
  res.json({ user: req.user });
});

// Admins only
router.get('/admin/users', protect, authorize('admin'), async (req, res) => {
  const users = await (await import('../models/User.js')).default.find();
  res.json(users);
});

export default router;

πŸ“– Status codes that mean specific things

401 Unauthorized β€” "I don't know who you are" (missing or invalid token). 403 Forbidden β€” "I know who you are, but you're not allowed." Using them correctly makes your API predictable and easier to debug.

Auth State in React

The frontend needs to know whether someone is logged in so it can show the right UI and guard routes. A context provider holds that state in one place and exposes login, logout, and the current user to the whole tree.

// client/src/context/AuthContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
import api from '../services/api';

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  // On first load, if a token exists, fetch the current user
  useEffect(() => {
    const token = localStorage.getItem('token');
    if (!token) {
      setLoading(false);
      return;
    }
    api
      .get('/auth/me')
      .then((res) => setUser(res.data.user))
      .catch(() => localStorage.removeItem('token'))
      .finally(() => setLoading(false));
  }, []);

  async function login(credentials) {
    const { data } = await api.post('/auth/login', credentials);
    localStorage.setItem('token', data.token);   // interceptor sends it onward
    setUser(data.user);
    return data.user;
  }

  function logout() {
    localStorage.removeItem('token');
    setUser(null);
  }

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

// Convenience hook
export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used within AuthProvider');
  return ctx;
}

A route guard component redirects anonymous users away from protected pages. It waits for the initial auth check so it doesn't bounce a logged-in user on refresh:

// client/src/components/ProtectedRoute.jsx
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';

export default function ProtectedRoute({ children }) {
  const { isAuthenticated, loading } = useAuth();

  if (loading) return <p>Loading…</p>;          // wait for /me to resolve
  if (!isAuthenticated) return <Navigate to="/login" replace />;
  return children;
}

Compose them in the router. The provider wraps everything; the guard wraps only protected pages:

// client/src/App.jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';

export default function App() {
  return (
    <AuthProvider>
      <BrowserRouter>
        <Routes>
          <Route path="/login" element={<Login />} />
          <Route
            path="/dashboard"
            element={
              <ProtectedRoute>
                <Dashboard />
              </ProtectedRoute>
            }
          />
        </Routes>
      </BrowserRouter>
    </AuthProvider>
  );
}
sequenceDiagram participant U as User participant R as React (AuthContext) participant E as Express participant DB as MongoDB U->>R: Submit login form R->>E: POST /api/auth/login E->>DB: Find user, verify hash DB-->>E: User record E-->>R: JWT + user R->>R: Store token, set user Note over U,R: Later β€” visit /dashboard U->>R: Navigate to protected route R->>E: GET /api/auth/me (Bearer token) E-->>R: User data R-->>U: Render Dashboard

⚠️ The client guard is convenience, not security

Hiding a page in React only improves UX β€” anyone can edit client code. The real protection is the server's protect middleware. Always enforce auth on the backend; treat the frontend guard as a nicety on top.

Security & Token Storage

Where you keep the token is the security decision beginners get wrong most often. Each option trades convenience against a different attack.

StorageProMain risk
localStorageSimple; survives refreshReadable by any injected script (XSS)
sessionStorageCleared on tab closeStill XSS-readable
httpOnly cookieJavaScript can't read it β€” resists XSSCSRF, unless you add protection
In-memoryNever persistedLost on every refresh

βœ… Practical guidance

For learning and many small apps, localStorage with short-lived tokens is acceptable and what this lesson uses. For higher-stakes apps, prefer an httpOnly, Secure, SameSite cookie set by the server plus CSRF protection β€” it removes the XSS token-theft vector entirely. Whatever you choose, always serve over HTTPS.

The attacks to know

  • XSS (Cross-Site Scripting): injected JS reads tokens from storage. Defend with input sanitization, a Content-Security-Policy, and httpOnly cookies.
  • CSRF (Cross-Site Request Forgery): a malicious site rides your cookie to make requests. Defend with CSRF tokens and the SameSite cookie attribute.
  • Token theft in transit: defend with HTTPS everywhere (and HSTS).

Refresh tokens, briefly

Short-lived access tokens are safer but expire while users are active. The common fix is a pair: a short-lived access token (say 15 minutes) plus a longer-lived refresh token that mints a new access token when the old one expires β€” ideally via an Axios response interceptor that catches a 401, refreshes, and retries the original request. Rotate refresh tokens so a stolen one can't be reused indefinitely.

Hands-on: A Protected Route

πŸ‹οΈ Wire authentication end to end

Objective: Add a protected profile endpoint and a matching React guard, then confirm both the happy path and the rejection path.

Instructions:

  1. Confirm your login controller returns a JWT and the client stores it (Section 4 & 6).
  2. Add a GET /api/auth/me route protected by the protect middleware that returns req.user.
  3. Wrap a Dashboard page in <ProtectedRoute>.
  4. Log in, visit /dashboard, and confirm your data loads.
  5. Delete the token from localStorage, refresh, and confirm you're redirected to /login.
πŸ’‘ Hint

The request interceptor from the Axios lesson attaches Authorization: Bearer <token> automatically, so api.get('/auth/me') is all the client needs. On the server, protect must run before the handler in the route definition, or req.user won't exist.

βœ… Sample solution
// server/routes/auth.js
router.get('/me', protect, (req, res) => {
  res.json({ user: req.user });
});
// client/src/pages/Dashboard.jsx
import { useAuth } from '../context/AuthContext';

export default function Dashboard() {
  const { user, logout } = useAuth();
  if (!user) return <p>Loading…</p>;

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <p>Email: {user.email}</p>
      <p>Role: {user.role}</p>
      <button onClick={logout}>Log out</button>
    </div>
  );
}

With a valid token the dashboard renders your profile; without one, ProtectedRoute sends you to the login page β€” exactly the behavior you want.

🎯 Quick Quiz

Question 1: What is the difference between authentication and authorization?

Question 2: Why hash passwords with bcrypt instead of storing them directly?

Question 3: Your React ProtectedRoute hides the dashboard from anonymous users. Is that sufficient security?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Authentication proves identity; authorization grants permission β€” you do them in that order.
  • MERN apps favor stateless JWTs: signed, self-contained, and easy to scale.
  • Never store plain passwords β€” hash with bcrypt in a Mongoose pre('save') hook.
  • Express middleware verifies the token (protect) and checks roles (authorize); use 401 vs. 403 correctly.
  • A React auth context plus a route guard manages UI state β€” but the server is the real gatekeeper.
  • Token storage is a trade-off: localStorage is simple but XSS-exposed; httpOnly cookies resist XSS at the cost of CSRF handling. Always use HTTPS.

πŸ“š Further Reading

πŸš€ What's Next?

You've built the connection, the data layer, and the auth flow β€” the full toolkit of a MERN developer. Put it all together in the Weekend Project: JavaScript Web Frameworks, where you'll ship a complete authenticated app end to end.

πŸŽ‰ Outstanding!

Authentication is a rite of passage. You now understand every step from hashed password to guarded route.