Skip to content

Rate limiting

Use Configuration as the main guide for the current Redis-backed auth contract: the RedisAuthPreset flow, stable slot and group names, namespace defaults and override patterns, and the TOTP Redis-store setup all live there. This page focuses on the public rate-limit types themselves.

The higher-level one-client Redis preset lives in litestar_auth.contrib.redis.RedisAuthPreset. This module owns the lower-level shared builder plus the AuthRateLimitSlot enum accepted by SharedRateLimitConfigOptions.enabled and SharedRateLimitConfigOptions.disabled.

For a smaller public-entry-point preset, AuthRateLimitConfig.strict(backend=...) wires a shared backend to login, register, and totp_verify using the package default scopes and route-style namespaces. Pass a backend that already encodes the lower attempt budget you want for those surfaces.

For internal or lower-risk deployments, AuthRateLimitConfig.lenient(backend=...) uses the supplied built-in backend for login, refresh, and register, then clones that limiter with a five-attempt cap for token- and secret-bearing routes such as password reset, verification, and TOTP. That keeps the broader environment budget off the sensitive recovery and step-up surfaces.

For local development, test harnesses, or other trusted environments that want the plugin wiring without active throttling, AuthRateLimitConfig.disabled() returns a config where every auth slot is left unset.

Import the builder aliases and slot enum from litestar_auth.ratelimit when app code annotates or reuses the shared-backend inventory:

from litestar_auth.ratelimit import (
    AuthRateLimitEndpointGroup,
    AuthRateLimitSlot,
    SharedRateLimitConfigOptions,
)

shared_options = SharedRateLimitConfigOptions(
    enabled=tuple(AuthRateLimitSlot),
    disabled={AuthRateLimitSlot.VERIFY_TOKEN, AuthRateLimitSlot.REQUEST_VERIFY_TOKEN},
)
  • AuthRateLimitSlot names the per-endpoint enum keys accepted by SharedRateLimitConfigOptions.enabled, SharedRateLimitConfigOptions.disabled, and SharedRateLimitConfigOptions.endpoint_overrides.
  • AuthRateLimitEndpointGroup names the shared-backend keys accepted by SharedRateLimitConfigOptions.group_backends.
  • Iterate AuthRateLimitSlot directly when you need every supported slot for an explicit SharedRateLimitConfigOptions.enabled value.
  • Use {AuthRateLimitSlot.VERIFY_TOKEN, AuthRateLimitSlot.REQUEST_VERIFY_TOKEN} for SharedRateLimitConfigOptions.disabled when verification routes stay off.

litestar_auth.ratelimit

Rate-limiting helpers for authentication endpoints.

Use :meth:AuthRateLimitConfig.from_shared_backend for direct shared-backend assembly when one :class:InMemoryRateLimiter or :class:RedisRateLimiter should back the standard auth endpoint set. For the higher-level one-client Redis preset that also builds :class:~litestar_auth.totp.RedisTotpEnrollmentStore and :class:~litestar_auth.totp.RedisUsedTotpCodeStore, see :class:litestar_auth.contrib.redis.RedisAuthPreset. Keep manual AuthRateLimitConfig(...) plus EndpointRateLimit(...) assembly for advanced cases that need fully custom per-endpoint wiring.

Examples:

Build the shared-backend recipe::

from litestar_auth.ratelimit import (
    AuthRateLimitConfig,
    AuthRateLimitSlot,
    RedisRateLimiter,
    SharedRateLimitConfigOptions,
)

rate_limit_config = AuthRateLimitConfig.from_shared_backend(
    RedisRateLimiter(redis=redis_client, max_attempts=5, window_seconds=60),
    options=SharedRateLimitConfigOptions(
        disabled={AuthRateLimitSlot.VERIFY_TOKEN, AuthRateLimitSlot.REQUEST_VERIFY_TOKEN},
    ),
)

AuthRateLimitEndpointGroup = Literal['api_keys', 'login', 'password_reset', 'refresh', 'register', 'totp', 'verification']

RateLimitScope = Literal['api_key_id', 'ip', 'ip_email']

