Skip to content
rusthacks.dev
Security

Rate Limiting and Abuse Prevention in Rust APIs Explained

Abuse prevention goes beyond a request counter: separate limits per route and identity, login throttling, lockouts without enabling denial of service, enumeration defense and cost-based limits.

3 min read
Summarize in AI
Flat illustration for the post “Rate Limiting and Abuse Prevention in Rust APIs Explained”, composed around web.

A request counter stops the simplest floods. Real abuse is aimed: credential stuffing against login, enumeration of valid emails, scraping, expensive queries. Each needs a different policy. The mechanism for counting is in Axum rate limiting; this post is about what to limit and why. Everything compiles on Rust 1.90 with edition 2024 and axum 0.8.

Policy table

EndpointKeyLimit ideaWhy
Loginaccount and IP5 failures per account per 15 minutes; 20 per IPcredential stuffing, guessing
Password reset requestaccount and IP3 per houremail flooding, enumeration
SignupIP10 per hourfake accounts
Authenticated readsusergenerous (hundreds per minute)scraping
Search or exportuser, by costa budget of cost units per minuteresource exhaustion
Public unauthenticated APIIP or keyper planfair use

The numbers are starting points; tune from your traffic. The principle is that the most dangerous endpoint gets the strictest policy.

Two keys for logins

An attacker with one IP and many accounts hits the IP limit. An attacker with many IPs and one account hits the account limit. Count both:

rust
use std::{collections::HashMap, sync::Mutex, time::{Duration, Instant}};

pub struct FailureCounter {
    window: Duration,
    max: u32,
    seen: Mutex<HashMap<String, Vec<Instant>>>,
}

impl FailureCounter {
    pub fn new(window: Duration, max: u32) -> Self {
        Self { window, max, seen: Mutex::new(HashMap::new()) }
    }

    pub fn blocked(&self, key: &str) -> bool {
        let now = Instant::now();
        let mut map = self.seen.lock().unwrap();
        let hits = map.entry(key.to_string()).or_default();
        hits.retain(|t| now.duration_since(*t) < self.window);
        hits.len() as u32 >= self.max
    }

    pub fn record_failure(&self, key: &str) {
        self.seen.lock().unwrap().entry(key.to_string()).or_default().push(Instant::now());
    }
}

Use keys such as acct:{lowercased email} and ip:{addr}. Check both before verifying the password, and record a failure on either. A real deployment keeps the counters in Redis so all instances share them (Redis).

Do not make lockouts a weapon

If five wrong passwords lock the account for an hour, an attacker can keep a victim locked out forever. Better:

  • Add delay that grows with failures (1s, 2s, 4s) for that account from unfamiliar addresses.
  • Require a challenge (CAPTCHA, email confirmation) after several failures.
  • Notify the user of repeated failures.
  • Always allow the legitimate path: password reset by email works while the account is throttled.

Do not reveal which accounts exist

rust
use axum::{http::StatusCode, Json};
use serde_json::json;

pub async fn request_reset(Json(body): Json<serde_json::Value>) -> (StatusCode, Json<serde_json::Value>) {
    let _email = body["email"].as_str().unwrap_or_default();
    // look up the account; if it exists, enqueue the email; either way return the same thing
    (
        StatusCode::ACCEPTED,
        Json(json!({ "message": "If an account exists, an email has been sent." })),
    )
}

The same status, body and, as far as practical, timing for existing and missing accounts. Doing the email send in a background job keeps response time equal. For login, return one generic "invalid email or password". Verify a dummy hash when the account is missing so the Argon2 cost is paid either way (password hashing).

Cost based limits

An export that scans a million rows is not the same as a health check. Give each operation a cost and each user a budget per window; reject when exceeded, with 429 and Retry-After. Also bound the work per request itself: page size, query time, result size (request limits, pagination).

Observe

Count rejections by route and key type, alert on spikes, and log the key hash and route (not the password). A sudden jump of failures across many accounts is credential stuffing; a few accounts with many failures is targeted guessing. Pair with multi-factor authentication, which makes stolen passwords far less useful.

Where this fits in your plan is in a practical threat model.

Also inWeb
Taggedabuse
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.