Skip to main content
Project Web Security Intermediate

Building a Secure API with JWT Authentication

Jason J. Boderebe
23 min tutorial
#api-security #jwt #authentication #node.js

Building a Secure API with JWT Authentication

In this comprehensive guide, we’ll build a secure REST API from the ground up, implementing industry-standard security practices including JWT authentication, rate limiting, input validation, and protection against common web vulnerabilities.

Project Overview

We’ll create a secure user management API that demonstrates:

  • JWT-based authentication with refresh tokens
  • Role-based access control (RBAC)
  • Rate limiting and DDoS protection
  • Input validation and sanitization
  • SQL injection prevention
  • HTTPS enforcement and security headers
  • Comprehensive logging and monitoring

Technology Stack

Core Dependencies

{
  "dependencies": {
    "express": "^4.18.2",
    "jsonwebtoken": "^9.0.2",
    "bcrypt": "^5.1.1",
    "helmet": "^7.1.0",
    "express-rate-limit": "^7.1.5",
    "express-validator": "^7.0.1",
    "mongoose": "^8.0.3",
    "cors": "^2.8.5",
    "dotenv": "^16.3.1",
    "morgan": "^1.10.0",
    "winston": "^3.11.0"
  },
  "devDependencies": {
    "jest": "^29.7.0",
    "supertest": "^6.3.3",
    "nodemon": "^3.0.2"
  }
}

Development Tools

  • Node.js 18+: Modern JavaScript runtime
  • MongoDB: Document database for user storage
  • Postman: API testing and documentation
  • Jest: Unit and integration testing
  • ESLint: Code quality and security linting

Project Structure

secure-api/
├── src/
│   ├── controllers/
│   │   ├── authController.js
│   │   └── userController.js
│   ├── middleware/
│   │   ├── auth.js
│   │   ├── validation.js
│   │   ├── rateLimiting.js
│   │   └── security.js
│   ├── models/
│   │   ├── User.js
│   │   └── RefreshToken.js
│   ├── routes/
│   │   ├── auth.js
│   │   └── users.js
│   ├── utils/
│   │   ├── logger.js
│   │   ├── tokenUtils.js
│   │   └── emailUtils.js
│   └── app.js
├── tests/
│   ├── auth.test.js
│   └── users.test.js
├── .env.example
├── .gitignore
└── server.js

Implementation

1. Application Setup

Environment Configuration

// .env.example
NODE_ENV=development
PORT=3000
MONGODB_URI=mongodb://localhost:27017/secure-api
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRE=15m
REFRESH_TOKEN_SECRET=your-refresh-token-secret
REFRESH_TOKEN_EXPIRE=7d
BCRYPT_ROUNDS=12
API_RATE_LIMIT=100
EMAIL_SERVICE=gmail
EMAIL_USER=your-[email protected]
EMAIL_PASS=your-app-password

Main Application File

// src/app.js
const express = require('express');
const mongoose = require('mongoose');
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
const { body, validationResult } = require('express-validator');
require('dotenv').config();

const logger = require('./utils/logger');
const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');
const { errorHandler, notFound } = require('./middleware/errorHandling');

const app = express();

// Security middleware
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      scriptSrc: ["'self'"],
      imgSrc: ["'self'", "data:", "https:"],
    },
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true
  }
}));

// CORS configuration
app.use(cors({
  origin: process.env.NODE_ENV === 'production' 
    ? ['https://yourapp.com'] 
    : ['http://localhost:3000', 'http://localhost:3001'],
  credentials: true,
  optionsSuccessStatus: 200
}));

// Rate limiting
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: parseInt(process.env.API_RATE_LIMIT) || 100,
  message: {
    error: 'Too many requests from this IP, please try again later.',
    retryAfter: '15 minutes'
  },
  standardHeaders: true,
  legacyHeaders: false,
  handler: (req, res) => {
    logger.warn(`Rate limit exceeded for IP: ${req.ip}`, {
      ip: req.ip,
      userAgent: req.get('User-Agent'),
      endpoint: req.originalUrl
    });
    res.status(429).json({
      error: 'Too many requests from this IP, please try again later.',
      retryAfter: '15 minutes'
    });
  }
});

app.use(limiter);

// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));

// Logging
app.use(morgan('combined', {
  stream: { write: message => logger.info(message.trim()) }
}));

