Rate Limit Bypass in Axum (Rust)
Rate Limit Bypass in Axum with Rust — how this specific combination creates or exposes the vulnerability
Rate limiting in Rust web services built with Axum is typically implemented using middleware that tracks request counts per key (IP, API key, or user ID) over a time window. When misconfigured or applied inconsistently across routes, it can lead to Rate Limit Bypass, allowing an attacker to exceed intended request quotas.
One common pattern is to use a crate like governor or actix-ratelimit-inspired middleware with a storage backend such as Redis. If the rate-limiting middleware is not applied to all sensitive endpoints, or if route grouping excludes certain paths, an attacker can access an unprotected route to make unlimited requests. In Axum, this often happens when developers apply middleware selectively using Router::layer() at the router root but omit it on specific routes or nested routers.
A second bypass vector arises from key derivation flaws. For example, if the rate limiter uses only the client IP and an attacker shares that IP (via NAT or load balancers), they can exhaust the quota for many users. Worse, if the key includes a value supplied by the client (such as a user ID from a header) without server-side validation, an attacker can manipulate the key to target a specific user’s quota while appearing to come from multiple IPs.
In distributed deployments, clock skew across instances can also weaken rate limiting. If timestamps or token bucket states are not synchronized, a client may make requests that appear within the window on one instance but exceed the aggregated limit when viewed across the cluster. Axum apps behind a load balancer must ensure that shared state is externalized and monotonic time sources are used.
Consider an Axum endpoint that retrieves user profile data without applying rate limits:
use axum::{routing::get, Router};
async fn user_profile() -> &'static str {
"profile data"
}
// BUG: rate limiting applied only to /api/*, not to /profile
pub fn app() -> Router {
Router::new()
.route("/profile", get(user_profile)) // unprotected
.nest("/api", Router::new()
.route("/data", get(|| async { "sensitive" }))
.layer(rate_limit_layer()) // applied only to /api routes
)
}
An attacker can call /profile indefinitely to bypass the intended global limit. Another real-world pattern involves time-based windows; if the sliding window is implemented with a coarse granularity (e.g., 60-second buckets), a burst at bucket boundaries can exceed the intended requests-per-minute (RPM) cap. This is especially relevant for token-bucket implementations in Rust where bucket refill logic may not account for partial-window smoothing.
Finally, insecure deserialization of rate-limit state (e.g., Redis entries) can allow tampering. If an attacker can modify stored counters or TTLs, they can artificially reset limits. Axum apps must treat rate-limit metadata as untrusted and validate or sign external state where possible.
Rust-Specific Remediation in Axum — concrete code fixes
Remediation in Axum requires consistent middleware application, robust key design, and externalized state. Use a well-maintained rate-limiting crate such as governor with a shared backend like Redis, and apply it uniformly to all routes that require protection.
First, apply rate limiting at the router root so it covers every path, including dynamically added routes:
use axum::{routing::get, Router};
use governor::{Quota, RateLimiter};
use std::num::NonZeroU32;
use governor::state::NotKeyed;
// A simple in-memory rate limiter: 10 requests per second
let quota = Quota::per_second(NonZeroU32::new(10).unwrap());
let limiter = RateLimiter::new(quota);
async fn handler() -> &'static str {
"ok"
}
pub fn app() -> Router {
Router::new()
.route("/profile", get(handler))
.route("/api/data", get(handler))
.layer(axum_extra::extensions::ExtensionLayer::new(limiter)) // applied globally
}
Second, use a server-side key that cannot be manipulated by the client. Derive the rate-limit key from authenticated context or server-side identifiers rather than raw headers:
use axum::{extract::Extension, http::Request, middleware::Next};
use std::sync::Arc;
struct RateLimitKey(String);
async fn key_middleware(
Extension(limiter): Extension<Arc<RateLimit>>,
request: Request<body::BoxBody>,
next: Next<body::BoxBody>
) -> Result<impl IntoResponse, (StatusCode, String)> {
// Derive key from authenticated user ID, not client-supplied header
let user_id = get_authenticated_user_id(&request).unwrap_or("anonymous");
let key = format!("rate_limit:{}:{}", request.uri().path(), user_id);
if limiter.check(&key).is_err() {
return Err((StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded".into()));
}
Ok(next.run(request).await)
}
Third, externalize state with Redis and ensure atomic operations to avoid race conditions across Axum worker threads:
use governor::Quota;
use redis::AsyncCommands;
use std::time::Duration;
async fn check_redis_rate_limit(key: &str, quota: &Quota) -> bool {
let client = redis::Client::open("redis://127.0.0.1/").unwrap();
let mut conn = client.get_async_connection().await.unwrap();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
// Use Redis atomic commands to track counts within a window
let count: i64 = conn.get(key).await.unwrap_or(0);
if count >= quota.0.0 as i64 {
false
} else {
let _: () = conn.incr(key, 1).await.unwrap();
if count == 0 {
let ttl = quota.duration.as_secs();
let _: () = conn.expire(key, ttl).await.unwrap();
}
true
}
}
Finally, for distributed deployments, synchronize time sources and prefer a centralized data store. Combine these practices to close bypass vectors while preserving the performance and correctness guarantees expected from Rust-based Axum services.