TotpSensitiveEndpoint = Literal['enable', 'confirm_enable', 'verify', 'disable', 'regenerate_recovery_codes']

AuthRateLimitConfig(login=None, change_password=None, refresh=None, register=None, forgot_password=None, reset_password=None, totp_enable=None, totp_confirm_enable=None, totp_verify=None, totp_disable=None, totp_regenerate_recovery_codes=None, verify_token=None, request_verify_token=None, api_key_create=None, api_key_update=None, api_key_use=None) dataclass

Optional rate-limit rules for auth-related endpoints.

disabled() classmethod

Build a preset that disables rate limiting for every auth endpoint.

Returns:

Type Description
Self

New config with every supported auth slot left unset.

Source code in litestar_auth/ratelimit/_config.py
@classmethod
def disabled(cls) -> Self:
    """Build a preset that disables rate limiting for every auth endpoint.

    Returns:
        New config with every supported auth slot left unset.
    """
    return cls()

from_shared_backend(backend, *, options=None) classmethod

Build endpoint-specific limiters from the package-owned shared-backend recipe.

The builder uses the private endpoint catalog for default scopes and namespace tokens, then applies override precedence in this order:

  1. backend for every enabled slot
  2. group_backends for slot groups such as totp or verification
  3. endpoint_overrides for full slot replacement or explicit None disablement

Parameters:

Name Type Description Default
backend RateLimiterBackend

Default limiter backend for enabled auth slots.

required
options SharedRateLimitConfigOptions | None

Optional shared builder options. Use SharedRateLimitConfigOptions(disabled={...}) to leave built-in verification routes disabled.

None

Returns:

Type Description
Self

New config populated from the shared-backend builder inputs.

Source code in litestar_auth/ratelimit/_config.py
@classmethod
def from_shared_backend(
    cls,
    backend: RateLimiterBackend,
    *,
    options: SharedRateLimitConfigOptions | None = None,
) -> Self:
    """Build endpoint-specific limiters from the package-owned shared-backend recipe.

    The builder uses the private endpoint catalog for default scopes and namespace
    tokens, then applies override precedence in this order:

    1. ``backend`` for every enabled slot
    2. ``group_backends`` for slot groups such as ``totp`` or ``verification``
    3. ``endpoint_overrides`` for full slot replacement or explicit ``None`` disablement

    Args:
        backend: Default limiter backend for enabled auth slots.
        options: Optional shared builder options. Use
            ``SharedRateLimitConfigOptions(disabled={...})`` to leave built-in verification routes disabled.

    Returns:
        New config populated from the shared-backend builder inputs.
    """
    resolved_options = options or SharedRateLimitConfigOptions()
    group_backend_map: dict[AuthRateLimitEndpointGroup, RateLimiterBackend] = dict(
        resolved_options.group_backends or {},
    )
    endpoint_override_map = dict(resolved_options.endpoint_overrides or {})
    config_kwargs = dict(
        _iter_auth_rate_limit_config_items(
            _AuthRateLimitConfigItems(
                catalog=_SLOT_CATALOG,
                backend=backend,
                enabled=resolved_options.enabled,
                disabled=resolved_options.disabled,
                group_backends=group_backend_map,
                endpoint_overrides=endpoint_override_map,
                endpoint_type=cls._endpoint_rate_limit_type,
                trusted_proxy=resolved_options.trusted_proxy,
                identity_fields=resolved_options.identity_fields,
                trusted_headers=resolved_options.trusted_headers,
            ),
        ),
    )
    return cls(**cast("Any", config_kwargs))

lenient(*, backend) classmethod

Build a lenient preset for internal or low-risk deployments.

The supplied backend sets the broader budget used for the lower-risk login, change-password, refresh, and registration surfaces. Token- and secret-bearing flows still receive a stricter built-in limiter clone capped at five attempts per window so reset, verification, and TOTP endpoints do not inherit an overly permissive budget.

Parameters:

Name Type Description Default
backend RateLimiterBackend

Built-in shared backend instance for the lenient preset.

required

Returns:

Type Description
Self

New config with route-style namespaces for every supported auth slot.

