<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: VATSRAJ PARMAR</title>
    <description>The latest articles on DEV Community by VATSRAJ PARMAR (@vatsraj_parmar_17a32201fd).</description>
    <link>https://dev.to/vatsraj_parmar_17a32201fd</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4086462%2Ff196ebd4-81aa-400a-9840-e8fc53a87971.jpeg</url>
      <title>DEV Community: VATSRAJ PARMAR</title>
      <link>https://dev.to/vatsraj_parmar_17a32201fd</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vatsraj_parmar_17a32201fd"/>
    <language>en</language>
    <item>
      <title>Building JWT Authentication + Role-Based Access Control for MERN Apps</title>
      <dc:creator>VATSRAJ PARMAR</dc:creator>
      <pubDate>Thu, 20 Aug 2026 11:00:13 +0000</pubDate>
      <link>https://dev.to/vatsraj_parmar_17a32201fd/building-jwt-authentication-role-based-access-control-for-mern-apps-fbc</link>
      <guid>https://dev.to/vatsraj_parmar_17a32201fd/building-jwt-authentication-role-based-access-control-for-mern-apps-fbc</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The problem with "just use localStorage"&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The tradeoff is a bit more setup — so let's go through it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The User model, with roles baked in&lt;/strong&gt;&lt;br&gt;
`// models/User.js&lt;br&gt;
import mongoose from "mongoose";&lt;br&gt;
import bcrypt from "bcryptjs";&lt;/p&gt;

&lt;p&gt;export const ROLES = ["admin", "manager", "user"];&lt;/p&gt;

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

&lt;p&gt;userSchema.index({ role: 1 });&lt;/p&gt;

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

&lt;p&gt;userSchema.methods.comparePassword = function (candidate) {&lt;br&gt;
  return bcrypt.compare(candidate, this.password);&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;export default mongoose.model("User", userSchema);`&lt;/p&gt;

&lt;p&gt;A couple of things worth noting:&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
The role field has an enum — Mongoose will reject anything outside your defined roles at the database level, not just in your app logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Generating access + refresh tokens&lt;/strong&gt;&lt;br&gt;
`// utils/generateTokens.js&lt;br&gt;
import jwt from "jsonwebtoken";&lt;br&gt;
import crypto from "crypto";&lt;/p&gt;

&lt;p&gt;export const generateAccessToken = (user) =&amp;gt;&lt;br&gt;
  jwt.sign({ sub: user._id.toString(), role: user.role }, process.env.JWT_ACCESS_SECRET, {&lt;br&gt;
    expiresIn: "15m",&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;export const generateRefreshToken = (user) =&amp;gt;&lt;br&gt;
  jwt.sign(&lt;br&gt;
    { sub: user._id.toString(), tokenVersion: crypto.randomUUID() },&lt;br&gt;
    process.env.JWT_REFRESH_SECRET,&lt;br&gt;
    { expiresIn: "7d" }&lt;br&gt;
  );&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;3. The RBAC middleware itself&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;Usage:&lt;br&gt;
&lt;code&gt;router.get("/reports", protect, authorize("admin", "manager"), getReports);&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;*&lt;em&gt;4. Don't forget: never trust the client on registration either&lt;br&gt;
*&lt;/em&gt;&lt;br&gt;
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:&lt;/p&gt;

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

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

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

&lt;p&gt;&lt;strong&gt;5. Frontend route guards&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On the React side, a simple wrapper component keeps unauthorized users out of role-gated pages:&lt;/p&gt;

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

&lt;p&gt;const ProtectedRoute = ({ children, roles }) =&amp;gt; {&lt;br&gt;
  const { user, loading } = useAuth();&lt;/p&gt;

&lt;p&gt;if (loading) return &lt;/p&gt;Loading...;&lt;br&gt;
  if (!user) return ;&lt;br&gt;
  if (roles &amp;amp;&amp;amp; !roles.includes(user.role)) return ;

&lt;p&gt;return children;&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;export default ProtectedRoute;`&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;Route path="/admin" element={&lt;br&gt;
  &amp;lt;ProtectedRoute roles={["admin"]}&amp;gt;&lt;br&gt;
    &amp;lt;AdminPanel /&amp;gt;&lt;br&gt;
  &amp;lt;/ProtectedRoute&amp;gt;&lt;br&gt;
} /&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Again — this is UX, not security. The real gate is the backend middleware.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Auto-refreshing expired access tokens&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;The refresh token lives in an httpOnly cookie, so this request automatically includes it — no manual token juggling on the frontend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrapping up&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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: &lt;a href="https://vatsraj.gumroad.com/l/uvvnww" rel="noopener noreferrer"&gt;MERN Auth + RBAC Starter Kit&lt;/a&gt;. 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.&lt;/p&gt;

&lt;p&gt;Happy to answer questions about any of the above in the comments.&lt;/p&gt;

</description>
      <category>react</category>
      <category>node</category>
      <category>mongodb</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
