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: Bearer header 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/refresh endpoint that issues a new access token, with refresh-token rotation to detect theft.

πŸ” The Full Flow

text
 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 tokenVersion logs 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 (collection sessions with userId, 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 / requireRole on 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 + tokenVersion lets 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

  1. Walk me through JWT auth in a MERN app. β€” Use the flow diagram above.
  2. Why two tokens? β€” Short-lived access token limits exposure; refresh token gives long sessions without re-login.
  3. Where do you store JWTs on the client? β€” Access token in memory (Redux); refresh token in httpOnly Secure SameSite cookie.
  4. How do you log out a JWT user if JWTs are stateless? β€” Short expiry + server-side refresh invalidation (tokenVersion / session records / denylist).
  5. What is refresh-token rotation? β€” Every refresh issues a new refresh token and invalidates the old one; reuse of an old one signals theft.
  6. How do you keep the user logged in after a page reload? β€” Call /auth/refresh on app start; the cookie is sent automatically.
  7. How do you implement role-based access? β€” requireRole("admin") middleware on the server; route guards on the client for UX.
  8. How do you type jwt.verify's result? β€” It returns string | JwtPayload; use a type guard, not a blind cast.
  9. 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 β†’