// Database connection
mongoose.connect(process.env.MONGODB_URI, {
  useNewUrlParser: true,
  useUnifiedTopology: true,
})
.then(() => logger.info('Connected to MongoDB'))
.catch(err => logger.error('MongoDB connection error:', err));

// Routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);

// Health check
app.get('/health', (req, res) => {
  res.status(200).json({
    status: 'OK',
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
    environment: process.env.NODE_ENV
  });
});

// Error handling middleware
app.use(notFound);
app.use(errorHandler);

module.exports = app;

2. User Model with Security Features

// src/models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');

const userSchema = new mongoose.Schema({
  username: {
    type: String,
    required: [true, 'Username is required'],
    unique: true,
    trim: true,
    minlength: [3, 'Username must be at least 3 characters'],
    maxlength: [30, 'Username cannot exceed 30 characters'],
    match: [/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores']
  },
  email: {
    type: String,
    required: [true, 'Email is required'],
    unique: true,
    lowercase: true,
    trim: true,
    match: [/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, 'Please enter a valid email']
  },
  password: {
    type: String,
    required: [true, 'Password is required'],
    minlength: [8, 'Password must be at least 8 characters'],
    select: false // Don't include password in queries by default
  },
  role: {
    type: String,
    enum: ['user', 'admin', 'moderator'],
    default: 'user'
  },
  isActive: {
    type: Boolean,
    default: true
  },
  lastLogin: {
    type: Date,
    default: null
  },
  loginAttempts: {
    type: Number,
    default: 0
  },
  lockUntil: {
    type: Date,
    default: null
  },
  emailVerified: {
    type: Boolean,
    default: false
  },
  emailVerificationToken: {
    type: String,
    default: null
  },
  passwordResetToken: {
    type: String,
    default: null
  },
  passwordResetExpires: {
    type: Date,
    default: null
  }
}, {
  timestamps: true,
  toJSON: {
    transform: function(doc, ret) {
      delete ret.password;
      delete ret.emailVerificationToken;
      delete ret.passwordResetToken;
      delete ret.loginAttempts;
      delete ret.lockUntil;
      return ret;
    }
  }
});

// Indexes for performance and security
userSchema.index({ email: 1 });
userSchema.index({ username: 1 });
userSchema.index({ emailVerificationToken: 1 });
userSchema.index({ passwordResetToken: 1 });

// Account lockout virtual
userSchema.virtual('isLocked').get(function() {
  return !!(this.lockUntil && this.lockUntil > Date.now());
});

// Pre-save middleware to hash password
userSchema.pre('save', async function(next) {
  if (!this.isModified('password')) return next();
  
  try {
    const saltRounds = parseInt(process.env.BCRYPT_ROUNDS) || 12;
    this.password = await bcrypt.hash(this.password, saltRounds);
    next();
  } catch (error) {
    next(error);
  }
});

// Password comparison method
userSchema.methods.comparePassword = async function(candidatePassword) {
  if (!candidatePassword || !this.password) return false;
  return await bcrypt.compare(candidatePassword, this.password);
};

// Generate JWT token
userSchema.methods.generateAuthToken = function() {
  return jwt.sign(
    { 
      userId: this._id, 
      username: this.username,
      role: this.role 
    },
    process.env.JWT_SECRET,
    { 
      expiresIn: process.env.JWT_EXPIRE || '15m',
      issuer: 'secure-api',
      audience: 'secure-api-users'
    }
  );
};

// Handle login attempts and account locking
userSchema.methods.incLoginAttempts = function() {
  const maxAttempts = 5;
  const lockTime = 2 * 60 * 60 * 1000; // 2 hours

  // If we have a previous lock that has expired, restart at 1
  if (this.lockUntil && this.lockUntil < Date.now()) {
    return this.updateOne({
      $unset: { lockUntil: 1 },
      $set: { loginAttempts: 1 }
    });
  }

  const updates = { $inc: { loginAttempts: 1 } };

  // If we hit max attempts and it's not locked yet, lock the account
  if (this.loginAttempts + 1 >= maxAttempts && !this.isLocked) {
    updates.$set = { lockUntil: Date.now() + lockTime };
  }

  return this.updateOne(updates);
};

// Reset login attempts
userSchema.methods.resetLoginAttempts = function() {
  return this.updateOne({
    $unset: { loginAttempts: 1, lockUntil: 1 }
  });
};

module.exports = mongoose.model('User', userSchema);

3. JWT Authentication Middleware

// src/middleware/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const logger = require('../utils/logger');

const auth = async (req, res, next) => {
  try {
    const authHeader = req.header('Authorization');
    
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res.status(401).json({ 
        error: 'Access denied. No valid token provided.' 
      });
    }

    const token = authHeader.substring(7); // Remove 'Bearer ' prefix

    try {
      const decoded = jwt.verify(token, process.env.JWT_SECRET, {
        issuer: 'secure-api',
        audience: 'secure-api-users'
      });

      const user = await User.findById(decoded.userId);
      
      if (!user || !user.isActive) {
        return res.status(401).json({ 
          error: 'Access denied. User not found or inactive.' 
        });
      }

      req.user = user;
      req.tokenPayload = decoded;
      next();
    } catch (jwtError) {
      logger.warn('Invalid JWT token attempt', {
        ip: req.ip,
        userAgent: req.get('User-Agent'),
        error: jwtError.message
      });

      if (jwtError.name === 'TokenExpiredError') {
        return res.status(401).json({ 
          error: 'Token expired. Please refresh your token.' 
        });
      }

      return res.status(401).json({ 
        error: 'Invalid token.' 
      });
    }
  } catch (error) {
    logger.error('Auth middleware error:', error);
    res.status(500).json({ error: 'Internal server error.' });
  }
};