Source code in litestar_auth/ratelimit/_config.py
@classmethod
def lenient(cls, *, backend: RateLimiterBackend) -> Self:
    """Build a lenient preset for internal or low-risk deployments.

    The supplied backend sets the broader budget used for the lower-risk
    login, change-password, refresh, and registration surfaces. Token- and secret-bearing
    flows still receive a stricter built-in limiter clone capped at five
    attempts per window so reset, verification, and TOTP endpoints do not
    inherit an overly permissive budget.

    Args:
        backend: Built-in shared backend instance for the lenient preset.

    Returns:
        New config with route-style namespaces for every supported auth slot.
    """
    strict_backend = _clone_backend_with_capped_attempts(
        backend,
        max_attempts_cap=_LENIENT_STRICT_MAX_ATTEMPTS_CAP,
    )
    config_kwargs = {
        recipe.slot.value: cls._endpoint_rate_limit_type(
            backend=backend if recipe.slot in _LENIENT_AUTH_RATE_LIMIT_SHARED_SLOTS else strict_backend,
            scope=recipe.default_scope,
            namespace=recipe.default_namespace,
        )
        for recipe in _SLOT_RECIPES
    }
    return cls(**cast("Any", config_kwargs))

strict(*, backend) classmethod

Build a strict preset for public-facing sign-in surfaces.

The provided backend should already be configured with the lower attempt budget you want to enforce. This preset wires that shared backend to the highest-risk credential entry points: login, change-password, register, and TOTP verify.

Parameters:

Name Type Description Default
backend RateLimiterBackend

Shared backend instance for the strict preset slots.

required

Returns:

Type Description
Self

New config with only the strict preset slots enabled.

Source code in litestar_auth/ratelimit/_config.py
@classmethod
def strict(cls, *, backend: RateLimiterBackend) -> Self:
    """Build a strict preset for public-facing sign-in surfaces.

    The provided backend should already be configured with the lower attempt
    budget you want to enforce. This preset wires that shared backend to the
    highest-risk credential entry points: login, change-password, register,
    and TOTP verify.

    Args:
        backend: Shared backend instance for the strict preset slots.

    Returns:
        New config with only the strict preset slots enabled.
    """
    config_kwargs = {
        recipe.slot.value: cls._endpoint_rate_limit_type(
            backend=backend,
            scope=recipe.default_scope,
            namespace=recipe.default_namespace,
        )
        for recipe in _SLOT_RECIPES
        if recipe.slot in _STRICT_AUTH_RATE_LIMIT_PRESET_SLOTS
    }
    return cls(**cast("Any", config_kwargs))

AuthRateLimitSlot

Bases: StrEnum

IDE-friendly enum of supported auth rate-limit endpoint slots.

EndpointRateLimit(backend, scope, namespace, trusted_proxy=False, identity_fields=_DEFAULT_IDENTITY_FIELDS, trusted_headers=_DEFAULT_TRUSTED_HEADERS) dataclass

Per-endpoint rate-limit settings and request hook.

before_request(request) async

Reject the request with 429 when its key is over the configured limit.

Security

Only set trusted_proxy=True when this service is behind a trusted proxy or load balancer that overwrites client IP headers. Otherwise, attackers can spoof headers like X-Forwarded-For and evade or poison rate-limiting keys.

Raises:

Type Description
TooManyRequestsException

If the request exceeded the configured limit.

Source code in litestar_auth/ratelimit/_endpoint.py
async def before_request(self, request: Request[Any, Any, Any]) -> None:
    """Reject the request with 429 when its key is over the configured limit.

    Security:
        Only set ``trusted_proxy=True`` when this service is behind a trusted
        proxy or load balancer that overwrites client IP headers. Otherwise,
        attackers can spoof headers like ``X-Forwarded-For`` and evade or
        poison rate-limiting keys.

    Raises:
        TooManyRequestsException: If the request exceeded the configured limit.
    """
    key = await self.build_key(request)
    if await self.backend.check(key):
        return

    retry_after = await self.backend.retry_after(key)
    logger.warning(
        "Rate limit exceeded",
        extra={
            "event": "rate_limit_triggered",
            "namespace": self.namespace,
            "scope": self.scope,
            "trusted_proxy": self.trusted_proxy,
        },
    )
    msg = "Too many requests."
    raise TooManyRequestsException(
        detail=msg,
        headers={"Retry-After": str(max(retry_after, 1))},
    )

