StackDevLife
How to Build Secure Node.js APIs with JWT & Role-Based Access
Back to Blog

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.

SB

Sandeep Bansod

June 1, 202512 min read
Share:
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.

[@portabletext/react] Unknown block type "callout", specify a component for it in the `components.types` prop

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.

auth/generateToken.js
JavaScript
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',
  });
}
[@portabletext/react] Unknown block type "callout", specify a component for it in the `components.types` prop

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.

[@portabletext/react] Unknown block type "callout", specify a component for it in the `components.types` prop

The request lifecycle looks like: Request → verifyToken() → authorise('admin') → Controller. Simple, composable, testable.

middleware/auth.js
JavaScript
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:

routes/content.js
JavaScript
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.

[@portabletext/react] Unknown block type "callout", specify a component for it in the `components.types` prop

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.

auth/tokenService.js
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 };
}
[@portabletext/react] Unknown block type "callout", specify a component for it in the `components.types` prop

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.

routes/auth.js — /refresh endpoint
JavaScript
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.

[@portabletext/react] Unknown block type "callout", specify a component for it in the `components.types` prop

Always scope queries to the authenticated user's context. Never trust a raw ID from the URL without pairing it to the session identity:

controllers/invoiceController.js
JavaScript
// 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' });
[@portabletext/react] Unknown block type "callout", specify a component for it in the `components.types` prop

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.

SB

Sandeep Bansod

I'm a Front‑End Developer located in India focused on website look great, work fast and perform well with a seamless user experience. Over the years I worked across different areas of digital design, web development, email design, app UI/UX and developemnt.

Related Articles

You might also enjoy these

Stay in the loop

Get articles on technology, health, and lifestyle delivered to your inbox.No spam — unsubscribe anytime.