
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.
TL;DR
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.
Enjoying this article?
Get new articles, tips, and fixes delivered straight to your inbox — free, no spam.
Was this article helpful?
Let me know if this was useful — it helps me write more content like this.
What's next?
Daily Challenge
Put it into practice
Try today's hands-on dev challenge — takes under 5 minutes.
Open challengeRelated Tool
Timestamp Converter
Free browser-based dev tool — no sign-up needed.
Open toolQuick Tip
30-second dev lessons
Browse tips, fixes, and bugs — bite-sized and practical.
Browse tipsNew challenge and tips drop daily. Come back tomorrow to keep your streak going.
Related Articles
You might also enjoy these
Your Node.js API Is Doing the Same Work Over and Over — Redis Fixes That
Your API is running the same database query hundreds of times a minute. Redis caching fixes that — response times drop from 400ms to under 10ms. Here's the complete setup: client config, cache-aside pattern, per-user keys, cache invalidation, and a reusable Express middleware.
Rate Limiting Isn't Optional - Here How to Actually Implement It in Node.js
No rate limiting means any client can hit your API as many times as it wants. This guide walks through the right way to implement it in Node.js - from express-rate-limit basics to Redis-backed sliding windows and layered per-route limits that work in production.
CORS Isn't a Bug - It's Your API Trying to Warn You (And You Ignored It)
Stop fighting CORS. Understand preflight requests, credentials, wildcard mistakes. CORS isn't a bug—it's your API warning you about real security issues.



Comments
Leave a Comment
All comments are reviewed before publishing