build_key(request) async

Build the backend key for the given request.

Returns:

Type Description
str

Namespaced rate-limit key for the request.

Source code in litestar_auth/ratelimit/_endpoint.py
async def build_key(self, request: Request[Any, Any, Any]) -> str:
    """Build the backend key for the given request.

    Returns:
        Namespaced rate-limit key for the request.
    """
    host = _client_host(request, trusted_proxy=self.trusted_proxy, trusted_headers=self.trusted_headers)
    parts = [self.namespace, _safe_key_part(host)]
    if self.scope == "ip_email":
        email = await _extract_email(request, identity_fields=self.identity_fields)
        if email:
            parts.append(_safe_key_part(email))
    if self.scope == "api_key_id":
        key_id = _extract_api_key_id(request)
        if key_id:
            parts.append(_safe_key_part(key_id))

    return ":".join(parts)

increment(request) async

Record a failed or rate-limited attempt for the current request.

Source code in litestar_auth/ratelimit/_endpoint.py
async def increment(self, request: Request[Any, Any, Any]) -> None:
    """Record a failed or rate-limited attempt for the current request."""
    await self.backend.increment(await self.build_key(request))

reset(request) async

Clear stored attempts for the current request key.

Source code in litestar_auth/ratelimit/_endpoint.py
async def reset(self, request: Request[Any, Any, Any]) -> None:
    """Clear stored attempts for the current request key."""
    await self.backend.reset(await self.build_key(request))

SharedRateLimitConfigOptions(enabled=None, disabled=(), group_backends=None, endpoint_overrides=None, trusted_proxy=False, identity_fields=_DEFAULT_IDENTITY_FIELDS, trusted_headers=_DEFAULT_TRUSTED_HEADERS) dataclass

Shared-backend builder options for auth endpoint rate-limit configs.

Parameters:

Name Type Description Default
enabled Iterable[AuthRateLimitSlot] | None

Optional auth slot enum values to build. Defaults to all supported slots.

None
disabled Iterable[AuthRateLimitSlot]

Auth slot enum values to leave unset, even when they would otherwise be enabled.

()
group_backends Mapping[AuthRateLimitEndpointGroup, RateLimiterBackend] | None

Optional backend overrides keyed by auth slot group.

None
endpoint_overrides Mapping[AuthRateLimitSlot, EndpointRateLimit | None] | None

Optional full per-slot replacements. None disables a slot.

None
trusted_proxy bool

Shared trusted-proxy setting applied to generated limiters.

False
identity_fields tuple[str, ...]

Shared request body identity fields applied to generated limiters.

_DEFAULT_IDENTITY_FIELDS
trusted_headers tuple[str, ...]

Shared trusted proxy header names applied to generated limiters.

_DEFAULT_TRUSTED_HEADERS

InMemoryRateLimiter(*, max_attempts, window_seconds, max_keys=100000, sweep_interval=1000, clock=time.monotonic)

Async-safe in-memory sliding-window rate limiter.

Not safe for multi-process or multi-host deployments; use :class:RedisRateLimiter for shared storage (e.g. multi-worker or multi-pod).

Store the limiter configuration and request counters.

Raises:

Type Description
ValueError

If any limiter or storage configuration is invalid.

Source code in litestar_auth/ratelimit/_memory.py
def __init__(
    self,
    *,
    max_attempts: int,
    window_seconds: float,
    max_keys: int = 100_000,
    sweep_interval: int = 1_000,
    clock: Clock = time.monotonic,
) -> None:
    """Store the limiter configuration and request counters.

    Raises:
        ValueError: If any limiter or storage configuration is invalid.
    """
    _validate_configuration(max_attempts=max_attempts, window_seconds=window_seconds)
    if max_keys < 1:
        msg = "max_keys must be at least 1"
        raise ValueError(msg)
    if sweep_interval < 1:
        msg = "sweep_interval must be at least 1"
        raise ValueError(msg)

    self.max_attempts = max_attempts
    self.window_seconds = window_seconds
    self.max_keys = max_keys
    self.sweep_interval = sweep_interval
    self._clock: Clock = clock
    self._lock = asyncio.Lock()
    self._windows: dict[str, SlidingWindow] = {}
    self._operation_count = 0

