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.
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.