Implementing JWT Authorization with User Moderator and Admin Roles in Node.js

Implementing JWT Authorization with User Moderator and Admin Roles in Node.js I’m building a local meetup app, and pretty early on it became clear that a simple “logged in or not” auth check wasn’t going to cut it. Once safety features entered the picture — SOS alerts, abuse reports, identity verification — I needed real role separation, and getting it wrong had actual consequences, not just inconvenience.

Here’s how I built role-based access control with JWT, and a genuine privilege-escalation bug I found and fixed along the way.

Implementing JWT Authorization with User Moderator and Admin Roles in Node.js

The problem: why three roles, not one

For a basic blog or store, binary auth (logged in / not logged in) is usually enough. For a platform where users meet strangers in person and can trigger real emergency alerts, one tier of “authenticated” is actually dangerous.

A few concrete reasons three roles were necessary:

  • Ordinary users shouldn’t be able to see each other’s raw data. A user needs to discover nearby people, start safety timers, and trigger their own SOS — but if any logged-in user could query abuse reports, read emergency dispatch logs, or resolve someone else’s SOS as a “false alarm,” the platform itself becomes a tool for exactly the kind of harm it’s supposed to prevent.
  • Safety decisions need neutral review. When an SOS or a harassment report comes in, resolving it has to go through someone who isn’t a party to the incident. A user should never be able to dismiss their own report or someone else’s emergency alert.
  • Even trusted staff shouldn’t have unlimited power. A moderator handling day-to-day reports doesn’t need the ability to inspect raw database collections, change system configuration, or promote other accounts to admin. That’s a separate, smaller tier of trust.

So the roles ended up being user, moderator, and super_admin, each with a strictly defined and strictly limited set of permissions — including limits on what the top role’s own staff can do to each other.

How it works: the JWT payload, and what’s deliberately left out of it

The first real design decision was what to actually put inside the JWT. The obvious-seeming approach is to embed the role directly:

{ "userId": "abc123", "role": "moderator" }

I didn’t do that, on purpose. A JWT is stateless — once issued, the server can’t reach into it and change what’s inside. If a moderator account gets compromised, is fired, or starts abusing their access, and their role is baked into a 30-day token, there’s no way to revoke that access without maintaining a token blocklist (which defeats a lot of the point of using stateless JWTs in the first place).

So the token carries only the user ID:

// Sign the token with only userId — role is never embedded
const token = jwt.sign({ userId: user._id }, getJwtSecret(), { expiresIn: '30d' });

The role gets resolved fresh from the database on every authenticated request instead:

export async function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Authentication required.' });
  }

  const token = authHeader.split(' ')[1];
  try {
    const payload = jwt.verify(token, getJwtSecret());
    const user = await User.findById(payload.userId);
    if (!user) {
      return res.status(401).json({ error: 'User not found.' });
    }

    // Real-time suspension check on every request, not just at login
    const suspension = await Suspension.findOne({
      userId: user._id,
      isActive: true,
      $or: [{ expiresAt: { $exists: false } }, { expiresAt: { $gt: new Date() } }],
    });
    if (suspension) {
      return res.status(403).json({ error: `Account suspended: ${suspension.reason}` });
    }

    req.user = user;
    next();
  } catch {
    res.status(401).json({ error: 'Invalid or expired session token.' });
  }
}

The trade-off is an extra database read on every request instead of a pure in-memory token check. In practice that cost is small next to what it buys: promote or demote someone, suspend an account, and it takes effect on their very next request — no waiting for a token to expire, no forced re-login, no blocklist infrastructure to maintain.

Role gating on top of that is a small, focused middleware:

export function requireAdmin(req, res, next) {
  if (!req.user || !['super_admin', 'moderator'].includes(req.user.role)) {
    return res.status(403).json({ error: 'Administrator privileges required.' });
  }
  next();
}

Chained together on a sensitive route, it reads clearly:

router.use(authenticate);
router.use(requireAdmin);
// everything below here requires a valid token AND staff-level role

For actions that mix ownership and staff override — like resolving an SOS incident — the check needs both angles at once:

