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.

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
| Endpoint | Key | Limit idea | Why |
|---|---|---|---|
| Login | account and IP | 5 failures per account per 15 minutes; 20 per IP | credential stuffing, guessing |
| Password reset request | account and IP | 3 per hour | email flooding, enumeration |
| Signup | IP | 10 per hour | fake accounts |
| Authenticated reads | user | generous (hundreds per minute) | scraping |
| Search or export | user, by cost | a budget of cost units per minute | resource exhaustion |
| Public unauthenticated API | IP or key | per plan | fair 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:
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
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.


