Menu
CoddyTech

Rate Limiting Logins

Part of the Auth and Security section of Coddy's Node.js journey. Lesson 23 of 24.

A script can try thousands of passwords a minute against one account. Count the failed logins per username, and after five in a minute refuse to check any more until the minute is over.

Status 429 Too Many Requests says why, and the Retry-After header says how many seconds to wait. Count failures only: a person who types the right password must never lock themselves out. A Map stores values by key: map.get(key) reads one and map.set(key, value) writes one. It lives in memory, which is enough for one server, and it empties when the server restarts. This example allows one reset email a minute, so it keeps only a time. Your login counts failures, so its record keeps a count and a time.

const lastSent = new Map();

app.post('/reset', (req, res) => {
    const email = String(req.body.email ?? '').toLowerCase();
    const wait = (lastSent.get(email) ?? 0) + 60_000 - Date.now();
    if (wait > 0) {
        res.set('Retry-After', String(Math.ceil(wait / 1000)));
        return res.status(429).json({ error: 'One reset email a minute' });
    }
    lastSent.set(email, Date.now());
    // ... send the email
});

This learning version counts completed failures in one server process. Concurrent requests can exceed its limit while password checks are pending; a production limiter must count an attempt before checking the password and keep counts in a store shared by all servers.

challenge icon

Challenge

Easy

This step opens the course's prepared private notes API project, rather than your project files from Security Headers (including server.js). Your earlier work stays in its own lesson. Read the supplied files before editing server.js. This copy contains the private notes owner filters, role checks and security headers.

A script can try thousands of passwords a minute against one account. Slow it down: after 5 wrong passwords for one username within a minute, stop checking passwords for that name until the minute is up.

  1. In server.js, above POST /login, keep a record of failed logins for each username in a Map. For each name, store how many failures there were and when the first one happened.
  2. In POST /login, before you check the password, look up the username's record. A record whose first failure is a minute old or more counts as no record. When the name has 5 failures within the minute, respond with status 429 and {"error": "Too many failed logins. Try again later."}. Do this even when the password would be right. 429 Too Many Requests means "slow down".
  3. On that 429, set the header Retry-After to the whole number of seconds left until a minute has passed since the first failure, for example Retry-After: 42.
  4. When the password is wrong, add 1 to that username's failures, then respond with the usual 401. The first failure, or the first after an old record, starts a new count at 1 with the current time.
  5. Never count a successful login. Failures for one username never limit another.

Try it: Press Run.

  • Send POST /signup with {"username": "ada", "password": "correct horse"}.
  • Send POST /login with {"username": "ada", "password": "guess"} five times: status 401 each time.
  • Send it a sixth time: status 429. The Headers tab shows Retry-After with a number of seconds, at most 60.
  • Send the right password, {"username": "ada", "password": "correct horse"}: still 429.
  • Send POST /signup, then POST /login, with {"username": "bob", "password": "correct horse"}: status 200, because only ada is limited. After a minute, ada can log in again.

Try it yourself

import express from 'express';
import { ObjectId } from 'mongodb';
import { randomBytes } from 'node:crypto';
import { parseCookies } from './cookies.js';
import { notes, sessions, users } from './db.js';
import { hashPassword, verifyPassword } from './passwords.js';

const app = express();
app.disable('x-powered-by');
app.use(express.json());

// Headers that tell the browser to be careful with every answer.
app.use((req, res, next) => {
    res.set({
        'X-Content-Type-Options': 'nosniff',
        'Referrer-Policy': 'no-referrer',
        'Content-Security-Policy': "default-src 'self'",
    });
    next();
});

// The one admin account. Its password comes from the environment.
await users.insertOne({ username: 'admin', role: 'admin', passwordHash: await hashPassword(process.env.ADMIN_PASSWORD) });

