How to prevent brute force attacks in Node.js

Brute force attacks attempt to gain unauthorized access by systematically trying all possible password combinations. As the creator of CoreUI with 12 years of Node.js backend experience, I’ve implemented brute force protection strategies that successfully blocked millions of attack attempts while maintaining seamless user experience for legitimate users in enterprise applications.

The most effective approach combines rate limiting, account lockout, and CAPTCHA challenges.

Rate Limiting on Login Endpoint

Install dependencies:

npm install express-rate-limit express-slow-down

Implement strict rate limiting:

const express = require('express')
const rateLimit = require('express-rate-limit')

const app = express()

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 attempts per window
  message: 'Too many login attempts. Please try again later.',
  standardHeaders: true,
  legacyHeaders: false
})

app.post('/api/login', loginLimiter, async (req, res) => {
  const { username, password } = req.body

  // Authentication logic here
  const user = await authenticateUser(username, password)

  if (user) {
    res.json({ success: true, token: generateToken(user) })
  } else {
    res.status(401).json({ error: 'Invalid credentials' })
  }
})

app.listen(3000)

Progressive Delays

Slow down requests progressively:

const slowDown = require('express-slow-down')

const speedLimiter = slowDown({
  windowMs: 15 * 60 * 1000, // 15 minutes
  delayAfter: 3, // Allow 3 requests at full speed
  delayMs: 500, // Add 500ms delay per request after delayAfter
  maxDelayMs: 5000 // Max delay of 5 seconds
})

app.post('/api/login', speedLimiter, loginLimiter, async (req, res) => {
  // Login logic
})

Account Lockout

Implement temporary account lockout:

const loginAttempts = new Map()

async function checkAccountLockout(username) {
  const attempts = loginAttempts.get(username)

  if (!attempts) {
    return false // Not locked
  }

  if (attempts.count >= 5 && Date.now() < attempts.lockedUntil) {
    return true // Account locked
  }

  if (Date.now() >= attempts.lockedUntil) {
    loginAttempts.delete(username) // Unlock account
    return false
  }

  return false
}

function recordFailedAttempt(username) {
  const attempts = loginAttempts.get(username) || {
    count: 0,
    lockedUntil: null
  }

  attempts.count++

  if (attempts.count >= 5) {
    attempts.lockedUntil = Date.now() + (30 * 60 * 1000) // Lock for 30 minutes
  }

  loginAttempts.set(username, attempts)
}

app.post('/api/login', loginLimiter, async (req, res) => {
  const { username, password } = req.body

  if (await checkAccountLockout(username)) {
    return res.status(429).json({
      error: 'Account temporarily locked. Please try again later.'
    })
  }

  const user = await authenticateUser(username, password)

  if (user) {
    loginAttempts.delete(username) // Clear attempts on success
    res.json({ success: true, token: generateToken(user) })
  } else {
    recordFailedAttempt(username)
    res.status(401).json({ error: 'Invalid credentials' })
  }
})

Database-Based Account Lockout

For production, store lockout data in database:

const User = require('./models/User')

async function checkAndLockAccount(username) {
  const user = await User.findOne({ username })

  if (!user) {
    return null
  }

  if (user.lockoutUntil && user.lockoutUntil > Date.now()) {
    const minutesLeft = Math.ceil((user.lockoutUntil - Date.now()) / 60000)
    throw new Error(`Account locked. Try again in ${minutesLeft} minutes`)
  }

  return user
}

async function recordFailedLogin(user) {
  user.failedLoginAttempts = (user.failedLoginAttempts || 0) + 1

  if (user.failedLoginAttempts >= 5) {
    user.lockoutUntil = Date.now() + (30 * 60 * 1000) // 30 minutes
  }

  await user.save()
}

async function resetFailedAttempts(user) {
  user.failedLoginAttempts = 0
  user.lockoutUntil = null
  await user.save()
}

app.post('/api/login', loginLimiter, async (req, res) => {
  const { username, password } = req.body

  try {
    const user = await checkAndLockAccount(username)

    if (!user) {
      return res.status(401).json({ error: 'Invalid credentials' })
    }

    const isValid = await bcrypt.compare(password, user.password)

    if (isValid) {
      await resetFailedAttempts(user)
      const token = generateToken(user)
      res.json({ success: true, token })
    } else {
      await recordFailedLogin(user)
      res.status(401).json({ error: 'Invalid credentials' })
    }
  } catch (error) {
    res.status(429).json({ error: error.message })
  }
})

CAPTCHA Integration

Add CAPTCHA after failed attempts:

const axios = require('axios')

async function verifyCaptcha(token) {
  const response = await axios.post(
    'https://www.google.com/recaptcha/api/siteverify',
    null,
    {
      params: {
        secret: process.env.RECAPTCHA_SECRET,
        response: token
      }
    }
  )

  return response.data.success
}

app.post('/api/login', loginLimiter, async (req, res) => {
  const { username, password, captchaToken } = req.body

  const user = await User.findOne({ username })

  // Require CAPTCHA after 3 failed attempts
  if (user && user.failedLoginAttempts >= 3) {
    if (!captchaToken) {
      return res.status(400).json({
        error: 'CAPTCHA required',
        requiresCaptcha: true
      })
    }

    const captchaValid = await verifyCaptcha(captchaToken)

    if (!captchaValid) {
      return res.status(400).json({
        error: 'Invalid CAPTCHA'
      })
    }
  }

  // Continue with authentication
  const isValid = await bcrypt.compare(password, user.password)

  if (isValid) {
    await resetFailedAttempts(user)
    res.json({ success: true, token: generateToken(user) })
  } else {
    await recordFailedLogin(user)
    res.status(401).json({
      error: 'Invalid credentials',
      requiresCaptcha: user.failedLoginAttempts >= 3
    })
  }
})

Email Notifications

Alert users about suspicious activity:

const nodemailer = require('nodemailer')

async function sendSecurityAlert(user, ip) {
  const transporter = nodemailer.createTransporter({
    service: 'gmail',
    auth: {
      user: process.env.EMAIL_USER,
      pass: process.env.EMAIL_PASS
    }
  })

  await transporter.sendMail({
    from: process.env.EMAIL_USER,
    to: user.email,
    subject: 'Security Alert: Multiple Failed Login Attempts',
    html: `
      <p>We detected multiple failed login attempts on your account.</p>
      <p>IP Address: ${ip}</p>
      <p>Time: ${new Date().toISOString()}</p>
      <p>If this wasn't you, please reset your password immediately.</p>
    `
  })
}

async function recordFailedLogin(user, ip) {
  user.failedLoginAttempts = (user.failedLoginAttempts || 0) + 1

  if (user.failedLoginAttempts === 3) {
    await sendSecurityAlert(user, ip)
  }

  if (user.failedLoginAttempts >= 5) {
    user.lockoutUntil = Date.now() + (30 * 60 * 1000)
  }

  await user.save()
}

Best Practice Note

This is the same brute force protection strategy we use in CoreUI enterprise applications to protect millions of user accounts. Always implement progressive delays, temporary account lockouts, and CAPTCHA challenges after repeated failures. Log all failed attempts for security monitoring, and notify users via email when suspicious activity is detected. Use Redis for distributed systems to track attempts across multiple servers.

For related security patterns, check out how to use rate limiting in Node.js and how to implement authentication in Node.js.


Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


About the Author