October 1, 2026
The Anti-Cargo-Cult Guide to Node.js JWT & React CORS (ES6 + RBAC Edition)
Build production-ready Node.js JWT auth with dual tokens, strict CORS for React, and RBAC, and learn why each piece exists.

By Livingstone- Full Stack Developer
6 min read
You have a React frontend, a Node.js API, and a ticket that says "add login." So you do what everyone does. You paste a tutorial snippet, store the token in localStorage, set cors({ origin: '*' }) to make the red console errors disappear, and ship it. It works, and that's the problem.
As cloud technologist Kelsey Hightower warns, many professionals
"have no idea why they're doing what they're doing. They're just assigned the Jira ticket, and off they go like little robots."
When it comes to security, cutting such corners creates immediate architectural debt.
Let's break the cycle of cargo-cult engineering. In this guide, we will build a production-hardened, zero-session authentication system from scratch using modern ES6 Modules, secure CORS configurations, a dual-token JWT architecture, and Role-Based Access Control (RBAC).
The Dual-Token Architecture
If your application takes a JWT and saves it into the browser's localStorage, it is vulnerable to Cross-Site Scripting (XSS) token theft. If an attacker injects a rogue script into your frontend via a compromised npm package or analytics script, they can execute localStorage.getItem('token') and instantly hijack the user session.
To completely bypass the localStorage trap, we implement a Dual-Token Architecture:
- Access Token: Short-lived (15 minutes). Sent to the client in the body of the JSON login response and kept entirely in JavaScript volatile memory (RAM) or ephemeral state. It includes the user's roles for quick enforcement.
- Refresh Token: Long-lived (7 days). Sent to the client inside an httpOnly cookie. The browser automatically attaches this cookie to token-refresh requests, but client-side JavaScript is completely blocked from reading or copying it.
The Backend: Node.js (ES6) Server with RBAC
Ensure your package.json contains the key "type": "module" so Node knows to leverage native ES6 Imports/Exports instead of older CommonJS require statements.
{
"name": "node-secure-auth-rbac",
"version": "1.0.0",
"type": "module",
"dependencies": {
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2"
}
}{
"name": "node-secure-auth-rbac",
"version": "1.0.0",
"type": "module",
"dependencies": {
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2"
}
}Here is your production-hardened server.js:
import express from 'express';
import jwt from 'jsonwebtoken';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import dotenv from 'dotenv';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 5000;
// 1. HARDENED CORS CONFIGURATION FOR REACT APPS
const ALLOWED_ORIGINS = [
'http://localhost:5173', // Standard local React/Vite development server
'http://localhost:3000' // Legacy Create-React-App local port
];
app.use(cors({
origin: (origin, callback) => {
if (!origin) return callback(null, true);
if (ALLOWED_ORIGINS.indexOf(origin) === -1) {
const errorMsg = 'The CORS policy for this system blocks access from the specified origin.';
return callback(new Error(errorMsg), false);
}
return callback(null, true);
},
credentials: true, // MANDATORY: Permits the browser to accept and transmit cross-origin cookies
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
app.use(cookieParser());
// Mock database with Role definitions
const USERS = [
{ id: "101", email: "admin@example.com", password: "hashed_password", roles: ["ADMIN", "USER"] },
{ id: "102", email: "user@example.com", password: "hashed_password", roles: ["USER"] }
];
/**
* 2. ENDPOINT: POST /api/login
*/
app.post('/api/login', (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: "Email and password parameters are mandatory." });
}
const user = USERS.find(u => u.email === email);
if (!user) {
return res.status(401).json({ message: "Invalid credentials provided." });
}
// Embed roles inside the short-lived access token payload
const accessToken = jwt.sign(
{ userId: user.id, roles: user.roles },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.json({ accessToken });
});
/**
* 3. CORE MIDDLEWARE: Base Token Verification
*/
export const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Extract token from "Bearer <token>"
if (!token) {
return res.status(401).json({ message: "Authorization token is missing." });
}
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, decodedUserPayload) => {
if (err) {
return res.status(403).json({ message: "Token verification failed or expired." });
}
req.user = decodedUserPayload; // Attach payload (including roles) to the request
next();
});
};
/**
* 4. RBAC MIDDLEWARE ROUTE GUARD
* A higher-order function that generates middleware to check user permissions dynamically.
*/
export const requireRoles = (allowedRoles) => {
return (req, res, next) => {
if (!req.user || !req.user.roles) {
return res.status(403).json({ message: "Access denied. Roles missing from context." });
}
// Check if the user has at least one of the allowed roles
const hasPermission = req.user.roles.some(role => allowedRoles.includes(role));
if (!hasPermission) {
return res.status(403).json({ message: "Forbidden: You do not have the required role permissions." });
}
next();
};
};
/**
* 5. PROTECTED RBAC ROUTES
*/
// Publicly accessible to any valid user
app.get('/api/dashboard', authenticateToken, (req, res) => {
res.json({ message: `Welcome User ${req.user.userId}.` });
});
// Admin-only route guard enforcement
app.get('/api/admin/settings', authenticateToken, requireRoles(['ADMIN']), (req, res) => {
res.json({ message: "Welcome to the secure administrative system settings panel." });
});
/**
* 6. ENDPOINT: POST /api/refresh
*/
app.post('/api/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ message: "Refresh token missing." });
}
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, decodedUserPayload) => {
if (err) return res.status(403).json({ message: "Invalid refresh credentials." });
// Retrieve current state of user to ensure roles haven't changed since token issuance
const user = USERS.find(u => u.id === decodedUserPayload.userId);
if (!user) return res.status(403).json({ message: "User no longer exists." });
const newAccessToken = jwt.sign(
{ userId: user.id, roles: user.roles },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
});
});
app.listen(PORT, () => console.log(`๐ Production RBAC auth cluster active on port ${PORT}`));import express from 'express';
import jwt from 'jsonwebtoken';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import dotenv from 'dotenv';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 5000;
// 1. HARDENED CORS CONFIGURATION FOR REACT APPS
const ALLOWED_ORIGINS = [
'http://localhost:5173', // Standard local React/Vite development server
'http://localhost:3000' // Legacy Create-React-App local port
];
app.use(cors({
origin: (origin, callback) => {
if (!origin) return callback(null, true);
if (ALLOWED_ORIGINS.indexOf(origin) === -1) {
const errorMsg = 'The CORS policy for this system blocks access from the specified origin.';
return callback(new Error(errorMsg), false);
}
return callback(null, true);
},
credentials: true, // MANDATORY: Permits the browser to accept and transmit cross-origin cookies
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
app.use(cookieParser());
// Mock database with Role definitions
const USERS = [
{ id: "101", email: "admin@example.com", password: "hashed_password", roles: ["ADMIN", "USER"] },
{ id: "102", email: "user@example.com", password: "hashed_password", roles: ["USER"] }
];
/**
* 2. ENDPOINT: POST /api/login
*/
app.post('/api/login', (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: "Email and password parameters are mandatory." });
}
const user = USERS.find(u => u.email === email);
if (!user) {
return res.status(401).json({ message: "Invalid credentials provided." });
}
// Embed roles inside the short-lived access token payload
const accessToken = jwt.sign(
{ userId: user.id, roles: user.roles },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.json({ accessToken });
});
/**
* 3. CORE MIDDLEWARE: Base Token Verification
*/
export const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Extract token from "Bearer <token>"
if (!token) {
return res.status(401).json({ message: "Authorization token is missing." });
}
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, decodedUserPayload) => {
if (err) {
return res.status(403).json({ message: "Token verification failed or expired." });
}
req.user = decodedUserPayload; // Attach payload (including roles) to the request
next();
});
};
/**
* 4. RBAC MIDDLEWARE ROUTE GUARD
* A higher-order function that generates middleware to check user permissions dynamically.
*/
export const requireRoles = (allowedRoles) => {
return (req, res, next) => {
if (!req.user || !req.user.roles) {
return res.status(403).json({ message: "Access denied. Roles missing from context." });
}
// Check if the user has at least one of the allowed roles
const hasPermission = req.user.roles.some(role => allowedRoles.includes(role));
if (!hasPermission) {
return res.status(403).json({ message: "Forbidden: You do not have the required role permissions." });
}
next();
};
};
/**
* 5. PROTECTED RBAC ROUTES
*/
// Publicly accessible to any valid user
app.get('/api/dashboard', authenticateToken, (req, res) => {
res.json({ message: `Welcome User ${req.user.userId}.` });
});
// Admin-only route guard enforcement
app.get('/api/admin/settings', authenticateToken, requireRoles(['ADMIN']), (req, res) => {
res.json({ message: "Welcome to the secure administrative system settings panel." });
});
/**
* 6. ENDPOINT: POST /api/refresh
*/
app.post('/api/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ message: "Refresh token missing." });
}
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, decodedUserPayload) => {
if (err) return res.status(403).json({ message: "Invalid refresh credentials." });
// Retrieve current state of user to ensure roles haven't changed since token issuance
const user = USERS.find(u => u.id === decodedUserPayload.userId);
if (!user) return res.status(403).json({ message: "User no longer exists." });
const newAccessToken = jwt.sign(
{ userId: user.id, roles: user.roles },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
});
});
app.listen(PORT, () => console.log(`๐ Production RBAC auth cluster active on port ${PORT}`));Line-by-Line Breakdown: The Engineering Behind the Code
1. How the RBAC Middleware Operates
export const requireRoles = (allowedRoles) => {
return (req, res, next) => { ... }
}export const requireRoles = (allowedRoles) => {
return (req, res, next) => { ... }
}This is a standard higher-order function. It accepts an array of strings representing permitted permissions (e.g., ['ADMIN']) and outputs a standard Express middleware function execution block.
Because it relies on the req.user payload extracted by the upstream authenticateToken middleware, we can verify permissions directly in-memory without invoking a database lookup on every single client request.
2. The credentials: true Secret
By default, standard browser cross-origin constraints strip out incoming cookies entirely when running scripts across separate domains (like localhost:5173 talking to localhost:5000).
Toggling credentials: true inside the CORS package configuration changes this behavior, telling the browser engine that it has explicit permission to store and transmit cross-origin cookies.
3. Cross-Origin sameSite Dynamics
Look at the login cookie definition:
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax'sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax'In production, your frontend and backend run on distinct domain spaces. To safely route cookies between separate domains, browsers demand that sameSite be explicitly set to 'none', which additionally requires secure: true (HTTPS).
However, if you hardcode 'none' during local development over unencrypted local HTTP (http://localhost), your browser will reject the cookie. Using an environment-driven ternary operator keeps local development functional without compromising production security flags.
Connecting the Pieces: The React Token Interceptor
To keep the application UX seamless, your frontend application needs to transparently refresh access tokens before they expire so the user doesn't get abruptly kicked out to the login page.
We can automate this process using an interceptor provided by Axios. This instance intercepts network layer errors, automatically calls our silent /api/refresh token route if a request fails due to an expired access token, and retries the original request seamlessly.
// apiInstance.js
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:5000/api',
withCredentials: true // MANDATORY: Instructs Axios to append the secure httpOnly cookie automatically
});
// Cache variables for application volatile memory storage
let memoryToken = null;
export const setMemoryToken = (token) => {
memoryToken = token;
};
// Outgoing Request Interceptor: Append the volatile memory access token to headers
api.interceptors.request.use((config) => {
if (memoryToken) {
config.headers['Authorization'] = `Bearer ${memoryToken}`;
}
return config;
}, (error) => Promise.reject(error));
// Incoming Response Interceptor: Watch for 403 authorization failures
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If the server returns a 403 (Token Expired) and this isn't a retry attempt
if (error.response?.status === 403 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Call the silent refresh route. The browser automatically handles the httpOnly cookie!
const refreshResponse = await axios.post(
'http://localhost:5000/api/refresh',
{},
{ withCredentials: true }
);
const newAccessToken = refreshResponse.data.accessToken;
setMemoryToken(newAccessToken);
// Update headers and replay the failed request
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
return api(originalRequest);
} catch (refreshError) {
// The refresh token itself is expired or invalid -> Kill session and route to login
setMemoryToken(null);
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default api;// apiInstance.js
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:5000/api',
withCredentials: true // MANDATORY: Instructs Axios to append the secure httpOnly cookie automatically
});
// Cache variables for application volatile memory storage
let memoryToken = null;
export const setMemoryToken = (token) => {
memoryToken = token;
};
// Outgoing Request Interceptor: Append the volatile memory access token to headers
api.interceptors.request.use((config) => {
if (memoryToken) {
config.headers['Authorization'] = `Bearer ${memoryToken}`;
}
return config;
}, (error) => Promise.reject(error));
// Incoming Response Interceptor: Watch for 403 authorization failures
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If the server returns a 403 (Token Expired) and this isn't a retry attempt
if (error.response?.status === 403 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Call the silent refresh route. The browser automatically handles the httpOnly cookie!
const refreshResponse = await axios.post(
'http://localhost:5000/api/refresh',
{},
{ withCredentials: true }
);
const newAccessToken = refreshResponse.data.accessToken;
setMemoryToken(newAccessToken);
// Update headers and replay the failed request
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
return api(originalRequest);
} catch (refreshError) {
// The refresh token itself is expired or invalid -> Kill session and route to login
setMemoryToken(null);
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default api;The Final Security Checklist
Before pushing your new architecture live, run through this checklist to ensure everything is locked down:
- Entropy Audit: Ensure your ACCESS_TOKEN_SECRET and REFRESH_TOKEN_SECRET environment variables use cryptographic, high-entropy 256-bit strings generated via Node's native engine (crypto.randomBytes(64).toString('hex')).
- Transport Constraints: Verify that your production environment uses a global middleware rule enforcing HTTP Strict Transport Security (HSTS) headers to block unencrypted connections entirely.
Token Sync Strategy: When modifying user roles in the database, invalidate or shorten refresh tokens so changes register across the app without a full 7-day delay.