app.post('/signup', async (req, res) => {
    const username = String(req.body.username ?? '').trim().toLowerCase();
    const password = String(req.body.password ?? '');
    if (!username || password.length < 8) {
        return res.status(400).json({ error: 'Pick a username and a password of at least 8 characters' });
    }
    if (await users.findOne({ username })) {
        return res.status(409).json({ error: 'That username is taken' });
    }
    await users.insertOne({ username, role: 'member', passwordHash: await hashPassword(password) });
    res.status(201).json({ username });
});

app.post('/login', async (req, res) => {
    const username = String(req.body.username ?? '').trim().toLowerCase();
    const user = await users.findOne({ username });
    if (!user || !(await verifyPassword(String(req.body.password ?? ''), user.passwordHash))) {
        return res.status(401).json({ error: 'Wrong username or password' });
    }
    const sid = randomBytes(24).toString('base64url');
    await sessions.insertOne({ _id: sid, userId: user._id, createdAt: new Date() });
    res.cookie('sid', sid, {
        httpOnly: true,
        sameSite: 'lax',
        secure: true,
        maxAge: 24 * 60 * 60 * 1000,
    });
    res.json({ username: user.username });
});

// Finds the user of the session cookie, or answers 401.
async function requireUser(req, res, next) {
    const { sid } = parseCookies(req.get('cookie'));
    const session = sid && (await sessions.findOne({ _id: sid }));
    const user = session && (await users.findOne({ _id: session.userId }));
    if (!user) {
        return res.status(401).json({ error: 'Log in first' });
    }
    req.user = { id: user._id.toHexString(), username: user.username, role: user.role };
    next();
}

// Answers 403 unless the logged in user has this role.
function requireRole(role) {
    return (req, res, next) => {
        if (req.user.role !== role) {
            return res.status(403).json({ error: 'Not allowed' });
        }
        next();
    };
}

app.get('/me', requireUser, (req, res) => {
    res.json({ username: req.user.username });
});

app.post('/logout', async (req, res) => {
    const { sid } = parseCookies(req.get('cookie'));
    if (sid) {
        await sessions.deleteOne({ _id: sid });
    }
    res.clearCookie('sid');
    res.status(204).end();
});

// Your notes as a page.
app.get('/', requireUser, async (req, res) => {
    const list = await notes.find({ owner: req.user.id }).toArray();
    const items = list.map((note) => `<li>${note.text}</li>`).join('');
    res.send(`<!doctype html><title>Vault</title><h1>${req.user.username}'s notes</h1><ul>${items}</ul>`);
});

// Notes: each user reads and writes only their own.
app.get('/notes', requireUser, async (req, res) => {
    res.json(await notes.find({ owner: req.user.id }).toArray());
});

app.post('/notes', requireUser, async (req, res) => {
    const note = { owner: req.user.id, text: String(req.body.text ?? '') };
    await notes.insertOne(note);
    res.status(201).json(note);
});

app.get('/notes/:id', requireUser, async (req, res) => {
    if (!ObjectId.isValid(req.params.id)) {
        return res.status(404).json({ error: 'Note not found' });
    }
    const note = await notes.findOne({ _id: new ObjectId(req.params.id), owner: req.user.id });
    if (!note) {
        return res.status(404).json({ error: 'Note not found' });
    }
    res.json(note);
});

app.delete('/notes/:id', requireUser, async (req, res) => {
    if (!ObjectId.isValid(req.params.id)) {
        return res.status(404).json({ error: 'Note not found' });
    }
    const { deletedCount } = await notes.deleteOne({ _id: new ObjectId(req.params.id), owner: req.user.id });
    if (!deletedCount) {
        return res.status(404).json({ error: 'Note not found' });
    }
    res.status(204).end();
});

// For admins: how many accounts there are.
app.get('/admin', requireUser, requireRole('admin'), async (req, res) => {
    res.json({ users: await users.countDocuments() });
});

app.listen(3000, () => {
    console.log('Vault is running on port 3000');
});

All lessons in Auth and Security

Practice on your own: Express playground