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
- Implement OAuth 2.0 for third-party authentication
- Add two-factor authentication (TOTP/SMS)
- Integrate with SIEM for advanced monitoring
- Add API documentation with security considerations
- Implement GraphQL with field-level security
Interested in frontend security? Check out our [Client-Side Security Best Practices](/projects/Web Security/frontend-security) guide.