Rate Limiting

Rate limiting is a critical security measure for authentication systems. Without it, attackers can brute-force passwords, flood OTP endpoints, and abuse password reset flows. AllSafe Fast includes a built-in rate limiter with two backends: Redis for production and in-memory for development.

Why Rate Limiting Matters for Auth

Authentication endpoints are prime targets for abuse. The specific threats rate limiting addresses include:

ThreatTarget EndpointWithout Rate LimitingWith Rate Limiting
Brute force passwordsSign-inUnlimited password attempts5 attempts per window
OTP floodingEmail OTP / Magic LinkUnlimited OTP requestsLimited requests per window
Reset token spamPassword resetUnlimited reset emailsLimited requests per window
Account enumerationSign-in / ResetTiming-based enumerationConsistent rate limits
Token guessingRefresh / VerifyUnlimited token attemptsLimited attempts per window
ℹ Rate Limiting Is Not Authentication

Rate limiting is a defense-in-depth measure, not a replacement for strong passwords, Argon2id hashing, and token security. It slows down attackers and makes automated attacks impractical, but it does not prevent them entirely. Combine it with monitoring and alerting for best results.

Sliding Window Algorithm

AllSafe Fast uses a sliding window algorithm for rate limiting. Unlike a fixed window counter (which allows bursts at window boundaries), the sliding window tracks the actual number of requests in the trailing time window:

Request Arrives
Client sends a request to a rate-limited endpoint
Remove Expired Entries
Remove requests older than the window (zremrangebyscore)
Add Current Request
Add the current request timestamp (zadd)
Count Requests in Window
Count entries in the window (zcard)
Allow or Deny
If count <= limit: allow. If count > limit: deny (429)
# Sliding window example with limit=5, window=900s (15 minutes)
#
# Timeline (timestamps in seconds):
#   t=0:   Request 1 -> count=1 -> ALLOW
#   t=100: Request 2 -> count=2 -> ALLOW
#   t=200: Request 3 -> count=3 -> ALLOW
#   t=300: Request 4 -> count=4 -> ALLOW
#   t=400: Request 5 -> count=5 -> ALLOW
#   t=500: Request 6 -> count=6 -> DENY (6 > 5)
#   t=950: Request 7 -> count=6 -> DENY (requests 1-6 still in window)
#   t=901: Request 8 -> count=5 -> ALLOW (request 1 expired, count=5)
#
# The window "slides" — it always covers the last 900 seconds

Redis Backend (Production)

The Redis rate limiter uses sorted sets and an atomic pipeline for accurate, distributed rate limiting. This is the recommended backend for production:

How It Works

The Redis backend uses four Redis operations executed as an atomic pipeline:

zremrangebyscore
Removes entries with scores (timestamps) older than the current time minus the window size. This cleans up expired entries.
zadd
Adds the current request's timestamp as a member with its timestamp as the score. Each request gets a unique member (e.g., UUID + timestamp).
zcard
Counts the number of members in the sorted set, which equals the number of requests in the current window.
expire
Sets a TTL on the key equal to the window size, ensuring the key is automatically cleaned up when no longer needed.
import time
import redis.asyncio as redis

class RedisRateLimiter:
    def __init__(self, redis_client, default_limit=5, default_window=900):
        self.redis = redis_client
        self.default_limit = default_limit
        self.default_window = default_window

    async def check(self, key: str, limit=None, window=None) -> bool:
        limit = limit or self.default_limit
        window = window or self.default_window
        now = time.time()
        window_start = now - window

        # Atomic pipeline — all operations execute together
        async with self.redis.pipeline() as pipe:
            pipe.zremrangebyscore(key, 0, window_start)  # Remove expired
            pipe.zadd(key, {str(now): now})             # Add current request
            pipe.zcard(key)                             # Count in window
            pipe.expire(key, int(window))                # Set TTL
            results = await pipe.execute()

        count = results[2]  # zcard result
        return count <= limit
💡 Why Atomic Pipeline?

The four Redis operations are executed as a single atomic pipeline. This prevents race conditions where two concurrent requests might both pass the rate limit check before either increments the counter. The pipeline ensures all operations execute as a unit.

In-Memory Backend (Development)

The in-memory rate limiter uses a defaultdict(list) to track request timestamps. It implements the same sliding window algorithm but stores data in process memory:

import time
from collections import defaultdict

class InMemoryRateLimiter:
    def __init__(self, default_limit=5, default_window=900):
        self.default_limit = default_limit
        self.default_window = default_window
        self._requests: dict[str, list[float]] = defaultdict.__call__(list)

    async def check(self, key: str, limit=None, window=None) -> bool:
        limit = limit or self.default_limit
        window = window or self.default_window
        now = time.time()
        window_start = now - window

        # Remove expired timestamps
        self._requests[key] = [
            ts for ts in self._requests[key] if ts > window_start
        ]

        # Check limit
        if len(self._requests[key]) >= limit:
            return False

        # Add current request
        self._requests[key].append(now)
        return True
⚠ In-Memory Limitations

