Implementing JWT Authentication for secure SaaS API access.

Implementing JWT Authentication for secure SaaS API access.

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:

bash
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:

bash
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:

sql
-- 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:

bash
psql -U your_username -d your_database -f db/schema.sql

package.json — Add scripts:

json
{
  "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:

bash
echo "node_modules/
.env
*.log
private.key
public.key" > .gitignore

Step 2: Configuration

.env — Store secrets securely:

env
# 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):

bash
# 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:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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

bash
npm run dev

Test 2: Register User

bash
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:

json
{
  "success": true,
  "message": "User registered successfully",
  "user": {
    "id": 1,
    "email": "john@example.com",
    "firstName": "John",
    "lastName": "Doe",
    "role": "user"
  },
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Test 3: Login

bash
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

bash
curl http://localhost:3000/api/users/dashboard \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN_HERE"

Expected response:

json
{
  "success": true,
  "message": "Welcome to your dashboard, john@example.com",
  "data": {
    "userId": 1,
    "role": "user"
  }
}

Test 5: Refresh Token

bash
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

bash
curl -X POST http://localhost:3000/api/auth/logout \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -b cookies.txt

Test 8: Verify Database

bash
psql -U your_username -d jwt_auth_db
sql
-- 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:

javascript
// ❌ 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:

javascript
import dotenv from 'dotenv';
dotenv.config();

console.log('JWT Secret:', process.env.JWT_ACCESS_SECRET ? 'Loaded' : 'MISSING');

Check algorithm consistency:

javascript
// ❌ 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:

javascript
// 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:

javascript
// ❌ 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:

javascript
// 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:

bash
# Check server time
date

# If time is wrong, sync with NTP
sudo ntpdate -s time.nist.gov

Add buffer for clock skew:

javascript
// 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:

javascript
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:

javascript
// ✅ 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:

javascript
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:

javascript
// ❌ 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:
bash
  # 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:
env
  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:
javascript
  // ✅ 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:
javascript
  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:
javascript
  const token = jwt.sign(payload, secret, {
    jwtid: crypto.randomUUID(), // ✅ Unique ID per token
  });
  • Validate all inputs — Use express-validator to prevent injection:
javascript
  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:
javascript
  const saltRounds = 12; // ✅ Recommended minimum
  const hash = await bcrypt.hash(password, saltRounds);
  • Implement rate limiting — Prevent brute force attacks:
javascript
  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:
javascript
  // 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:
javascript
  app.use(cors({
    origin: ['https://app.yourdomain.com'], // ✅ Specific domain
    credentials: true,
  }));
  • Clean up expired tokens — Run cleanup job periodically:
javascript
  import cron from 'node-cron';
  
  // Run daily at midnight
  cron.schedule('0 0 * * *', async () => {
    await cleanupExpiredTokens();
    console.log('✓ Expired tokens cleaned');
  });

Related Resources:

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.

Leave a Comment

Your email address will not be published. Required fields are marked *