Skip to content

A Rate Limit Alone Was Not Enough for This Login Endpoint

Adityo Guni Waluyo

Sliding window dual keying plus dummy-hash timing equalization: two separate defenses for one login endpoint.

TL;DR

A live smoke test confirmed the login rate limiter kicks in with HTTP 429 and a Retry-After header on the eleventh attempt per minute. The real fix pairs that throttling with timing equalization, running bcrypt against a dummy hash so response speed never reveals whether an account exists. Docs endpoints are now dev-only, and duplicate registrations return 409.

A live smoke test against production showed a consistent pattern. On the 11th login attempt within one minute, the server answered with HTTP 429 and a Retry-After header. The rate limiter was doing exactly what it was designed to do.

Before this change, the login endpoint of DemandScope, a fictional FastAPI service, had a classic weakness. It allowed unlimited attempts and answered faster for unknown email addresses. The early return happened before any password hashing, a pattern OWASP calls quick exit [1]. Response speed itself became a signal that an account did not exist.

The wrong guess was assuming a rate limiter alone would close the enumeration hole. Throttling slows brute force, but it does nothing about the latency difference between an existing and a missing account.

Two layers with separate jobs

What the fix actually needed were two mechanisms that do not substitute for each other. The first is a sliding window rate limiter with dual keying: 30 requests per minute per IP address and 10 requests per minute per account for login, plus 20 requests per minute per IP for registration. The key is always the direct-peer IP. The X-Forwarded-For header is never trusted, so a client cannot forge its network identity to sidestep the limiter.

The second layer is timing equalization. When an email is not found in the database, the service still runs bcrypt against a constant dummy hash [1]. The dummy must use the same work factor as real password hashes, so computation cost and response latency stay identical whether the account exists or not. OWASP recommends a work factor of 10 or more for bcrypt [4].

The standards behind this are explicit. RFC 6585 defines 429 as too many requests in a given amount of time and says the response MAY include Retry-After [2]. RFC 9110 continues: the header value is either an HTTP-date or a delay-seconds integer [3]. That integer is what clients use to decide how long to wait before retrying.

A minimal, testable implementation

The dummy-hash verify pattern fits in a few lines:

import bcrypt

# Precomputed once, same work factor as real password hashes
DUMMY_BCRYPT_HASH = b"$2b$12$C6UzMDM.H6dfI/f/IKcEeO7ZBpUvXPZjWFFMXqFQqVH2tXEuhtbCS"

def verify_login(email: str, password: str) -> bool:
    user = database.get_user_by_email(email)
    if user:
        return bcrypt.checkpw(password.encode("utf-8"), user.password_hash)
    # Run the same expensive check so latency does not reveal existence
    bcrypt.checkpw(password.encode("utf-8"), DUMMY_BCRYPT_HASH)
    return False

The limiter, in its smallest form, keeps a deque of timestamps per key:

import time
from collections import deque

# State lives per process; fine for a single-container deployment
request_history = {}

def is_allowed(key: str, limit: int, window_seconds: int) -> bool:
    now = time.time()
    history = request_history.setdefault(key, deque())
    while history and history[0] <= now - window_seconds:
        history.popleft()
    if len(history) >= limit:
        return False
    history.append(now)
    return True

One honest caveat: that state lives per process. For a single-container deployment like the one DemandScope runs today, it is fine. Raise the worker count and each process counts separately, so the effective limit loosens. It is a known trade-off, not a hidden one.

The API surface gets closed too

The hardening did not stop at the login endpoint. FastAPI serves interactive documentation at /docs by default [5]. On DemandScope, that path and the OpenAPI schema are now served only when the APP_ENV environment variable equals dev, so production no longer publishes its endpoint map. As a bonus, the duplicate-email race on registration is now mapped from a database integrity error to HTTP 409, giving clients a safe message without leaking schema details.

Splitting the jobs this way shrinks the attack surface from both directions. Nothing hints at account existence through response speed, and brute-force volume is absorbed before it loads the expensive parts of the system.

Sources

[1] OWASP Authentication Cheat Sheet, cheatsheetseries.owasp.org

[2] RFC 6585, Additional HTTP Status Codes, rfc-editor.org

[3] RFC 9110, HTTP Semantics section 10.2.3, rfc-editor.org

[4] OWASP Password Storage Cheat Sheet, cheatsheetseries.owasp.org

[5] FastAPI class reference, docs_url, fastapi.tiangolo.com

Related articles