DEV Community

VATSRAJ PARMAR
VATSRAJ PARMAR

Posted on

Building JWT Authentication + Role-Based Access Control for MERN Apps

If you've built more than one or two production apps, you've probably written auth + RBAC from scratch more times than you'd like to admit. Here's the pattern I keep coming back to — JWT access + refresh tokens, httpOnly cookies, and role-based route protection on both the frontend and backend.

This post walks through the core pieces. At the end I'll link a ready-to-use starter kit if you'd rather not rebuild this from scratch every time.

The problem with "just use localStorage"

A lot of tutorials store the JWT in localStorage and call it done. The problem: any XSS vulnerability on your site can read localStorage and steal the token. httpOnly cookies can't be read by JavaScript at all, which closes that attack surface.

The tradeoff is a bit more setup — so let's go through it.

1. The User model, with roles baked in
`// models/User.js
import mongoose from "mongoose";
import bcrypt from "bcryptjs";

export const ROLES = ["admin", "manager", "user"];

const userSchema = new mongoose.Schema(
{
name: { type: String, required: true, trim: true },
email: { type: String, required: true, unique: true, lowercase: true },
password: { type: String, required: true, minlength: 8, select: false },
role: { type: String, enum: ROLES, default: "user" },
refreshTokenHash: { type: String, select: false },
},
{ timestamps: true }
);

userSchema.index({ role: 1 });

userSchema.pre("save", async function (next) {
if (!this.isModified("password")) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});

userSchema.methods.comparePassword = function (candidate) {
return bcrypt.compare(candidate, this.password);
};

export default mongoose.model("User", userSchema);`

A couple of things worth noting:

select: false on password and refreshTokenHash means these never come back in a normal .find() — you have to explicitly .select("+password") when you need them. One less way to accidentally leak a hash in an API response.
The role field has an enum — Mongoose will reject anything outside your defined roles at the database level, not just in your app logic.

2. Generating access + refresh tokens
`// utils/generateTokens.js
import jwt from "jsonwebtoken";
import crypto from "crypto";

export const generateAccessToken = (user) =>
jwt.sign({ sub: user._id.toString(), role: user.role }, process.env.JWT_ACCESS_SECRET, {
expiresIn: "15m",
});

export const generateRefreshToken = (user) =>
jwt.sign(
{ sub: user._id.toString(), tokenVersion: crypto.randomUUID() },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" }
);

export const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/api/auth",
maxAge: 7 * 24 * 60 * 60 * 1000,
};`

Short-lived access token (15 min), long-lived refresh token (7 days), different secrets for each — if one leaks, the other stays valid independently.

3. The RBAC middleware itself

This is the part that actually enforces roles:
// middleware/roleMiddleware.js
export const authorize = (...allowedRoles) => {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ message: "Not authorized" });
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
message:
Access denied. Requires role: ${allowedRoles.join(" or ")},
});
}
next();
};
};

Usage:
router.get("/reports", protect, authorize("admin", "manager"), getReports);

The key design decision here: roles are checked server-side, always. Your React app can (and should) also hide admin-only UI from regular users, but that's a UX nicety — the actual security boundary is this middleware. Never trust a frontend route guard alone.

*4. Don't forget: never trust the client on registration either
*

This one's easy to miss. If your registration endpoint accepts a role field straight from the request body, anyone can sign up as an admin:

`// DON'T do this:
const user = await User.create({ name, email, password, role: req.body.role });

// DO this instead — hardcode new signups to the lowest privilege:
const safeRole = "user";
const user = await User.create({ name, email, password, role: safeRole });`

Promotion to admin/manager should only happen through a separate, already-authenticated admin-only endpoint — never at signup.

5. Frontend route guards

On the React side, a simple wrapper component keeps unauthorized users out of role-gated pages:

`// components/ProtectedRoute.jsx
import { Navigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";

const ProtectedRoute = ({ children, roles }) => {
const { user, loading } = useAuth();

if (loading) return

Loading...;
if (!user) return ;
if (roles && !roles.includes(user.role)) return ;

return children;
};

export default ProtectedRoute;`

<Route path="/admin" element={
<ProtectedRoute roles={["admin"]}>
<AdminPanel />
</ProtectedRoute>
} />

Again — this is UX, not security. The real gate is the backend middleware.

6. Auto-refreshing expired access tokens

Fifteen-minute access tokens mean your users would get logged out constantly without this piece. An axios interceptor handles it silently:
// utils/api.js
api.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
const { data } = await axios.post("/api/auth/refresh", {}, { withCredentials: true });
setAccessToken(data.accessToken);
original.headers.Authorization =
Bearer ${data.accessToken};
return api(original);
}
return Promise.reject(error);
}
);

The refresh token lives in an httpOnly cookie, so this request automatically includes it — no manual token juggling on the frontend.

Wrapping up

That's the core of it: short-lived access tokens, httpOnly refresh tokens, server-enforced roles, and a frontend that stays in sync without constant re-logins.

If you want the fully wired version of this — rate limiting, refresh token hashing in the DB, paginated admin user management, and a working React UI — I packaged it as a starter kit: MERN Auth + RBAC Starter Kit. Drop it in, connect your MongoDB URI, and you've got working auth in about 10 minutes instead of rebuilding all of this from scratch.

Happy to answer questions about any of the above in the comments.

Top comments (0)