
How to Build Secure Node.js APIs with JWT & Role-Based Access
Bad auth doesn't announce itself. Here's how Slack, Netflix, GitHub, and Stripe solved JWT and role-based access — and what you can steal from them.
Bad auth doesn't announce itself. It quietly lets the wrong user read your data, call your admin routes, or impersonate someone else — until it's too late. Here's how three real product teams solved it, and what you can steal from them.
01 — The Slack Problem: Multi-Workspace Auth
When Slack scaled to millions of users across thousands of workspaces, a single token per user broke down fast. A user in 10 workspaces had 10 different permission contexts — admin in one, guest in another, standard in the rest.
The fix is to embed the user's role and workspace context inside the token itself. Sign it, and your API can trust it without touching the DB.
const jwt = require('jsonwebtoken');
function generateToken(user, workspace) {
const payload = {
sub: user.id,
email: user.email,
workspaceId: workspace.id,
role: workspace.userRole, // 'admin' | 'member' | 'guest'
plan: workspace.plan, // 'free' | 'pro' | 'enterprise'
iat: Math.floor(Date.now() / 1000),
};
return jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: '15m', // keep access tokens SHORT
algorithm: 'HS256',
});
}02 — The Netflix Problem: Role-Based Route Guards
Netflix serves three types of users on the same API: regular subscribers, content creators with upload access, and internal admins. Each group hits the same endpoints but should only see — or do — certain things.
The request lifecycle looks like: Request → verifyToken() → authorise('admin') → Controller. Simple, composable, testable.
const jwt = require('jsonwebtoken');
// Step 1 — Verify the token is real and not expired
const verifyToken = (req, res, next) => {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer '))
return res.status(401).json({ error: 'Missing token' });
try {
req.user = jwt.verify(auth.slice(7), process.env.JWT_SECRET);
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
};
// Step 2 — Check the user's role against what's allowed
const authorise = (...allowedRoles) => (req, res, next) => {
if (!allowedRoles.includes(req.user.role))
return res.status(403).json({ error: 'Insufficient permissions' });
next();
};
module.exports = { verifyToken, authorise };Then wire it to your routes cleanly:
const { verifyToken, authorise } = require('../middleware/auth');
// Any logged-in user can browse
router.get('/browse', verifyToken, contentController.browse);
// Only creators and admins can upload
router.post('/upload', verifyToken, authorise('creator', 'admin'), contentController.upload);
// Admins only
router.delete('/content/:id', verifyToken, authorise('admin'), contentController.remove);What your role matrix should look like
Map every role against every action before you write a line of code. viewer: browse only. creator: browse + upload + delete own content. admin: full access including user management. If it's not in the matrix, it doesn't ship.
03 — The GitHub Problem: Token Expiry & Refresh Flow
In 2021, GitHub launched fine-grained personal access tokens because coarse 'all-or-nothing' tokens were being leaked constantly. When a token lives forever and has full access, one leak equals full compromise.
The solution: short-lived access tokens (15 minutes) paired with a separate, single-use refresh token (7 days). The access token dies fast. The refresh token stays in an httpOnly cookie — never exposed to JavaScript.
const crypto = require('crypto');
function issueTokenPair(user, res) {
// Short-lived access token in response body
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' }
);
// Long-lived refresh token — httpOnly cookie only
const refreshToken = crypto.randomBytes(64).toString('hex');
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // JS cannot read this
secure: true, // HTTPS only
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
// Store hashed refresh token in DB
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
await db.refreshTokens.create({ userId: user.id, tokenHash: hash });
return { accessToken };
}When the access token expires, the client hits your refresh endpoint. You validate the cookie, rotate the refresh token (one-time use), and issue a fresh pair.
router.post('/refresh', async (req, res) => {
const token = req.cookies.refreshToken;
if (!token) return res.status(401).json({ error: 'No refresh token' });
const hash = crypto.createHash('sha256').update(token).digest('hex');
const stored = await db.refreshTokens.findOne({ tokenHash: hash });
if (!stored || stored.used)
return res.status(401).json({ error: 'Invalid or reused token' });
// Invalidate old token before issuing a new one
await db.refreshTokens.update({ tokenHash: hash }, { used: true });
const user = await db.users.findById(stored.userId);
return issueTokenPair(user, res);
});04 — The Stripe Problem: Row-Level Security
Role checks at the route level aren't enough. Stripe's API handles thousands of merchants — each with their own customers, invoices, and payment methods. A role of 'merchant' means nothing if merchant A can accidentally fetch merchant B's invoice.
Always scope queries to the authenticated user's context. Never trust a raw ID from the URL without pairing it to the session identity:
// WRONG — Any authenticated user can read any invoice
const invoice = await Invoice.findById(req.params.id);
// RIGHT — Scope to the requesting merchant always
const invoice = await Invoice.findOne({
_id: req.params.id,
merchantId: req.user.sub, // from the verified JWT
});
if (!invoice) return res.status(404).json({ error: 'Not found' });05 — The Checklist: Don't Ship Without It
Before your next auth PR merges, run through this. Each item has caused a real breach somewhere:
- Access tokens expire in ≤ 15 minutes
- JWT secret is ≥ 256 bits and stored in env, not code
- Refresh tokens are single-use, rotated on every refresh
- Refresh token stored in httpOnly cookie, not localStorage
- All DB queries scoped to req.user.sub
- Role check runs as middleware, not inside the controller
- Token invalidation list exists for logout / revocation
- Rate limiting on /login and /refresh endpoints
The bottom line
JWT is a delivery mechanism, not a security model. The security comes from what you put in the token, how long it lives, how you scope your queries, and how you rotate credentials when things go wrong.
Slack, Netflix, GitHub, and Stripe didn't get this right on day one either. They got burned — or almost burned — and built the patterns above out of necessity. Now you don't have to learn the hard way.
Related Articles
You might also enjoy these
How to Create and Publish Your First npm Package (and Use It in Angular)
This guide explains how to convert a TypeScript utility into a reusable npm package and use it in an Angular app. It covers project setup, configuration, publishing, and how to install, import, and run it in Angular, including common issues and solutions.
Environment Variables You're Leaking to the Frontend Without Knowing It
You added NEXT_PUBLIC_ to your API key 'just to test something quickly.' That was six months ago. It's still there. Here's what's actually leaking — and how to stop it.
Stay in the loop
Get articles on technology, health, and lifestyle delivered to your inbox.
No spam — unsubscribe anytime.