is_shared_across_workers property

In-memory counters are process-local and not shared across workers.

check(key) async

Return whether key can perform another attempt.

Source code in litestar_auth/ratelimit/_memory.py
async def check(self, key: str) -> bool:
    """Return whether ``key`` can perform another attempt."""
    async with self._lock:
        now = read_clock(self._clock)
        self._maybe_sweep(now)
        timestamps = self._prune(key, now)
        if timestamps is None:
            if self._is_at_capacity_after_prune(now):
                self._log_capacity_rejection()
                return False
            return True

        return len(timestamps) < self.max_attempts

increment(key) async

Record a new attempt for key in the current window.

Source code in litestar_auth/ratelimit/_memory.py
async def increment(self, key: str) -> None:
    """Record a new attempt for ``key`` in the current window."""
    async with self._lock:
        now = read_clock(self._clock)
        self._maybe_sweep(now)
        timestamps = self._prune(key, now)
        if timestamps is None:
            if self._is_at_capacity_after_prune(now):
                self._log_capacity_rejection()
                return
            timestamps = deque()
            self._windows[key] = timestamps

        timestamps.append(now)

reset(key) async

Clear the in-memory counter for key.

Source code in litestar_auth/ratelimit/_memory.py
async def reset(self, key: str) -> None:
    """Clear the in-memory counter for ``key``."""
    async with self._lock:
        self._windows.pop(key, None)

retry_after(key) async

Return the remaining block duration for key in whole seconds.

Source code in litestar_auth/ratelimit/_memory.py
async def retry_after(self, key: str) -> int:
    """Return the remaining block duration for ``key`` in whole seconds."""
    async with self._lock:
        now = read_clock(self._clock)
        timestamps = self._prune(key, now)
        if timestamps is None or len(timestamps) < self.max_attempts:
            return 0

        oldest_timestamp = timestamps[0]
        remaining = self.window_seconds - (now - oldest_timestamp)
        return max(math.ceil(remaining), 1)

RedisRateLimiter(*, redis, max_attempts, window_seconds, key_prefix=DEFAULT_KEY_PREFIX, clock=time.time)

Redis-backed sliding-window rate limiter backed by a sorted set.

Store the Redis client and shared rate-limiter configuration.

Source code in litestar_auth/ratelimit/_redis.py
def __init__(
    self,
    *,
    redis: RedisClientProtocol,
    max_attempts: int,
    window_seconds: float,
    key_prefix: str = DEFAULT_KEY_PREFIX,
    clock: Clock = time.time,
) -> None:
    """Store the Redis client and shared rate-limiter configuration."""
    _load_package_redis_asyncio()
    _validate_configuration(max_attempts=max_attempts, window_seconds=window_seconds)

    self.redis = redis
    self.max_attempts = max_attempts
    self.window_seconds = window_seconds
    self.key_prefix = key_prefix
    self._clock: Clock = clock

is_shared_across_workers property

Redis-backed counters are shared across workers using the same Redis.

check(key) async

Return whether key can perform another attempt.

Source code in litestar_auth/ratelimit/_redis.py
async def check(self, key: str) -> bool:
    """Return whether ``key`` can perform another attempt."""
    count = self._decode_integer(
        await self._eval(
            self._CHECK_SCRIPT,
            key,
            read_clock(self._clock),
            self.window_seconds,
            self.max_attempts,
        ),
    )
    return count < self.max_attempts

increment(key) async

Record a new attempt for key atomically in Redis.

Source code in litestar_auth/ratelimit/_redis.py
async def increment(self, key: str) -> None:
    """Record a new attempt for ``key`` atomically in Redis."""
    now = read_clock(self._clock)
    await self._eval(
        self._INCREMENT_SCRIPT,
        key,
        now,
        self.window_seconds,
        f"{now:.9f}:{uuid4().hex}",
        self._ttl_seconds,
    )

