Chapter 86 β Authentication End-to-End (Express + Mongo + React/Redux)
π Definition
Production MERN auth usually means:
- A short-lived access token (JWT, ~15 min) sent in the
Authorization: Bearerheader and kept in memory (Redux state) on the client. - A long-lived refresh token (~7 days) stored in an httpOnly, Secure, SameSite cookie, so JavaScript (and XSS) can't read it.
- A
/auth/refreshendpoint that issues a new access token, with refresh-token rotation to detect theft.
π The Full Flow
React (Redux) Express API MongoDB
βββββββββββββ βββββββββββ βββββββ
POST /auth/login {email,pw} ββββββββΊ verify password (bcrypt) βββββββββΊ users
sign access (15m) + refresh (7d)
ββββββββ 200 { user, accessToken }
Set-Cookie: rt=β¦; HttpOnly; Secure
store accessToken in Redux
GET /api/tasks
Authorization: Bearer <access> ββββΊ requireAuth β verify JWT β req.user
β¦15 min laterβ¦
GET /api/tasks ββββββββββββββββββββββΊ 401 TOKEN_EXPIRED
POST /auth/refresh (cookie sent) βββββΊ verify refresh, check tokenVersion
ββββββββ { accessToken } + rotated cookie
retry original request (RTK Query baseQueryWithReauth)
POST /auth/logout ββββββββββββββββββββΊ bump tokenVersion, clear cookieπ» Server β Token Helpers
// modules/auth/tokens.ts
import jwt, { type JwtPayload } from "jsonwebtoken";
import { env } from "../../config/env.js";
import type { Role } from "@app/shared";
export interface AccessPayload { sub: string; role: Role }
export interface RefreshPayload { sub: string; ver: number } // ver = user.tokenVersion
export const signAccess = (p: AccessPayload) =>
jwt.sign(p, env.JWT_ACCESS_SECRET, { expiresIn: "15m" });
export const signRefresh = (p: RefreshPayload) =>
jwt.sign(p, env.JWT_REFRESH_SECRET, { expiresIn: "7d" });
function isAccess(p: string | JwtPayload): p is JwtPayload & AccessPayload {
return typeof p === "object" && typeof p.sub === "string" && typeof p.role === "string";
}
function isRefresh(p: string | JwtPayload): p is JwtPayload & RefreshPayload {
return typeof p === "object" && typeof p.sub === "string" && typeof p.ver === "number";
}
export function verifyAccess(token: string): AccessPayload {
const p = jwt.verify(token, env.JWT_ACCESS_SECRET);
if (!isAccess(p)) throw new Error("Malformed token");
return p;
}
export function verifyRefresh(token: string): RefreshPayload {
const p = jwt.verify(token, env.JWT_REFRESH_SECRET);
if (!isRefresh(p)) throw new Error("Malformed token");
return p;
}jwt.verify returns string | JwtPayload. The type guards make the payload type true rather than just cast.
π» Server β User Model Addition
// user.model.ts (add to IUser and the schema from the Mongoose chapter)
tokenVersion: { type: Number, default: 0 }, // bump to invalidate every refresh token for this userπ» Server β Auth Service & Controller
// modules/auth/auth.service.ts
import { User, type UserDocument } from "../users/user.model.js";
import { AppError } from "../../utils/AppError.js";
import { signAccess, signRefresh, verifyRefresh } from "./tokens.js";
export const authService = {
async register(dto: { email: string; name: string; password: string }) {
if (await User.exists({ email: dto.email })) throw AppError.conflict("Email already registered");
const user = await User.create(dto); // pre("save") hashes the password
return issueTokens(user);
},
async login(email: string, password: string) {
const user = await User.findByEmail(email); // selects +password
// Same message for "no user" and "wrong password" β no user enumeration
if (!user || !(await user.comparePassword(password))) throw AppError.unauthorized("Invalid credentials");
if (!user.isActive) throw AppError.forbidden("Account disabled");
return issueTokens(user);
},
async refresh(token: string | undefined) {
if (!token) throw AppError.unauthorized("No refresh token");
const payload = verifyRefresh(token);
const user = await User.findById(payload.sub);
// tokenVersion mismatch = logged out everywhere OR a stolen, already-rotated token
if (!user || user.tokenVersion !== payload.ver) throw AppError.unauthorized("Session expired");
user.tokenVersion += 1; // rotation: the old refresh token is now dead
await user.save();
return issueTokens(user);
},
async logout(userId: string) {
await User.updateOne({ _id: userId }, { $inc: { tokenVersion: 1 } });
},
};
function issueTokens(user: UserDocument) {
return {
user: user.toJSON(),
accessToken: signAccess({ sub: user.id, role: user.role }),
refreshToken: signRefresh({ sub: user.id, ver: user.tokenVersion }),
};
}Simple rotation with a single
tokenVersionlogs the user out on all devices when any refresh happens on another device. For multi-device support, store one hashed refresh-token record per session (collectionsessionswithuserId,tokenHash,expiresAt+ TTL index) and rotate per session.
// modules/auth/auth.controller.ts
import type { Request, Response, CookieOptions } from "express";
import { env } from "../../config/env.js";
import { authService } from "./auth.service.js";
const REFRESH_COOKIE = "rt";
const cookieOpts: CookieOptions = {
httpOnly: true, // JS can't read it β safe from XSS theft
secure: env.NODE_ENV === "production", // HTTPS only
sameSite: "strict", // not sent on cross-site requests β CSRF protection
path: "/api/auth", // only sent to auth endpoints
maxAge: 7 * 24 * 60 * 60 * 1000,
};
function sendAuth(res: Response, r: Awaited<ReturnType<typeof authService.login>>, status = 200) {
res.cookie(REFRESH_COOKIE, r.refreshToken, cookieOpts);
res.status(status).json({ success: true, data: { user: r.user, accessToken: r.accessToken } });
}
export async function register(req: Request, res: Response) {
sendAuth(res, await authService.register(req.body), 201);
}
export async function login(req: Request<{}, unknown, { email: string; password: string }>, res: Response) {
sendAuth(res, await authService.login(req.body.email, req.body.password));
}
export async function refresh(req: Request, res: Response) {
sendAuth(res, await authService.refresh(req.cookies?.[REFRESH_COOKIE]));
}
export async function logout(req: Request, res: Response) {
if (req.user) await authService.logout(req.user.id);
res.clearCookie(REFRESH_COOKIE, { ...cookieOpts, maxAge: undefined });
res.status(204).end();
}
export async function me(req: Request, res: Response) {
res.json({ success: true, data: req.user });
}π» Server β requireAuth and requireRole Middleware
// middleware/auth.ts
import type { RequestHandler } from "express";
import jwt from "jsonwebtoken";
import type { Role } from "@app/shared";
import { verifyAccess } from "../modules/auth/tokens.js";
import { AppError } from "../utils/AppError.js";
export const requireAuth: RequestHandler = (req, _res, next) => {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) return next(AppError.unauthorized("Missing token"));
try {
const payload = verifyAccess(header.slice(7));
req.user = { id: payload.sub, role: payload.role }; // typed via express.d.ts augmentation
next();
} catch (err) {
next(
err instanceof jwt.TokenExpiredError
? new AppError(401, "Token expired", "TOKEN_EXPIRED") // client knows to refresh
: AppError.unauthorized("Invalid token"),
);
}
};
export const requireRole =
(...roles: Role[]): RequestHandler =>
(req, _res, next) => {
if (!req.user) return next(AppError.unauthorized());
if (!roles.includes(req.user.role)) return next(AppError.forbidden("Insufficient role"));
next();
};
// Ownership check: users can only touch their own resources unless admin
export const requireSelfOrAdmin: RequestHandler<{ id: string }> = (req, _res, next) => {
if (req.user?.role === "admin" || req.user?.id === req.params.id) return next();
next(AppError.forbidden());
};// modules/auth/auth.routes.ts
import { Router } from "express";
import rateLimit from "express-rate-limit";
import { body } from "express-validator";
import { validate } from "../../middleware/validate.js";
import { requireAuth } from "../../middleware/auth.js";
import * as c from "./auth.controller.js";
const loginLimiter = rateLimit({ windowMs: 15 * 60_000, limit: 10, standardHeaders: true });
export const authRouter = Router();
authRouter.post("/register", validate([
body("email").isEmail().normalizeEmail(),
body("name").trim().isLength({ min: 2 }),
body("password").isLength({ min: 8 }),
]), c.register);
authRouter.post("/login", loginLimiter, validate([body("email").isEmail(), body("password").notEmpty()]), c.login);
authRouter.post("/refresh", c.refresh);
authRouter.post("/logout", requireAuth, c.logout);
authRouter.get("/me", requireAuth, c.me);π» Client β Auth Slice (Redux Toolkit)
// features/auth/authSlice.ts
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
import type { PublicUser } from "@app/shared";
import type { RootState } from "../../app/store";
interface AuthState {
user: PublicUser | null;
accessToken: string | null; // memory only β never localStorage
status: "unknown" | "authenticated" | "anonymous";
}
const initialState: AuthState = { user: null, accessToken: null, status: "unknown" };
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
loggedIn(state, action: PayloadAction<{ user: PublicUser; accessToken: string }>) {
state.user = action.payload.user;
state.accessToken = action.payload.accessToken;
state.status = "authenticated";
},
tokenRefreshed(state, action: PayloadAction<string>) {
state.accessToken = action.payload;
},
loggedOut() {
return { ...initialState, status: "anonymous" as const };
},
},
});
export const { loggedIn, tokenRefreshed, loggedOut } = authSlice.actions;
export default authSlice.reducer;
export const selectCurrentUser = (s: RootState) => s.auth.user;
export const selectAuthStatus = (s: RootState) => s.auth.status;
export const selectIsAdmin = (s: RootState) => s.auth.user?.role === "admin";π» Client β Auth Endpoints with RTK Query
// features/auth/authApi.ts
import { api } from "../../services/api"; // createApi with baseQueryWithReauth (Redux: RTK Query chapter)
import { loggedIn, loggedOut } from "./authSlice";
import type { PublicUser } from "@app/shared";
type AuthResponse = { success: true; data: { user: PublicUser; accessToken: string } };
export const authApi = api.injectEndpoints({
endpoints: (b) => ({
login: b.mutation<AuthResponse["data"], { email: string; password: string }>({
query: (body) => ({ url: "/auth/login", method: "POST", body }),
transformResponse: (r: AuthResponse) => r.data,
async onQueryStarted(_arg, { dispatch, queryFulfilled }) {
const { data } = await queryFulfilled;
dispatch(loggedIn(data));
},
}),
// Called once on app start: the httpOnly cookie restores the session after a page reload
restoreSession: b.mutation<AuthResponse["data"], void>({
query: () => ({ url: "/auth/refresh", method: "POST" }),
transformResponse: (r: AuthResponse) => r.data,
async onQueryStarted(_arg, { dispatch, queryFulfilled }) {
try { dispatch(loggedIn((await queryFulfilled).data)); }
catch { dispatch(loggedOut()); }
},
}),
logout: b.mutation<void, void>({
query: () => ({ url: "/auth/logout", method: "POST" }),
async onQueryStarted(_arg, { dispatch, queryFulfilled }) {
await queryFulfilled.catch(() => undefined);
dispatch(loggedOut());
dispatch(api.util.resetApiState()); // drop every cached response from the old user
},
}),
}),
});
export const { useLoginMutation, useRestoreSessionMutation, useLogoutMutation } = authApi;π» Client β Protected Routes (React Router)
// routes/RequireAuth.tsx
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAppSelector } from "../app/hooks";
import { selectAuthStatus, selectCurrentUser } from "../features/auth/authSlice";
import type { Role } from "@app/shared";
export function RequireAuth({ roles }: { roles?: Role[] }) {
const status = useAppSelector(selectAuthStatus);
const user = useAppSelector(selectCurrentUser);
const location = useLocation();
if (status === "unknown") return <p>Checking sessionβ¦</p>; // avoid redirect flash on reload
if (status === "anonymous") return <Navigate to="/login" replace state={{ from: location }} />;
if (roles && user && !roles.includes(user.role)) return <Navigate to="/403" replace />;
return <Outlet />;
}// App.tsx
export default function App() {
const [restore] = useRestoreSessionMutation();
useEffect(() => { void restore(); }, [restore]);
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route path="/" element={<Dashboard />} />
<Route path="/tasks" element={<TasksPage />} />
</Route>
<Route element={<RequireAuth roles={["admin"]} />}>
<Route path="/admin/users" element={<AdminUsers />} />
</Route>
</Routes>
);
}Client-side guards are UX only. The real protection is
requireAuth/requireRoleon the server β anyone can call the API directly.
π» Client β Login Page
export function LoginPage() {
const [login, { isLoading, error }] = useLoginMutation();
const navigate = useNavigate();
const location = useLocation();
const from = (location.state as { from?: Location } | null)?.from?.pathname ?? "/";
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const f = new FormData(e.currentTarget);
try {
await login({ email: String(f.get("email")), password: String(f.get("password")) }).unwrap();
navigate(from, { replace: true });
} catch { /* shown below */ }
}
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" autoComplete="email" required />
<input name="password" type="password" autoComplete="current-password" required />
<button disabled={isLoading}>Sign in</button>
{error && <p role="alert">{errorMessage(error)}</p>}
</form>
);
}π Where to Store Tokens
| Storage | XSS can steal it? | CSRF risk? | Verdict |
|---|---|---|---|
localStorage |
β Yes | No | Avoid for tokens |
| Memory (Redux) | Only while the page is open | No | β Access token |
| httpOnly cookie | β No | Yes β mitigate with SameSite=strict/lax + CORS |
β Refresh token |
π Real-World Impact
- Short access tokens limit the damage of a leaked token to minutes.
- Rotation +
tokenVersionlets you "log out of all devices" and detect replayed refresh tokens. - Rate-limited login and identical error messages block brute-force and user-enumeration attacks.
π― Likely Interview Questions
- Walk me through JWT auth in a MERN app. β Use the flow diagram above.
- Why two tokens? β Short-lived access token limits exposure; refresh token gives long sessions without re-login.
- Where do you store JWTs on the client? β Access token in memory (Redux); refresh token in httpOnly Secure SameSite cookie.
- How do you log out a JWT user if JWTs are stateless? β Short expiry + server-side refresh invalidation (
tokenVersion/ session records / denylist). - What is refresh-token rotation? β Every refresh issues a new refresh token and invalidates the old one; reuse of an old one signals theft.
- How do you keep the user logged in after a page reload? β Call
/auth/refreshon app start; the cookie is sent automatically. - How do you implement role-based access? β
requireRole("admin")middleware on the server; route guards on the client for UX. - How do you type
jwt.verify's result? β It returnsstring | JwtPayload; use a type guard, not a blind cast. - How do you handle many parallel 401s? β Share a single refresh promise, then retry the queued requests.
β Mongoose with TypeScript | Index | Next: React with TypeScript β