Building a Secure JWT Authentication System with Express.js and bcrypt
Introduction
Authentication is the backbone of modern web applications, yet it's often implemented incorrectly, leaving applications vulnerable to attacks. As a full-stack developer, I've seen countless projects with weak authentication systems that put user data at risk. Today, we'll build a robust JWT-based authentication system using Express.js that follows security best practices.
Why JWT for Authentication?
JSON Web Tokens (JWT) offer several advantages over traditional session-based authentication:
- Stateless: No server-side session storage required
- Scalable: Perfect for distributed systems and microservices
- Cross-domain: Works seamlessly across different domains
- Mobile-friendly: Ideal for mobile applications and APIs
Setting Up the Project
First, let's create our Express.js application with the necessary dependencies:
npm init -y
npm install express bcryptjs jsonwebtoken helmet cors express-rate-limit express-validator dotenv
npm install -D nodemonCreate a basic server structure:
// server.js
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
require('dotenv').config();
const app = express();
// Security middleware
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: '10mb' }));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
});
app.use(limiter);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});Implementing Secure Password Hashing
Never store passwords in plain text. We'll use bcrypt for secure password hashing:
// utils/auth.js
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
class AuthUtils {
static async hashPassword(password) {
const saltRounds = 12; // Higher is more secure but slower
return await bcrypt.hash(password, saltRounds);
}
static async comparePassword(plainPassword, hashedPassword) {
return await bcrypt.compare(plainPassword, hashedPassword);
}
static generateTokens(userId) {
const payload = { userId, type: 'access' };
const accessToken = jwt.sign(
payload,
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId, type: 'refresh' },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
}
static verifyToken(token, secret) {
try {
return jwt.verify(token, secret);
} catch (error) {
throw new Error('Invalid token');
}
}
}
module.exports = AuthUtils;Building Authentication Routes
Let's create secure registration and login endpoints with proper validation:
// routes/auth.js
const express = require('express');
const { body, validationResult } = require('express-validator');
const rateLimit = require('express-rate-limit');
const AuthUtils = require('../utils/auth');
const router = express.Router();
// Stricter rate limiting for auth routes
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // limit each IP to 5 requests per windowMs
message: 'Too many authentication attempts, please try again later.'
});
// Registration endpoint
router.post('/register',
authLimiter,
[
body('email').isEmail().normalizeEmail(),
body('password')
.isLength({ min: 8 })
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
.withMessage('Password must contain uppercase, lowercase, number and special character'),
body('username').isLength({ min: 3, max: 20 }).isAlphanumeric()
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { email, password, username } = req.body;
// Check if user exists (implement your database logic)
// const existingUser = await User.findByEmail(email);
// if (existingUser) {
// return res.status(409).json({ message: 'User already exists' });
// }
const hashedPassword = await AuthUtils.hashPassword(password);
// Save user to database (implement your database logic)
// const user = await User.create({ email, username, password: hashedPassword });
const tokens = AuthUtils.generateTokens('user_id_here');
res.status(201).json({
message: 'User created successfully',
tokens
});
} catch (error) {
res.status(500).json({ message: 'Internal server error' });
}
}
);Implementing Authentication Middleware
Create middleware to protect routes that require authentication:
// middleware/auth.js
const AuthUtils = require('../utils/auth');
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ message: 'Access token required' });
}
try {
const decoded = AuthUtils.verifyToken(token, process.env.JWT_SECRET);
if (decoded.type !== 'access') {
return res.status(403).json({ message: 'Invalid token type' });
}
req.userId = decoded.userId;
next();
} catch (error) {
return res.status(403).json({ message: 'Invalid or expired token' });
}
};
module.exports = { authenticateToken };Security Best Practices Implementation
Here are critical security measures to implement:
1. Environment Variables
Store sensitive data in environment variables:
# .env
JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters
JWT_REFRESH_SECRET=your-refresh-secret-different-from-access
DB_CONNECTION_STRING=your-database-connection
PORT=30002. Token Refresh Mechanism
// routes/auth.js
router.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token required' });
}
try {
const decoded = AuthUtils.verifyToken(refreshToken, process.env.JWT_REFRESH_SECRET);
if (decoded.type !== 'refresh') {
return res.status(403).json({ message: 'Invalid token type' });
}
const tokens = AuthUtils.generateTokens(decoded.userId);
res.json({ tokens });
} catch (error) {
res.status(403).json({ message: 'Invalid refresh token' });
}
});Testing Your Authentication System
Always test your authentication thoroughly:
// Test with curl
curl -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"username": "testuser",
"password": "SecurePass123!"
}'Conclusion
Building secure authentication requires attention to multiple layers of security: proper password hashing, secure token generation, input validation, rate limiting, and secure storage of secrets. This implementation provides a solid foundation for JWT-based authentication that you can extend based on your specific requirements.
Remember to regularly update dependencies, monitor for security vulnerabilities, and consider implementing additional features like two-factor authentication and account lockout mechanisms for production applications.
Related Posts
Building Secure Authentication with JWT and OAuth 2.0: A Complete Implementation Guide
Learn to implement bulletproof authentication using JWT tokens and OAuth 2.0 with practical security measures and code examples.
Building Secure REST APIs: A Complete Authentication and Authorization Guide
Master API security with JWT tokens, role-based access control, and essential security practices for production applications.
Building Secure User Authentication with JWT and Refresh Tokens in Node.js
Learn to implement bulletproof JWT authentication with refresh token rotation and security best practices in Node.js applications.