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'
});
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);
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 />}
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.