Abdulkader Safi

My login rate limit was a lockout button

Five tries per email in fifteen minutes stopped password guessing, and it also let anyone lock a person out with five wrong guesses. The other key read a header the client writes. The same review found that changing your password left your other devices signed in.

4 min read

Share
The GitBasedDocs sign in form next to an X-Forwarded-For header with two addresses

The login limit in GitBasedDocs was short and looked sensible:

const forwarded = req?.headers?.["x-forwarded-for"]
const ip =
  (Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(",")[0]) ??
  "unknown"
if (!checkRateLimit(`login:${ip}`) || !checkRateLimit(`login:${email}`)) {
  return null
}

Five hits per key per fifteen minutes. Rereading it before daily use, both keys had a problem.

The email key blocks the owner

Anyone who knows your email can type five wrong passwords, and then you cannot sign in for fifteen minutes either. For a private docs site shared with clients, emails are not secret. The limit that stops guessing also works as a lockout button anyone can press.

The address key trusts the client

A proxy like Caddy or nginx appends the address it received the request from to X-Forwarded-For. It does not remove what was already there. So in 6.6.6.6, 10.0.0.9, the first entry is whatever the client chose to send, and the last is the one the proxy wrote.

The old code read the first. A guesser could change that header on every request and never share a key with their previous attempt.

// The proxy in front (Caddy, nginx) appends the address it got the request
// from, so the last entry is the one a client cannot forge.
// ponytail: assumes one proxy hop, the deploy shape in the README.
export function clientIp(forwarded: string | string[] | undefined): string {
  const raw = Array.isArray(forwarded) ? forwarded.join(",") : (forwarded ?? "")
  const last = raw.split(",").map((s) => s.trim()).filter(Boolean).pop()
  return last ?? "unknown"
}

Two proxies in a chain would need the second-to-last entry. The comment names that ceiling rather than adding a setting nobody needs yet.

Three keys, in order

export function allowLogin(ip: string, email: string, now = Date.now()): boolean {
  return (
    checkRateLimit(`login-ip:${ip}`, 20, 15 * MINUTE, now) &&
    checkRateLimit(`login-pair:${ip}:${email}`, 5, 15 * MINUTE, now) &&
    checkRateLimit(`login-email:${email}`, 100, 60 * MINUTE, now)
  )
}

The tight limit is on address and email together, so it only blocks the person guessing. The per-address limit stops one machine spraying many accounts. The per-email limit is a backstop against guessing from many addresses, set high enough that locking someone out takes about twenty of them.

Order matters because && stops at the first refusal. A blocked address does not spend the email's budget.

I considered a growing delay per email instead. A delay on the server holds a connection open for every attempt, and a delay keyed on the email still slows the owner down. Separate keys were simpler and stricter.

The check covers the case that motivated all of it: five guesses from one address fail, and the owner signs in from another address straight away.

Your other devices stayed signed in

Sessions in GitBasedDocs are JWTs. Revoking them works by stamping sessionsRevokedAt on the user row, and the session callback refuses any token whose loginAt is earlier.

Admin resets already used that stamp. Changing your own password did not, and the code said why:

// The person changing their own password. Other sessions stay: revoking
// them here would also end this one, and refreshing this token instead
// would give a revoked token a way back in.

Both halves are true. Stamp the row and this browser is signed out too. Offer a "refresh my token" call and a stolen token can use it to outlive the revoke.

The way out is to refresh the token in the same request that proves the password, and nowhere else:

revokedAt = await changeOwnPassword(session.user.id, current, next)

const fresh = await encode({
  token: { ...token, loginAt: revokedAt.getTime() },
  secret,
  maxAge: REMEMBER_MAX_AGE,
})
res.cookies.set(`${secure ? "__Secure-" : ""}next-auth.session-token`, fresh, { ... })

The callback refuses loginAt < revokedAt, so a token stamped with exactly the revoke time passes and every older one fails. Someone holding a stolen cookie cannot reach this code without the current password.

I checked it over HTTP on a scratch database rather than trusting the unit check. With two sessions signed in, I changed the password from the first. The first browser got a new cookie and stayed signed in. Its old cookie was refused, and so was the second session.

One side effect is written on the card: a session without "keep me logged in" gets a fresh twelve hour window from the change, because that window counts from loginAt.

Last updated 13 Sep 2026 · filed under Typescript, web application, nextjs

Every entry on this project

19 build notes, in order

Including the ones where nothing worked. You are on part 16.

Read the series →

FAQ

Frequently asked questions

Which X-Forwarded-For entry should a login rate limiter trust?

The entry your own proxy appended, which with one proxy is the last one. Proxies add the address they received the request from to the end of the header, and everything before it came from the client and can be forged.

Why is a login limit keyed only on the email a problem?

Anyone who knows the email can use up the attempts and lock the real owner out. Keying the tight limit on address and email together only blocks the guesser, with a much higher per email backstop against guessing from many addresses.

How do you sign out other devices after a password change with JWT sessions?

Store a revoke time on the user and refuse tokens issued before it. In the same request that verified the current password, re-issue this browser's token stamped with the revoke time, so it stays signed in and nothing else can refresh a revoked token.

Written by

Abdulkader Safi

Software Engineer

Lead engineer at DSRPT, from Lebanon and based in Kuwait. I write about the tools and bugs from real client work, with the numbers I measured.

About me → GitHub LinkedIn

Need this kind of work done on your project?

Start a project →

Keep reading

More from GitBasedDocs

All 19 entries →
  • The GitBasedDocs search palette showing results for a partial word
    GitBasedDocs

    · part 19 of 19

    Search that quotes whatever you type

    GitBasedDocs search moved from LIKE over every page body to SQLite FTS5 kept in step by triggers. Word prefixes and accent folding came with the tokenizer. The real work was quoting input, choosing a stable order, and a backup detail about rowids.

  • A GitBasedDocs doc page with a Linked from list under the article
    GitBasedDocs

    · part 18 of 19

    Backlinks that use the renderer's own link rules

    GitBasedDocs pages now show which pages link to them, resolve Obsidian aliases, open embedded PDFs and list recent changes. The backlinks reuse the functions that turn links into hrefs, so a page appears under Linked from exactly when its link works.

  • A docker-compose.yml with BACKUP_ENABLED set to true and a folder of dated backup archives
    GitBasedDocs

    · part 17 of 19

    Backups behind one flag in docker compose

    GitBasedDocs now has a Dockerfile, a compose file and backups you turn on with BACKUP_ENABLED. Each archive is a VACUUM INTO copy plus the asset files, and the copy is edited before it is packed so a restored app catches up on its own.