const isOwner = sos.userId.toString() === req.user._id.toString();
const isStaff = ['super_admin', 'moderator'].includes(req.user.role);

if (!isOwner && !isStaff) {
  return res.status(403).json({ error: 'You do not have permission to resolve this incident.' });
}

Decisions: the server is the only source of truth

The client-side route guard (a ProtectedRoute wrapper in React) exists purely for user experience — it shows a clean “Access Restricted” message instead of letting someone stumble into a broken admin page. It carries zero actual security weight. Every real enforcement happens server-side, on the assumption that any client request could be coming from Postman or curl rather than the actual app UI.

That assumption turned out to matter more than I expected.

The bug that actually scared me: mass-assignment privilege escalation

While testing the “edit profile” endpoint, I found something I should have caught earlier: the update handler was written as

User.findByIdAndUpdate(req.user._id, req.body)

That takes whatever the client sends and writes it straight onto the user document. Which meant a request body of { "bio": "hi", "role": "super_admin" } would work exactly as written — a user could promote themselves to full admin by editing their own bio and sneaking an extra field into the same request.

This is a classic mass-assignment vulnerability, and it’s exactly the kind of bug that’s invisible in normal use (nobody accidentally sends a role field) but trivial to exploit deliberately once someone thinks to try it.

The fix was strict field whitelisting — building the update object explicitly from only the fields that should ever be user-editable, rather than trusting the request body wholesale:

const allowedFields = ['bio', 'occupation', 'city', 'interests', 'lookingFor', 'showLocation'];
const safeUpdate = {};
for (const field of allowedFields) {
  if (field in req.body) safeUpdate[field] = req.body[field];
}
await User.findByIdAndUpdate(req.user._id, safeUpdate);

role, trustScore, isVerified, and anything else sensitive simply can’t reach the database through this endpoint anymore, no matter what the client sends. The lesson generalized well beyond this one route: never pass a request body directly into an update call. Always build the update object field by field from an explicit allowlist.

Other things that gave me trouble

The “still-valid token, banned user” gap. A stateless JWT is normally valid until it expires — up to 30 days in this case. Without an extra check, a suspended user could keep hitting the API with a technically-valid token for the rest of that window. The fix is the suspension lookup inside authenticate() shown above — it runs on every request, so a suspension takes effect immediately regardless of how much life is left on the token.

WebSocket connections don’t go through Express middleware. HTTP route protection doesn’t automatically extend to persistent Socket.IO connections. Without separate handling, any authenticated user (any role) could potentially join a broadcast channel meant only for staff and eavesdrop on emergency dispatch events. The fix was verifying the JWT and role during the socket handshake itself (io.use(...)), and only allowing sockets belonging to moderators or admins to join the staff-only room.

Literal "Bearer undefined" strings from the mobile client. On the native mobile build, if the API client fired a request before local storage finished hydrating on app boot, it would sometimes send Authorization: Bearer undefined or Bearer null — not a missing header, but the literal string. The token verification step needs to explicitly treat those as invalid rather than assuming any string after Bearer is worth attempting to verify, or it produces a confusing crash instead of a clean 401.

What I’d tell someone building role-based access control

  • Don’t put mutable claims like role inside a stateless JWT if you ever want to revoke or change them before the token naturally expires. Keep the token minimal (just an identity), and resolve permissions fresh from the database.
  • Never trust a request body into an update call directly. Explicit field allowlists are cheap insurance against mass-assignment bugs that are otherwise invisible until someone actively tries to exploit them.
  • Client-side route guards are UX, not security. Assume every request could come from a tool that bypasses your UI entirely, and enforce every real check server-side.
  • Remember that WebSockets need their own auth path. Express middleware protecting your REST routes doesn’t automatically apply to persistent socket connections.

Have you run into mass-assignment bugs or other privilege-escalation surprises in your own projects? I’d like to hear how you caught them. Reach out via the Contact page.

Building a Multi-Platform React App with Capacitor (Web Mobile & Admin) · knowabteverything.com

Privacy-Preserving Proximity Discovery with MongoDB Geospatial Queries

Building an SOS Emergency Pipeline: From One Tap to Dispatch · knowabteverything.com

Leave a Comment