The in-memory rate limiter does not share state across processes. If you run multiple worker processes (e.g., uvicorn --workers 4), each worker has its own independent rate limit counter. This means the effective limit is multiplied by the number of workers. Always use the Redis backend in production.

Configuration

Rate limiting is configured through environment variables or the AuthConfig constructor:

ALLSAFE_RATE_LIMIT
Maximum number of requests allowed within the window. Default: 5.
ALLSAFE_RATE_LIMIT_WINDOW
Time window in seconds. Default: 900 (15 minutes).
ALLSAFE_REDIS_URL
Redis connection URL. If set, the Redis rate limiter is used. If not set, the in-memory limiter is used. Example: redis://localhost:6379/0.
# Via environment variables
# export ALLSAFE_RATE_LIMIT=5
# export ALLSAFE_RATE_LIMIT_WINDOW=900
# export ALLSAFE_REDIS_URL=redis://localhost:6379/0

# Via AuthConfig
config = AuthConfig(
    rate_limit=5,           # 5 requests per window
    rate_limit_window=900,    # 15-minute window
    redis_url="redis://localhost:6379/0",  # Use Redis backend
)

RateLimiter API

The RateLimiter class provides three methods for checking and managing rate limits:

from allsafe_fast import RateLimiter

# RateLimiter is created internally by Auth, but you can use it directly
limiter = RateLimiter(
    redis=redis_client,       # Redis client or None for in-memory
    default_limit=5,
    default_window=900,
)

# Check if a request is allowed (returns bool)
allowed = await limiter.check(key="sign_in:alice@example.com")
if not allowed:
    raise RateLimitError(retry_after=900)

# Check with custom limit and window
allowed = await limiter.check(
    key="otp:bob@example.com",
    limit=3,      # Stricter limit for OTP
    window=3600,   # 1-hour window
)

# Get remaining requests in the window (returns int)
remaining = await limiter.remaining(key="sign_in:alice@example.com")
# remaining = 3 (out of 5 allowed)

# Reset the rate limit for a key (clears all requests)
await limiter.reset(key="sign_in:alice@example.com")
check(key, limit, window)
Checks if a request is allowed. Returns True if allowed, False if rate limit exceeded. Uses default limit/window if not specified. Increments the counter if allowed.
remaining(key, limit, window)
Returns the number of remaining requests in the current window. Does not increment the counter.
reset(key)
Clears all request records for the given key. Useful for resetting limits after successful authentication.

Protecting Auth Endpoints

AllSafe Fast automatically applies rate limiting to sensitive authentication endpoints. Here is how different endpoints are protected:

Sign-In Endpoint

# Rate limited by email + IP address
# Key: "sign_in:{email}:{ip}"
# Default: 5 attempts per 15 minutes
#
# After 5 failed attempts, the endpoint returns 429 Too Many Requests
# with a Retry-After header indicating when to try again

@app.post("/auth/sign-in")
async def sign_in(request, email: str, password: str):
    try:
        result = await auth.sign_in_with_password(email, password)
        # Reset rate limit on successful sign-in
        await auth.rate_limiter.reset(f"sign_in:{email}:{request.client.host}")
        return result
    except RateLimitError as e:
        raise HTTPException(
            status_code=429,
            detail="Too many attempts. Try again later.",
            headers={"Retry-After": str(e.retry_after)},
        )

Email OTP and Magic Link Endpoints

# Rate limited by email address
# Key: "otp:{email}" or "magic_link:{email}"
# Recommended: 3 requests per hour (stricter than sign-in)
#
# This prevents OTP flooding and email spam

@app.post("/auth/otp/request")
async def request_otp(email: str):
    key = f"otp:{email}"
    allowed = await auth.rate_limiter.check(key, limit=3, window=3600)
    if not allowed:
        raise RateLimitError(retry_after=3600)
    await auth.send_otp(email)
    return {"message": "OTP sent"}

Password Reset Endpoint

# Rate limited by email address
# Key: "password_reset:{email}"
# Recommended: 3 requests per hour
#
# Prevents reset email spam and token enumeration

@app.post("/auth/password-reset/request")
async def request_reset(email: str):
    key = f"password_reset:{email}"
    allowed = await auth.rate_limiter.check(key, limit=3, window=3600)
    if not allowed:
        raise RateLimitError(retry_after=3600)
    await auth.request_password_reset(email)
    return {"message": "If the email is registered, a reset link has been sent"}

RateLimitError

When a rate limit is exceeded, AllSafe Fast raises a RateLimitError with a retry_after attribute indicating how many seconds the client should wait before retrying:

from allsafe_fast import RateLimitError

try:
    await auth.sign_in_with_password(email, password)
except RateLimitError as e:
    print(f"Rate limited. Retry after {e.retry_after} seconds")
    # e.retry_after -> 900 (seconds until the window resets)
💡 Best Practices

1. Use the Redis backend in production for distributed rate limiting.

2. Set stricter limits for OTP and reset endpoints (3/hour) than for sign-in (5/15min).

3. Reset the rate limit counter after successful authentication.

4. Always include the Retry-After header in 429 responses.

5. Use composite keys (email + IP) to prevent one user's limit from blocking others.