// Role-based access control
const authorize = (...roles) => {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required.' });
    }

    if (!roles.includes(req.user.role)) {
      logger.warn('Unauthorized access attempt', {
        userId: req.user._id,
        userRole: req.user.role,
        requiredRoles: roles,
        endpoint: req.originalUrl,
        ip: req.ip
      });

      return res.status(403).json({ 
        error: 'Access denied. Insufficient permissions.' 
      });
    }

    next();
  };
};

module.exports = { auth, authorize };

4. Authentication Controller

// src/controllers/authController.js
const User = require('../models/User');
const RefreshToken = require('../models/RefreshToken');
const { body, validationResult } = require('express-validator');
const rateLimit = require('express-rate-limit');
const logger = require('../utils/logger');
const { generateRefreshToken, verifyRefreshToken } = require('../utils/tokenUtils');
const { sendVerificationEmail, sendPasswordResetEmail } = require('../utils/emailUtils');

// Strict rate limiting for auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 attempts per window
  skipSuccessfulRequests: true,
  message: { error: 'Too many authentication attempts, please try again later.' }
});

// Registration validation rules
const registerValidation = [
  body('username')
    .isLength({ min: 3, max: 30 })
    .matches(/^[a-zA-Z0-9_]+$/)
    .withMessage('Username must be 3-30 characters and contain only letters, numbers, and underscores'),
  body('email')
    .isEmail()
    .normalizeEmail()
    .withMessage('Please provide a valid email'),
  body('password')
    .isLength({ min: 8 })
    .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
    .withMessage('Password must be at least 8 characters with uppercase, lowercase, number, and special character')
];

// Login validation rules
const loginValidation = [
  body('email').isEmail().normalizeEmail(),
  body('password').notEmpty().withMessage('Password is required')
];

const register = async (req, res) => {
  try {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({
        error: 'Validation failed',
        details: errors.array()
      });
    }

    const { username, email, password } = req.body;

    // Check if user already exists
    const existingUser = await User.findOne({
      $or: [{ email }, { username }]
    });

    if (existingUser) {
      // Don't reveal which field conflicts for security
      return res.status(409).json({
        error: 'User with this email or username already exists'
      });
    }

    // Create new user
    const user = new User({
      username,
      email,
      password,
      emailVerificationToken: require('crypto').randomBytes(32).toString('hex')
    });

    await user.save();

    // Send verification email
    try {
      await sendVerificationEmail(user.email, user.emailVerificationToken);
    } catch (emailError) {
      logger.error('Failed to send verification email:', emailError);
      // Don't fail registration if email fails
    }

    logger.info('New user registered', {
      userId: user._id,
      username: user.username,
      email: user.email,
      ip: req.ip
    });

    res.status(201).json({
      message: 'User registered successfully. Please check your email for verification.',
      userId: user._id
    });

  } catch (error) {
    logger.error('Registration error:', error);
    
    if (error.code === 11000) {
      return res.status(409).json({
        error: 'User with this email or username already exists'
      });
    }

    res.status(500).json({ error: 'Internal server error' });
  }
};

