Implementing JWT Authentication for Secure SaaS API Access
The Problem
SaaS applications need secure API authentication that scales across distributed systems. Session-based authentication breaks when you add load balancers or microservices—each server needs to share session state through Redis or a database, creating performance bottlenecks and single points of failure. API keys are simpler but lack expiration, user context, and granular permissions. You need stateless authentication that works across multiple services, expires automatically, supports refresh tokens for long-lived sessions, carries user roles and permissions in the token payload, and prevents common attacks like token theft, replay attacks, and privilege escalation. JWT (JSON Web Tokens) solves this by encoding authentication data in signed tokens that servers verify cryptographically without database lookups. However, implementing JWTs securely requires understanding token signing algorithms, refresh token rotation, secure storage patterns, token blacklisting for logout, and protecting against XSS and CSRF attacks. This tutorial provides production-ready code for a complete JWT authentication system with access/refresh tokens, role-based permissions, and security best practices.
Tech Stack & Prerequisites
- Node.js v18+ with npm
- Express.js 4.18+ for API server
- jsonwebtoken 9.0+ for JWT creation/verification
- bcrypt 5.1+ for password hashing
- PostgreSQL 14+ for user storage
- dotenv for environment variables
- pg 8.11+ for PostgreSQL connection
- cookie-parser 1.4+ for secure cookie handling
- express-validator 7.0+ for input validation
- helmet 7.1+ for security headers
- cors 2.8+ for CORS configuration
Required Knowledge:
- Understanding of HTTP headers and cookies
- Basic cryptography concepts (hashing, signing)
- REST API design patterns
- Database schema design
Optional:
- Redis for token blacklisting (production)
- React/Vue for frontend implementation
- Docker for containerization
Step-by-Step Implementation
Step 1: Setup
Initialize the project:
mkdir jwt-auth-saas
cd jwt-auth-saas
npm init -y
npm install express jsonwebtoken bcrypt pg dotenv cookie-parser express-validator helmet cors
npm install --save-dev nodemon
Create project structure:
mkdir src middleware routes db config utils
touch src/server.js middleware/auth.js routes/auth.js routes/users.js
touch db/database.js db/schema.sql config/jwt.js utils/tokenManager.js
touch .env .gitignore
```
Your structure should be:
```
jwt-auth-saas/
├── src/
│ └── server.js
├── middleware/
│ └── auth.js
├── routes/
│ ├── auth.js
│ └── users.js
├── db/
│ ├── database.js
│ └── schema.sql
├── config/
│ └── jwt.js
├── utils/
│ └── tokenManager.js
├── .env
├── .gitignore
└── package.json
db/schema.sql — Database schema:
-- Users table
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
role VARCHAR(50) DEFAULT 'user', -- 'user', 'admin', 'moderator'
is_active BOOLEAN DEFAULT true,
email_verified BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP
);
-- Refresh tokens table
CREATE TABLE IF NOT EXISTS refresh_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
revoked_at TIMESTAMP,
replaced_by_token VARCHAR(255),
created_by_ip VARCHAR(45),
revoked_by_ip VARCHAR(45)
);
-- Token blacklist (for logout/revocation)
CREATE TABLE IF NOT EXISTS token_blacklist (
id SERIAL PRIMARY KEY,
token_jti VARCHAR(255) UNIQUE NOT NULL, -- JWT ID claim
expires_at TIMESTAMP NOT NULL,
blacklisted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
reason VARCHAR(255)
);
-- User sessions (optional, for tracking active sessions)
CREATE TABLE IF NOT EXISTS user_sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
refresh_token_id INTEGER REFERENCES refresh_tokens(id) ON DELETE CASCADE,
device_info JSONB,
ip_address VARCHAR(45),
user_agent TEXT,
last_activity TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- API keys (for service-to-service auth)
CREATE TABLE IF NOT EXISTS api_keys (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
key_hash VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(100),
permissions JSONB,
expires_at TIMESTAMP,
last_used_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
revoked_at TIMESTAMP
);
-- Indexes for performance
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);
CREATE INDEX idx_token_blacklist_jti ON token_blacklist(token_jti);
CREATE INDEX idx_sessions_user ON user_sessions(user_id);
-- Cleanup function for expired tokens
CREATE OR REPLACE FUNCTION cleanup_expired_tokens()
RETURNS void AS $$
BEGIN
DELETE FROM token_blacklist WHERE expires_at < NOW();
DELETE FROM refresh_tokens WHERE expires_at < NOW() AND revoked_at IS NULL;
END;
$$ LANGUAGE plpgsql;
Run schema:
psql -U your_username -d your_database -f db/schema.sql
package.json — Add scripts:
{
"name": "jwt-auth-saas",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js"
},
"dependencies": {
"bcrypt": "^5.1.1",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-validator": "^7.0.1",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}
Update .gitignore:
echo "node_modules/
.env
*.log
private.key
public.key" > .gitignore
Step 2: Configuration
.env — Store secrets securely:
# JWT Configuration
JWT_ACCESS_SECRET=your-super-secret-access-token-key-min-32-chars
JWT_REFRESH_SECRET=your-super-secret-refresh-token-key-min-32-chars
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
# Alternative: Use RSA keys for asymmetric signing (recommended for production)
# JWT_PRIVATE_KEY_PATH=./private.key
# JWT_PUBLIC_KEY_PATH=./public.key
# PostgreSQL Configuration
PG_HOST=localhost
PG_PORT=5432
PG_DATABASE=jwt_auth_db
PG_USER=your_username
PG_PASSWORD=your_password
# Server Configuration
PORT=3000
NODE_ENV=development
FRONTEND_URL=http://localhost:5173
# Security
BCRYPT_ROUNDS=12
COOKIE_SECRET=your-cookie-signing-secret-min-32-chars
Generate RSA key pair (recommended for production):
# Generate private key
openssl genrsa -out private.key 2048
# Generate public key
openssl rsa -in private.key -pubout -out public.key
# Update .env
# JWT_PRIVATE_KEY_PATH=./private.key
# JWT_PUBLIC_KEY_PATH=./public.key
config/jwt.js — JWT configuration:
import dotenv from 'dotenv';
import fs from 'fs';
dotenv.config();
// JWT configuration
export const jwtConfig = {
// Access token settings
access: {
secret: process.env.JWT_ACCESS_SECRET,
expiresIn: process.env.JWT_ACCESS_EXPIRY || '15m',
algorithm: 'HS256', // Use RS256 with RSA keys for production
},
// Refresh token settings
refresh: {
secret: process.env.JWT_REFRESH_SECRET,
expiresIn: process.env.JWT_REFRESH_EXPIRY || '7d',
algorithm: 'HS256',
},
// Cookie settings
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
},
};
// Load RSA keys if using asymmetric signing
export function loadRSAKeys() {
if (process.env.JWT_PRIVATE_KEY_PATH && process.env.JWT_PUBLIC_KEY_PATH) {
return {
privateKey: fs.readFileSync(process.env.JWT_PRIVATE_KEY_PATH, 'utf8'),
publicKey: fs.readFileSync(process.env.JWT_PUBLIC_KEY_PATH, 'utf8'),
};
}
return null;
}
export default jwtConfig;
db/database.js — Database operations:
import pg from 'pg';
import dotenv from 'dotenv';
dotenv.config();
const { Pool } = pg;
const pool = new Pool({
host: process.env.PG_HOST,
port: process.env.PG_PORT,
database: process.env.PG_DATABASE,
user: process.env.PG_USER,
password: process.env.PG_PASSWORD,
max: 20,
});
// Test connection
export async function testConnection() {
try {
const client = await pool.connect();
console.log('✓ PostgreSQL connected');
client.release();
return true;
} catch (error) {
console.error('✗ PostgreSQL connection failed:', error.message);
return false;
}
}
// User operations
export async function createUser(email, passwordHash, firstName, lastName, role = 'user') {
const query = `
INSERT INTO users (email, password_hash, first_name, last_name, role)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, email, first_name, last_name, role, created_at
`;
const result = await pool.query(query, [email, passwordHash, firstName, lastName, role]);
return result.rows[0];
}
export async function getUserByEmail(email) {
const query = 'SELECT * FROM users WHERE email = $1';
const result = await pool.query(query, [email]);
return result.rows[0];
}
export async function getUserById(id) {
const query = 'SELECT id, email, first_name, last_name, role, is_active, email_verified FROM users WHERE id = $1';
const result = await pool.query(query, [id]);
return result.rows[0];
}
export async function updateLastLogin(userId) {
const query = 'UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = $1';
await pool.query(query, [userId]);
}
// Refresh token operations
export async function saveRefreshToken(userId, tokenHash, expiresAt, ipAddress) {
const query = `
INSERT INTO refresh_tokens (user_id, token_hash, expires_at, created_by_ip)
VALUES ($1, $2, $3, $4)
RETURNING id
`;
const result = await pool.query(query, [userId, tokenHash, expiresAt, ipAddress]);
return result.rows[0].id;
}
export async function getRefreshToken(tokenHash) {
const query = `
SELECT * FROM refresh_tokens
WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()
`;
const result = await pool.query(query, [tokenHash]);
return result.rows[0];
}
export async function revokeRefreshToken(tokenHash, ipAddress) {
const query = `
UPDATE refresh_tokens
SET revoked_at = CURRENT_TIMESTAMP, revoked_by_ip = $1
WHERE token_hash = $2
`;
await pool.query(query, [ipAddress, tokenHash]);
}
export async function revokeAllUserTokens(userId) {
const query = `
UPDATE refresh_tokens
SET revoked_at = CURRENT_TIMESTAMP
WHERE user_id = $1 AND revoked_at IS NULL
`;
await pool.query(query, [userId]);
}
// Token blacklist operations
export async function blacklistToken(jti, expiresAt, reason) {
const query = `
INSERT INTO token_blacklist (token_jti, expires_at, reason)
VALUES ($1, $2, $3)
ON CONFLICT (token_jti) DO NOTHING
`;
await pool.query(query, [jti, expiresAt, reason]);
}
export async function isTokenBlacklisted(jti) {
const query = 'SELECT * FROM token_blacklist WHERE token_jti = $1 AND expires_at > NOW()';
const result = await pool.query(query, [jti]);
return result.rows.length > 0;
}
// Cleanup expired tokens (run periodically)
export async function cleanupExpiredTokens() {
await pool.query('SELECT cleanup_expired_tokens()');
}
export default pool;
Step 3: Core Logic
utils/tokenManager.js — JWT token generation and verification:
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { jwtConfig, loadRSAKeys } from '../config/jwt.js';
import {
saveRefreshToken,
getRefreshToken,
revokeRefreshToken,
blacklistToken,
isTokenBlacklisted,
} from '../db/database.js';
const rsaKeys = loadRSAKeys();
// Generate access token (short-lived)
export function generateAccessToken(user) {
const payload = {
userId: user.id,
email: user.email,
role: user.role,
type: 'access',
};
const options = {
expiresIn: jwtConfig.access.expiresIn,
algorithm: jwtConfig.access.algorithm,
jwtid: crypto.randomUUID(), // Unique token ID for blacklisting
};
const secret = rsaKeys ? rsaKeys.privateKey : jwtConfig.access.secret;
return jwt.sign(payload, secret, options);
}
// Generate refresh token (long-lived)
export async function generateRefreshToken(user, ipAddress) {
const payload = {
userId: user.id,
type: 'refresh',
};
const options = {
expiresIn: jwtConfig.refresh.expiresIn,
algorithm: jwtConfig.refresh.algorithm,
jwtid: crypto.randomUUID(),
};
const secret = rsaKeys ? rsaKeys.privateKey : jwtConfig.refresh.secret;
const token = jwt.sign(payload, secret, options);
// Hash token for storage (never store raw tokens)
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
// Calculate expiry timestamp
const decoded = jwt.decode(token);
const expiresAt = new Date(decoded.exp * 1000);
// Save to database
await saveRefreshToken(user.id, tokenHash, expiresAt, ipAddress);
return token;
}
// Verify access token
export async function verifyAccessToken(token) {
try {
const secret = rsaKeys ? rsaKeys.publicKey : jwtConfig.access.secret;
const decoded = jwt.verify(token, secret, {
algorithms: [jwtConfig.access.algorithm],
});
// Check if token is blacklisted
const blacklisted = await isTokenBlacklisted(decoded.jti);
if (blacklisted) {
throw new Error('Token has been revoked');
}
return decoded;
} catch (error) {
throw new Error(`Invalid access token: ${error.message}`);
}
}
// Verify refresh token
export async function verifyRefreshToken(token) {
try {
const secret = rsaKeys ? rsaKeys.publicKey : jwtConfig.refresh.secret;
const decoded = jwt.verify(token, secret, {
algorithms: [jwtConfig.refresh.algorithm],
});
// Hash token to lookup in database
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
// Verify token exists in database and is not revoked
const storedToken = await getRefreshToken(tokenHash);
if (!storedToken) {
throw new Error('Refresh token not found or expired');
}
return {
decoded,
tokenHash,
dbToken: storedToken,
};
} catch (error) {
throw new Error(`Invalid refresh token: ${error.message}`);
}
}
// Rotate refresh token (security best practice)
export async function rotateRefreshToken(oldToken, ipAddress) {
// Verify old token
const { decoded, tokenHash } = await verifyRefreshToken(oldToken);
// Revoke old token
await revokeRefreshToken(tokenHash, ipAddress);
// Generate new tokens
const user = { id: decoded.userId };
const newAccessToken = generateAccessToken({ ...user, role: decoded.role, email: decoded.email });
const newRefreshToken = await generateRefreshToken(user, ipAddress);
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
};
}
// Revoke token (for logout)
export async function revokeToken(token, reason = 'User logout') {
try {
const decoded = jwt.decode(token);
if (!decoded || !decoded.jti) {
throw new Error('Invalid token format');
}
// Add to blacklist
const expiresAt = new Date(decoded.exp * 1000);
await blacklistToken(decoded.jti, expiresAt, reason);
return true;
} catch (error) {
console.error('Error revoking token:', error);
return false;
}
}
export default {
generateAccessToken,
generateRefreshToken,
verifyAccessToken,
verifyRefreshToken,
rotateRefreshToken,
revokeToken,
};
middleware/auth.js — Authentication middleware:
import { verifyAccessToken } from '../utils/tokenManager.js';
import { getUserById } from '../db/database.js';
// Authenticate request using JWT
export async function authenticate(req, res, next) {
try {
// Extract token from Authorization header or cookie
let token = null;
if (req.headers.authorization?.startsWith('Bearer ')) {
token = req.headers.authorization.substring(7);
} else if (req.cookies?.accessToken) {
token = req.cookies.accessToken;
}
if (!token) {
return res.status(401).json({
error: 'Authentication required',
code: 'NO_TOKEN',
});
}
// Verify token
const decoded = await verifyAccessToken(token);
// Attach user info to request
req.user = {
userId: decoded.userId,
email: decoded.email,
role: decoded.role,
};
next();
} catch (error) {
console.error('Authentication error:', error.message);
return res.status(401).json({
error: 'Invalid or expired token',
code: 'INVALID_TOKEN',
});
}
}
// Authorize based on role
export function authorize(...allowedRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
error: 'Insufficient permissions',
code: 'FORBIDDEN',
required: allowedRoles,
current: req.user.role,
});
}
next();
};
}
// Optional authentication (doesn't fail if no token)
export async function optionalAuth(req, res, next) {
try {
let token = null;
if (req.headers.authorization?.startsWith('Bearer ')) {
token = req.headers.authorization.substring(7);
} else if (req.cookies?.accessToken) {
token = req.cookies.accessToken;
}
if (token) {
const decoded = await verifyAccessToken(token);
req.user = {
userId: decoded.userId,
email: decoded.email,
role: decoded.role,
};
}
next();
} catch (error) {
// Continue without authentication
next();
}
}
export default { authenticate, authorize, optionalAuth };
routes/auth.js — Authentication routes:
import express from 'express';
import bcrypt from 'bcrypt';
import { body, validationResult } from 'express-validator';
import {
createUser,
getUserByEmail,
updateLastLogin,
revokeAllUserTokens,
} from '../db/database.js';
import {
generateAccessToken,
generateRefreshToken,
verifyRefreshToken,
rotateRefreshToken,
revokeToken,
} from '../utils/tokenManager.js';
import { jwtConfig } from '../config/jwt.js';
import { authenticate } from '../middleware/auth.js';
const router = express.Router();
// POST /auth/register - Register new user
router.post(
'/register',
[
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 8 }).withMessage('Password must be at least 8 characters'),
body('firstName').trim().notEmpty(),
body('lastName').trim().notEmpty(),
],
async (req, res) => {
try {
// Validate input
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { email, password, firstName, lastName } = req.body;
// Check if user exists
const existing = await getUserByEmail(email);
if (existing) {
return res.status(409).json({
error: 'Email already registered',
code: 'EMAIL_EXISTS',
});
}
// Hash password
const saltRounds = parseInt(process.env.BCRYPT_ROUNDS) || 12;
const passwordHash = await bcrypt.hash(password, saltRounds);
// Create user
const user = await createUser(email, passwordHash, firstName, lastName);
console.log(`✓ User registered: ${email}`);
// Generate tokens
const accessToken = generateAccessToken(user);
const refreshToken = await generateRefreshToken(user, req.ip);
// Set refresh token in httpOnly cookie
res.cookie('refreshToken', refreshToken, jwtConfig.cookie);
res.status(201).json({
success: true,
message: 'User registered successfully',
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
role: user.role,
},
accessToken,
});
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({ error: 'Registration failed' });
}
}
);
// POST /auth/login - Login user
router.post(
'/login',
[
body('email').isEmail().normalizeEmail(),
body('password').notEmpty(),
],
async (req, res) => {
try {
// Validate input
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { email, password } = req.body;
// Get user
const user = await getUserByEmail(email);
if (!user) {
return res.status(401).json({
error: 'Invalid credentials',
code: 'INVALID_CREDENTIALS',
});
}
// Check if account is active
if (!user.is_active) {
return res.status(403).json({
error: 'Account is disabled',
code: 'ACCOUNT_DISABLED',
});
}
// Verify password
const passwordMatch = await bcrypt.compare(password, user.password_hash);
if (!passwordMatch) {
return res.status(401).json({
error: 'Invalid credentials',
code: 'INVALID_CREDENTIALS',
});
}
// Update last login
await updateLastLogin(user.id);
// Generate tokens
const accessToken = generateAccessToken(user);
const refreshToken = await generateRefreshToken(user, req.ip);
// Set refresh token in httpOnly cookie
res.cookie('refreshToken', refreshToken, jwtConfig.cookie);
console.log(`✓ User logged in: ${email}`);
res.json({
success: true,
message: 'Login successful',
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
role: user.role,
},
accessToken,
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
}
);
// POST /auth/refresh - Refresh access token
router.post('/refresh', async (req, res) => {
try {
// Get refresh token from cookie or body
const refreshToken = req.cookies?.refreshToken || req.body.refreshToken;
if (!refreshToken) {
return res.status(401).json({
error: 'Refresh token required',
code: 'NO_REFRESH_TOKEN',
});
}
// Rotate refresh token
const { accessToken, refreshToken: newRefreshToken } = await rotateRefreshToken(
refreshToken,
req.ip
);
// Set new refresh token in cookie
res.cookie('refreshToken', newRefreshToken, jwtConfig.cookie);
res.json({
success: true,
accessToken,
});
} catch (error) {
console.error('Token refresh error:', error);
// Clear invalid refresh token
res.clearCookie('refreshToken');
res.status(401).json({
error: 'Invalid or expired refresh token',
code: 'INVALID_REFRESH_TOKEN',
});
}
});
// POST /auth/logout - Logout user
router.post('/logout', authenticate, async (req, res) => {
try {
// Get tokens
const accessToken = req.headers.authorization?.substring(7) || req.cookies?.accessToken;
const refreshToken = req.cookies?.refreshToken;
// Revoke access token
if (accessToken) {
await revokeToken(accessToken, 'User logout');
}
// Revoke refresh token
if (refreshToken) {
const { tokenHash } = await verifyRefreshToken(refreshToken);
await revokeRefreshToken(tokenHash, req.ip);
}
// Clear cookies
res.clearCookie('refreshToken');
res.clearCookie('accessToken');
console.log(`✓ User logged out: ${req.user.email}`);
res.json({
success: true,
message: 'Logged out successfully',
});
} catch (error) {
console.error('Logout error:', error);
res.status(500).json({ error: 'Logout failed' });
}
});
// POST /auth/logout-all - Logout from all devices
router.post('/logout-all', authenticate, async (req, res) => {
try {
// Revoke all refresh tokens for user
await revokeAllUserTokens(req.user.userId);
// Clear cookies
res.clearCookie('refreshToken');
res.clearCookie('accessToken');
console.log(`✓ User logged out from all devices: ${req.user.email}`);
res.json({
success: true,
message: 'Logged out from all devices',
});
} catch (error) {
console.error('Logout all error:', error);
res.status(500).json({ error: 'Logout failed' });
}
});
// GET /auth/me - Get current user
router.get('/me', authenticate, async (req, res) => {
try {
const user = await getUserById(req.user.userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({
success: true,
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
role: user.role,
emailVerified: user.email_verified,
},
});
} catch (error) {
console.error('Get user error:', error);
res.status(500).json({ error: 'Failed to get user' });
}
});
export default router;
routes/users.js — Protected user routes:
import express from 'express';
import { authenticate, authorize } from '../middleware/auth.js';
import { getUserById } from '../db/database.js';
const router = express.Router();
// All routes require authentication
router.use(authenticate);
// GET /users/profile - Get own profile
router.get('/profile', async (req, res) => {
try {
const user = await getUserById(req.user.userId);
res.json({
success: true,
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
role: user.role,
},
});
} catch (error) {
res.status(500).json({ error: 'Failed to get profile' });
}
});
// GET /users/:id - Get user by ID (admin only)
router.get('/:id', authorize('admin'), async (req, res) => {
try {
const user = await getUserById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({
success: true,
user,
});
} catch (error) {
res.status(500).json({ error: 'Failed to get user' });
}
});
// Example: Protected resource
router.get('/dashboard', (req, res) => {
res.json({
success: true,
message: `Welcome to your dashboard, ${req.user.email}`,
data: {
userId: req.user.userId,
role: req.user.role,
},
});
});
// Example: Admin-only route
router.get('/admin/stats', authorize('admin'), (req, res) => {
res.json({
success: true,
message: 'Admin statistics',
stats: {
totalUsers: 150,
activeUsers: 120,
},
});
});
export default router;
src/server.js — Express server:
import express from 'express';
import cookieParser from 'cookie-parser';
import helmet from 'helmet';
import cors from 'cors';
import dotenv from 'dotenv';
import { testConnection } from '../db/database.js';
import authRoutes from '../routes/auth.js';
import userRoutes from '../routes/users.js';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
// Security middleware
app.use(helmet());
app.use(cors({
origin: process.env.FRONTEND_URL,
credentials: true, // Allow cookies
}));
// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser(process.env.COOKIE_SECRET));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'healthy' });
});
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
// Error handling middleware
app.use((error, req, res, next) => {
console.error('Server error:', error);
res.status(500).json({
error: 'Internal server error',
message: process.env.NODE_ENV === 'development' ? error.message : undefined,
});
});
// Start server
async function startServer() {
try {
await testConnection();
app.listen(PORT, () => {
console.log(`\n🚀 JWT Auth Server running on http://localhost:${PORT}`);
console.log(`\nAPI Endpoints:`);
console.log(` POST /api/auth/register - Register user`);
console.log(` POST /api/auth/login - Login user`);
console.log(` POST /api/auth/refresh - Refresh token`);
console.log(` POST /api/auth/logout - Logout user`);
console.log(` GET /api/auth/me - Get current user`);
console.log(` GET /api/users/profile - Get user profile`);
console.log(` GET /api/users/dashboard - Protected resource\n`);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
startServer();
Step 4: Testing
Test 1: Start Server
npm run dev
Test 2: Register User
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"password": "SecurePass123!",
"firstName": "John",
"lastName": "Doe"
}'
Expected response:
{
"success": true,
"message": "User registered successfully",
"user": {
"id": 1,
"email": "john@example.com",
"firstName": "John",
"lastName": "Doe",
"role": "user"
},
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Test 3: Login
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"password": "SecurePass123!"
}' \
-c cookies.txt
Save the accessToken from response.
Test 4: Access Protected Route
curl http://localhost:3000/api/users/dashboard \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN_HERE"
Expected response:
{
"success": true,
"message": "Welcome to your dashboard, john@example.com",
"data": {
"userId": 1,
"role": "user"
}
}
Test 5: Refresh Token
curl -X POST http://localhost:3000/api/auth/refresh \
-b cookies.txt \
-c cookies.txt
Test 6: Test Token Expiration
Wait for access token to expire (15 minutes by default), then try accessing protected route. Should receive 401 error.
Test 7: Logout
curl -X POST http://localhost:3000/api/auth/logout \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-b cookies.txt
Test 8: Verify Database
psql -U your_username -d jwt_auth_db
-- Check users
SELECT id, email, role, created_at FROM users;
-- Check refresh tokens
SELECT user_id, expires_at, revoked_at FROM refresh_tokens;
-- Check token blacklist
SELECT token_jti, expires_at FROM token_blacklist;
Testing Checklist:
- ✓ User registration works
- ✓ Login returns access + refresh tokens
- ✓ Access token grants access to protected routes
- ✓ Expired access token rejected
- ✓ Refresh token rotates successfully
- ✓ Logout revokes tokens
- ✓ Revoked tokens cannot access resources
- ✓ Role-based authorization works
- ✓ Tokens stored in database correctly
Common Errors & Troubleshooting
Error 1: “JsonWebTokenError: invalid signature” or Token Verification Fails
Problem: Token verification fails even with correct token.
Solution: Secret key mismatch or algorithm mismatch.
Check secret keys match:
// ❌ Wrong - different secrets for sign and verify
const token = jwt.sign(payload, 'secret1');
jwt.verify(token, 'secret2'); // Fails
// ✅ Correct - same secret
const SECRET = process.env.JWT_ACCESS_SECRET;
const token = jwt.sign(payload, SECRET);
jwt.verify(token, SECRET); // Works
Verify .env loaded correctly:
import dotenv from 'dotenv';
dotenv.config();
console.log('JWT Secret:', process.env.JWT_ACCESS_SECRET ? 'Loaded' : 'MISSING');
Check algorithm consistency:
// ❌ Wrong - algorithm mismatch
const token = jwt.sign(payload, secret, { algorithm: 'HS256' });
jwt.verify(token, secret, { algorithms: ['RS256'] }); // Fails
// ✅ Correct - matching algorithms
const token = jwt.sign(payload, secret, { algorithm: 'HS256' });
jwt.verify(token, secret, { algorithms: ['HS256'] }); // Works
RSA key issues:
// Verify RSA keys loaded correctly
const fs = require('fs');
try {
const privateKey = fs.readFileSync('./private.key', 'utf8');
const publicKey = fs.readFileSync('./public.key', 'utf8');
console.log('Private key length:', privateKey.length);
console.log('Public key length:', publicKey.length);
} catch (error) {
console.error('Failed to load RSA keys:', error.message);
}
Error 2: “TokenExpiredError: jwt expired” Immediately After Login
Problem: Access token expires immediately even though expiry is set to 15 minutes.
Solution: Check expiry format and server time.
Verify expiry format:
// ❌ Wrong formats
expiresIn: '15' // Missing unit
expiresIn: 15 // Interpreted as 15 milliseconds
expiresIn: '15 minutes' // Space not supported
// ✅ Correct formats
expiresIn: '15m' // 15 minutes
expiresIn: '7d' // 7 days
expiresIn: '2h' // 2 hours
expiresIn: 900 // 900 seconds (15 minutes)
Check server time is correct:
// Add to token generation
const now = Math.floor(Date.now() / 1000);
console.log('Current timestamp:', now);
console.log('Token will expire at:', now + (15 * 60));
const payload = {
userId: user.id,
iat: now, // Issued at
};
Verify client/server time sync:
# Check server time
date
# If time is wrong, sync with NTP
sudo ntpdate -s time.nist.gov
Add buffer for clock skew:
// Add 60 second clock tolerance
jwt.verify(token, secret, {
clockTolerance: 60, // seconds
});
Error 3: “CORS Error” or Cookies Not Set in Frontend
Problem: Frontend cannot receive cookies or gets CORS errors.
Solution: CORS must allow credentials and origin must match.
Backend CORS configuration:
import cors from 'cors';
app.use(cors({
origin: process.env.FRONTEND_URL, // Must match exactly
credentials: true, // CRITICAL for cookies
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
Frontend fetch configuration:
// ✅ Correct - include credentials
fetch('http://localhost:3000/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include', // CRITICAL
});
// Axios configuration
axios.defaults.withCredentials = true;
Cookie settings for cross-origin:
res.cookie('refreshToken', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', // 'none' requires HTTPS
maxAge: 7 * 24 * 60 * 60 * 1000,
domain: process.env.NODE_ENV === 'production' ? '.yourdomain.com' : undefined,
});
Verify cookie domain:
// ❌ Wrong - frontend and backend on different domains
Frontend: http://localhost:3000
Backend: http://localhost:5000
Cookies won't work across different ports
// ✅ Solution 1 - Use proxy in development
// In frontend (Vite):
// vite.config.js
export default {
server: {
proxy: {
'/api': 'http://localhost:5000'
}
}
}
// ✅ Solution 2 - Same domain in production
Frontend: https://app.yourdomain.com
Backend: https://api.yourdomain.com
Security Checklist
Critical security practices for JWT authentication:
- Use strong secrets — JWT secrets must be minimum 32 characters, random, and unique:
# Generate strong secret
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Store in .env
JWT_ACCESS_SECRET=8f7a9b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0
- Use short-lived access tokens — 15 minutes or less prevents prolonged token theft impact:
JWT_ACCESS_EXPIRY=15m # ✅ Good
JWT_ACCESS_EXPIRY=24h # ❌ Too long
- Implement refresh token rotation — Always rotate refresh tokens on use to prevent reuse attacks (already implemented in code).
- Store refresh tokens hashed — Never store raw tokens in database:
// ✅ Always hash before storing
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
await saveRefreshToken(userId, tokenHash, expiresAt);
- Use httpOnly cookies for refresh tokens — Prevents XSS attacks:
res.cookie('refreshToken', token, {
httpOnly: true, // ✅ JavaScript cannot access
secure: true, // ✅ HTTPS only
sameSite: 'strict', // ✅ CSRF protection
});
- Implement token blacklisting — Required for logout and immediate revocation (implemented with token_blacklist table).
- Add JTI (JWT ID) claim — Unique identifier for each token enables blacklisting:
const token = jwt.sign(payload, secret, {
jwtid: crypto.randomUUID(), // ✅ Unique ID per token
});
- Validate all inputs — Use express-validator to prevent injection:
import { body, validationResult } from 'express-validator';
router.post('/login', [
body('email').isEmail().normalizeEmail(),
body('password').notEmpty().trim(),
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Process login...
});
- Use bcrypt with sufficient rounds — 12+ rounds for password hashing:
const saltRounds = 12; // ✅ Recommended minimum
const hash = await bcrypt.hash(password, saltRounds);
- Implement rate limiting — Prevent brute force attacks:
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: 'Too many login attempts',
});
app.post('/api/auth/login', loginLimiter, loginHandler);
- Use RSA keys in production — Asymmetric signing more secure than shared secrets:
// Generate keys (one-time)
openssl genrsa -out private.key 2048
openssl rsa -in private.key -pubout -out public.key
// Use in production
const privateKey = fs.readFileSync('./private.key');
const publicKey = fs.readFileSync('./public.key');
jwt.sign(payload, privateKey, { algorithm: 'RS256' });
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
- Set CORS properly — Restrict origins in production:
app.use(cors({
origin: ['https://app.yourdomain.com'], // ✅ Specific domain
credentials: true,
}));
- Clean up expired tokens — Run cleanup job periodically:
import cron from 'node-cron';
// Run daily at midnight
cron.schedule('0 0 * * *', async () => {
await cleanupExpiredTokens();
console.log('✓ Expired tokens cleaned');
});
Related Resources:
- Creating Role-Based Access Control (RBAC) Inside Your CRM – Implement role-based permissions with JWT
- Implement SSO with Auth0 – Alternative authentication approach
- How to Integrate HubSpot OAuth – OAuth 2.0 implementation
- Can OAuth and JWT Be Used Together? – Understanding auth patterns
- Building Audit Trails and Activity Logs – Track authentication events
Need Authentication Expertise?
Implementing secure authentication requires deep understanding of cryptography, session management, and security best practices. If you need help building JWT authentication, implementing OAuth flows, or architecting multi-tenant SaaS security, schedule a consultation. We’ll help you build production-ready authentication that protects your users and scales with your business.

Huzaifa Asif is a dedicated software and integration specialist at TheSportsAngel, focused on making complex API and system integrations simple and actionable. With over 3+ years of hands-on experience in backend development, CRM/ERP connectivity, and third-party platform integrations, he transforms technical architecture into clear, step-by-step coding guides that both developers and non-technical users can follow.