reset(key) async

Delete the Redis sorted set for key.

Source code in litestar_auth/ratelimit/_redis.py
async def reset(self, key: str) -> None:
    """Delete the Redis sorted set for ``key``."""
    await self.redis.delete(self._key(key))

retry_after(key) async

Return the remaining block duration for key in whole seconds.

Source code in litestar_auth/ratelimit/_redis.py
async def retry_after(self, key: str) -> int:
    """Return the remaining block duration for ``key`` in whole seconds."""
    return max(
        self._decode_integer(
            await self._eval(
                self._RETRY_AFTER_SCRIPT,
                key,
                read_clock(self._clock),
                self.window_seconds,
                self.max_attempts,
            ),
        ),
        0,
    )

RateLimiterBackend

Bases: Protocol

Protocol shared by rate-limiter backends.

is_shared_across_workers property

Return whether backend state is shared across worker processes.

check(key) async

Return whether another attempt is allowed for key.

Source code in litestar_auth/ratelimit/_protocol.py
async def check(self, key: str) -> bool:
    """Return whether another attempt is allowed for ``key``."""

increment(key) async

Record an attempt for key.

Source code in litestar_auth/ratelimit/_protocol.py
async def increment(self, key: str) -> None:
    """Record an attempt for ``key``."""

reset(key) async

Clear tracked attempts for key.

Source code in litestar_auth/ratelimit/_protocol.py
async def reset(self, key: str) -> None:
    """Clear tracked attempts for ``key``."""

retry_after(key) async

Return the number of seconds until key can try again.

Source code in litestar_auth/ratelimit/_protocol.py
async def retry_after(self, key: str) -> int:
    """Return the number of seconds until ``key`` can try again."""

TotpRateLimitOrchestrator(enable=None, confirm_enable=None, verify=None, disable=None, regenerate_recovery_codes=None, _ACCOUNT_STATE_RESET_ENDPOINTS=frozenset({'verify'})) dataclass

Orchestrate TOTP endpoint rate-limit behavior with explicit semantics.

External behavior stays unchanged: - verify uses before-request checks, increments on invalid attempts, and resets on success/account-state failures. - enable and disable do not consume verify counters.

Endpoints that should reset on account-state failures are listed in _ACCOUNT_STATE_RESET_ENDPOINTS (currently only verify).

before_request(endpoint, request) async

Run endpoint-specific before-request checks.

Source code in litestar_auth/ratelimit/_orchestrator.py
async def before_request(self, endpoint: TotpSensitiveEndpoint, request: Request[Any, Any, Any]) -> None:
    """Run endpoint-specific before-request checks."""
    if limiter := self._limiters.get(endpoint):
        await limiter.before_request(request)

on_account_state_failure(endpoint, request) async

Apply endpoint-specific account-state failure behavior.

Source code in litestar_auth/ratelimit/_orchestrator.py
async def on_account_state_failure(self, endpoint: TotpSensitiveEndpoint, request: Request[Any, Any, Any]) -> None:
    """Apply endpoint-specific account-state failure behavior."""
    if endpoint in self._ACCOUNT_STATE_RESET_ENDPOINTS and (limiter := self._limiters.get(endpoint)):
        await limiter.reset(request)

on_invalid_attempt(endpoint, request) async

Record endpoint-specific invalid attempt failures.

Source code in litestar_auth/ratelimit/_orchestrator.py
async def on_invalid_attempt(self, endpoint: TotpSensitiveEndpoint, request: Request[Any, Any, Any]) -> None:
    """Record endpoint-specific invalid attempt failures."""
    if limiter := self._limiters.get(endpoint):
        await limiter.increment(request)

on_success(endpoint, request) async

Apply endpoint-specific success behavior.

Source code in litestar_auth/ratelimit/_orchestrator.py
async def on_success(self, endpoint: TotpSensitiveEndpoint, request: Request[Any, Any, Any]) -> None:
    """Apply endpoint-specific success behavior."""
    if limiter := self._limiters.get(endpoint):
        await limiter.reset(request)