π 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.
Sessions vs. Tokens (JWT)
Two broad strategies exist. Understanding the trade-off explains why single-page apps lean on tokens.
| Session-based | Token-based (JWT) | |
|---|---|---|
| Where state lives | On the server (session store) | Nowhere β the token is self-contained |
| Client holds | A session ID in a cookie | A signed token |
| Scaling across servers | Needs a shared session store | Stateless β scales easily |
| Best fit | Server-rendered apps | SPAs and APIs |
| Main risk | CSRF | XSS 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>
);
}
β οΈ 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.
| Storage | Pro | Main risk |
|---|---|---|
localStorage | Simple; survives refresh | Readable by any injected script (XSS) |
sessionStorage | Cleared on tab close | Still XSS-readable |
| httpOnly cookie | JavaScript can't read it β resists XSS | CSRF, unless you add protection |
| In-memory | Never persisted | Lost 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
SameSitecookie 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:
- Confirm your
logincontroller returns a JWT and the client stores it (Section 4 & 6). - Add a
GET /api/auth/meroute protected by theprotectmiddleware that returnsreq.user. - Wrap a
Dashboardpage in<ProtectedRoute>. - Log in, visit
/dashboard, and confirm your data loads. - 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
- JWT.io β Introduction to JSON Web Tokens
- jsonwebtoken (Node library)
- OWASP Top Ten
- OWASP β Authentication Cheat Sheet
π 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.