const login = async (req, res) => {
  try {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({
        error: 'Validation failed',
        details: errors.array()
      });
    }

    const { email, password } = req.body;

    // Find user and include password field
    const user = await User.findOne({ email }).select('+password');

    if (!user) {
      logger.warn('Login attempt with non-existent email', {
        email,
        ip: req.ip,
        userAgent: req.get('User-Agent')
      });
      
      return res.status(401).json({
        error: 'Invalid email or password'
      });
    }

    // Check if account is locked
    if (user.isLocked) {
      logger.warn('Login attempt on locked account', {
        userId: user._id,
        email: user.email,
        ip: req.ip
      });

      return res.status(423).json({
        error: 'Account temporarily locked due to too many failed login attempts'
      });
    }

    // Verify password
    const isPasswordValid = await user.comparePassword(password);

    if (!isPasswordValid) {
      await user.incLoginAttempts();
      
      logger.warn('Failed login attempt', {
        userId: user._id,
        email: user.email,
        attempts: user.loginAttempts + 1,
        ip: req.ip
      });

      return res.status(401).json({
        error: 'Invalid email or password'
      });
    }

    // Check if email is verified
    if (!user.emailVerified) {
      return res.status(403).json({
        error: 'Please verify your email before logging in'
      });
    }

    // Reset login attempts on successful login
    if (user.loginAttempts > 0) {
      await user.resetLoginAttempts();
    }

    // Update last login
    user.lastLogin = new Date();
    await user.save();

    // Generate tokens
    const accessToken = user.generateAuthToken();
    const refreshToken = await generateRefreshToken(user._id);

    logger.info('Successful login', {
      userId: user._id,
      username: user.username,
      ip: req.ip
    });

    res.json({
      message: 'Login successful',
      user: user.toJSON(),
      accessToken,
      refreshToken,
      expiresIn: process.env.JWT_EXPIRE || '15m'
    });

  } catch (error) {
    logger.error('Login error:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
};

const refreshToken = async (req, res) => {
  try {
    const { refreshToken } = req.body;

    if (!refreshToken) {
      return res.status(401).json({
        error: 'Refresh token is required'
      });
    }

    const decoded = await verifyRefreshToken(refreshToken);
    
    if (!decoded) {
      return res.status(401).json({
        error: 'Invalid or expired refresh token'
      });
    }

    const user = await User.findById(decoded.userId);
    
    if (!user || !user.isActive) {
      return res.status(401).json({
        error: 'User not found or inactive'
      });
    }

    // Generate new tokens
    const newAccessToken = user.generateAuthToken();
    const newRefreshToken = await generateRefreshToken(user._id);

    // Invalidate old refresh token
    await RefreshToken.deleteOne({ token: refreshToken });

    res.json({
      accessToken: newAccessToken,
      refreshToken: newRefreshToken,
      expiresIn: process.env.JWT_EXPIRE || '15m'
    });

  } catch (error) {
    logger.error('Token refresh error:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
};

module.exports = {
  register: [authLimiter, ...registerValidation, register],
  login: [authLimiter, ...loginValidation, login],
  refreshToken: [authLimiter, refreshToken]
};

5. Security Testing

// tests/auth.test.js
const request = require('supertest');
const mongoose = require('mongoose');
const app = require('../src/app');
const User = require('../src/models/User');

describe('Authentication Security', () => {
  beforeEach(async () => {
    await User.deleteMany({});
  });

  afterAll(async () => {
    await mongoose.connection.close();
  });

  describe('Input Validation', () => {
    test('should reject weak passwords', async () => {
      const response = await request(app)
        .post('/api/auth/register')
        .send({
          username: 'testuser',
          email: '[email protected]',
          password: '123456' // Weak password
        });

      expect(response.status).toBe(400);
      expect(response.body.error).toBe('Validation failed');
    });

    test('should sanitize SQL injection attempts', async () => {
      const response = await request(app)
        .post('/api/auth/login')
        .send({
          email: "admin'--",
          password: 'password'
        });

      expect(response.status).toBe(401);
    });

    test('should reject XSS in username', async () => {
      const response = await request(app)
        .post('/api/auth/register')
        .send({
          username: '<script>alert("xss")</script>',
          email: '[email protected]',
          password: 'SecurePass123!'
        });

      expect(response.status).toBe(400);
    });
  });

  describe('Rate Limiting', () => {
    test('should enforce rate limiting on login attempts', async () => {
      const loginData = {
        email: '[email protected]',
        password: 'wrongpassword'
      };

      // Make 6 requests (limit is 5)
      for (let i = 0; i < 6; i++) {
        await request(app)
          .post('/api/auth/login')
          .send(loginData);
      }

      const response = await request(app)
        .post('/api/auth/login')
        .send(loginData);

      expect(response.status).toBe(429);
    });
  });

  describe('Account Lockout', () => {
    test('should lock account after failed attempts', async () => {
      // Create a user
      const user = new User({
        username: 'testuser',
        email: '[email protected]',
        password: 'SecurePass123!',
        emailVerified: true
      });
      await user.save();

      // Make 5 failed login attempts
      for (let i = 0; i < 5; i++) {
        await request(app)
          .post('/api/auth/login')
          .send({
            email: '[email protected]',
            password: 'wrongpassword'
          });
      }

      // Next attempt should be locked
      const response = await request(app)
        .post('/api/auth/login')
        .send({
          email: '[email protected]',
          password: 'SecurePass123!'
        });

      expect(response.status).toBe(423);
      expect(response.body.error).toMatch(/locked/i);
    });
  });
});

Security Best Practices Implemented

1. Authentication Security

  • Strong password requirements with complexity validation
  • Account lockout after failed login attempts
  • JWT tokens with short expiration times
  • Refresh token rotation
  • Email verification requirement

2. Input Validation & Sanitization

  • Comprehensive input validation using express-validator
  • MongoDB injection prevention through parameterized queries
  • XSS protection via input sanitization
  • Request size limits to prevent DoS

3. Rate Limiting & DDoS Protection

  • Global rate limiting for all endpoints
  • Stricter limits for authentication endpoints
  • IP-based tracking and logging
  • Graceful error handling for rate limit violations

4. Security Headers & HTTPS

  • Helmet.js for security headers
  • Content Security Policy (CSP)
  • HTTP Strict Transport Security (HSTS)
  • CORS configuration for controlled access

5. Logging & Monitoring

  • Comprehensive security event logging
  • Failed authentication attempt tracking
  • Suspicious activity detection
  • Structured logging with Winston

Deployment Security

Production Environment Variables

# .env.production
NODE_ENV=production
PORT=443
MONGODB_URI=mongodb://user:pass@prod-cluster/secure-api?ssl=true
JWT_SECRET=very-long-random-production-secret-key
JWT_EXPIRE=5m
REFRESH_TOKEN_SECRET=different-very-long-refresh-secret
REFRESH_TOKEN_EXPIRE=1d
BCRYPT_ROUNDS=14
API_RATE_LIMIT=50

SSL/TLS Configuration

// server.js for production
const https = require('https');
const fs = require('fs');
const app = require('./src/app');

if (process.env.NODE_ENV === 'production') {
  const options = {
    key: fs.readFileSync('/path/to/private-key.pem'),
    cert: fs.readFileSync('/path/to/certificate.pem'),
    // Enable perfect forward secrecy
    honorCipherOrder: true,
    ciphers: [
      'ECDHE-RSA-AES128-GCM-SHA256',
      'ECDHE-RSA-AES256-GCM-SHA384',
      'ECDHE-RSA-AES128-SHA256',
      'ECDHE-RSA-AES256-SHA384'
    ].join(':'),
    secureProtocol: 'TLSv1_2_method'
  };

  https.createServer(options, app).listen(443, () => {
    console.log('Secure API server running on port 443');
  });
} else {
  app.listen(3000, () => {
    console.log('Development server running on port 3000');
  });
}

Conclusion

This secure API implementation demonstrates enterprise-grade security practices including:

  • Multi-layered authentication with JWT and refresh tokens
  • Comprehensive input validation preventing common attacks
  • Rate limiting and account lockout protecting against brute force
  • Security headers and HTTPS ensuring secure communication
  • Detailed logging and monitoring for security analysis

The API provides a solid foundation for building secure web applications and can be extended with additional features like OAuth integration, two-factor authentication, and advanced threat detection.

Next Steps

  1. Implement OAuth 2.0 for third-party authentication
  2. Add two-factor authentication (TOTP/SMS)
  3. Integrate with SIEM for advanced monitoring
  4. Add API documentation with security considerations
  5. Implement GraphQL with field-level security

Interested in frontend security? Check out our [Client-Side Security Best Practices](/projects/Web Security/frontend-security) guide.