When I joined Geri Care to build the DNB Portal, one of the first real problems I had to solve was access control. The app needed three distinct user types โ€” Admin, Supervisor, and Trainee โ€” each with completely different permissions. A Trainee should never see another trainee's data. A Supervisor can manage trainees but can't touch system settings. An Admin controls everything.

I'd used JWTs before for basic login flows, but this was the first time I had to think carefully about role enforcement at the API level. Here's exactly how I built it, what tripped me up, and what I'd do the same again.

Why JWT over sessions?

The app was deployed on GCP Cloud Run โ€” stateless containers. Sessions stored server-side would break as soon as a new container instance spun up. JWTs are self-contained and stateless, which made them the natural fit. The token carries everything the server needs to verify identity and role โ€” no database lookup required on every request.

The token structure

I kept the payload minimal. Only what the middleware needs to make an access decision goes in the token:

// Payload signed at login
const payload = {
  userId:   user.id,
  email:    user.email,
  role:     user.role,      // 'admin' | 'supervisor' | 'trainee'
  centreId: user.centre_id  // which centre they belong to
};

const token = jwt.sign(payload, process.env.JWT_SECRET, {
  expiresIn: '8h'
});
Keep the JWT payload small. Don't put sensitive data or large objects in it โ€” the token travels in every request header and is base64-decoded (not encrypted) by anyone who intercepts it.

The middleware

I wrote a single verifyRole function that takes an array of allowed roles. Any route can declare exactly who's allowed in:

const jwt = require('jsonwebtoken');

const verifyRole = (allowedRoles) => (req, res, next) => {
  const authHeader = req.headers['authorization'];

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    if (!allowedRoles.includes(decoded.role)) {
      return res.status(403).json({ error: 'Access denied' });
    }

    req.user = decoded; // attach decoded payload for downstream use
    next();

  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
};

Applying it to routes

With the middleware ready, protecting a route is one line:

// Only admins can create users
router.post('/users', verifyRole(['admin']), createUser);

// Admins and supervisors can view trainee progress
router.get('/trainees/:id/progress', verifyRole(['admin', 'supervisor']), getProgress);

// Trainees can only access their own logbook
router.get('/logbook', verifyRole(['admin', 'supervisor', 'trainee']), getLogbook);
A trainee hitting GET /logbook passes the role check โ€” but that's not enough. Inside getLogbook, I still filter by req.user.userId so a trainee can never read another trainee's entries. Role-based middleware is the gate; row-level filtering is the lock.

Protecting the React frontend

On the client, I stored the token in localStorage and decoded it with jwt-decode (no verification โ€” that's the server's job) to decide which UI elements to show:

import { jwtDecode } from 'jwt-decode';

const token = localStorage.getItem('token');
const { role } = jwtDecode(token);

// Render admin panel only for admins
{role === 'admin' && <AdminPanel />}
Frontend role checks are UX only โ€” they hide buttons and routes. Never rely on them for real security. Every sensitive operation must be re-validated on the server.

The mistake I made

Early on, I signed tokens with a hardcoded secret string directly in the code. It worked fine in development, then I committed it to Git. Caught it before pushing to the public repo, but it was a close call. The fix: always use process.env.JWT_SECRET and make sure the secret is a long random string (I use a 64-character hex value) stored in GCP Secret Manager.

What I'd do differently

If I were building this again, I'd add refresh tokens โ€” short-lived access tokens (15 min) with a separate long-lived refresh token stored in an httpOnly cookie. The 8-hour expiry I used is a reasonable trade-off for an internal healthcare app, but for a public-facing product the window is too wide.

This pattern โ€” verifyRole(allowedRoles) middleware plus server-side row filtering โ€” has held up well in production. It's simple enough to reason about, easy to test, and trivial to extend when new roles are added.