I’ve been building web applications for years, and security always tops my priorities. When clients need robust authentication, I consistently turn to a powerful trio: Passport.js for strategy handling, JWT for stateless tokens, and Redis for session management. Why this combination? It scales beautifully while maintaining security integrity. Today I’ll walk you through implementing this system properly. Let’s build something secure together.
First, our foundation: Express setup with essential security middleware. We’ll use Helmet for HTTP headers and express-rate-limit for basic protection. Notice how we initialize Redis for session storage:
// src/app.js
const express = require('express');
const passport = require('passport');
const redisClient = require('./config/redis');
const rateLimit = require('express-rate-limit');
const app = express();
app.use(helmet());
app.use(cors());
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: 'Too many requests'
});
app.use('/auth', limiter);
User modeling deserves special attention. We implement account locking after repeated failed logins and password versioning. How do we prevent compromised credentials from causing havoc? Here’s our schema approach:
// In User model
userSchema.methods.incLoginAttempts = async function() {
if (this.lockUntil && this.lockUntil < Date.now()) {
this.loginAttempts = 1;
this.lockUntil = undefined;
} else {
this.loginAttempts += 1;
if (this.loginAttempts >= maxAttempts) {
this.lockUntil = Date.now() + lockTime * 60 * 1000;
}
}
await this.save();
};
Token management is where Redis shines. We store refresh tokens with expiration and implement immediate invalidation on logout. What happens during token refresh? We verify the token hasn’t been blacklisted:
// Token service
async function verifyRefreshToken(token) {
const storedToken = await redisClient.get(`bl_${token}`);
if (storedToken) throw new Error('Token revoked');
return jwt.verify(token, process.env.JWT_REFRESH_SECRET);
}
Passport strategies glue everything together. The local strategy handles username/password, while JWT protects API routes. Notice how we attach user roles to the token payload:
// Passport config
passport.use(new LocalStrategy(
async (username, password, done) => {
try {
const user = await User.findOne({ username });
if (!user) return done(null, false);
const isValid = await user.comparePassword(password);
return isValid ? done(null, user) : done(null, false);
} catch (err) {
return done(err);
}
}
));
For route protection, our RBAC middleware checks tokens and permissions simultaneously. Why settle for basic authentication when you can authorize by role?
// RBAC middleware
function authorize(roles = []) {
return [
passport.authenticate('jwt', { session: false }),
(req, res, next) => {
if (roles.length && !roles.includes(req.user.role)) {
return res.status(403).json({ message: 'Forbidden' });
}
next();
}
];
}
Testing is non-negotiable. We verify all authentication flows with Supertest. Can your current system pass these tests?
// Auth test suite
test('Login locks account after 5 failures', async () => {
for (let i = 0; i < 5; i++) {
await request.post('/login')
.send({ username: 'test', password: 'wrong' });
}
const user = await User.findOne({ username: 'test' });
expect(user.isLocked).toBeTruthy();
});
Token refresh requires careful handling. We issue short-lived access tokens and longer-lived refresh tokens. When refreshing, we increment token versions to invalidate previous ones:
// In auth controller
async function refreshToken(req, res) {
const { refreshToken } = req.body;
const payload = verifyRefreshToken(refreshToken);
const user = await User.findById(payload.sub);
if (user.refreshTokenVersion !== payload.version) {
throw new Error('Token revoked');
}
const newAccessToken = createAccessToken(user);
res.json({ accessToken: newAccessToken });
}
Logout isn’t just clearing cookies. We add tokens to Redis blacklist and increment refresh token versions:
// Logout handler
async function logout(req, res) {
const token = req.headers.authorization.split(' ')[1];
const remainingTime = calculateTokenExpiry(token);
await redisClient.set(`bl_${token}`, 'revoked', remainingTime);
await req.user.invalidateRefreshTokens();
res.status(204).end();
}
Brute force protection combines rate limiting and account locking. Our dual-layer defense:
- Express-rate-limit at network level
- Account lock at application level
What threats does this prevent? Credential stuffing and distributed attacks.
Building this requires attention to edge cases. Token expiration mismatches? Version conflicts? We test these scenarios rigorously. Your authentication system should withstand real-world attacks, not just pass development checks.
This implementation has protected client applications handling millions of requests. The patterns scale well whether you’re using MongoDB, PostgreSQL, or other databases. I’ve battle-tested this in production environments - it works.
Security evolves constantly. What measures will you add next? Perhaps biometric checks or device fingerprinting? Share your enhancements below. If this approach resonates with you, spread the knowledge - like and share this with your network. Your comments fuel future improvements.