Debug School

rakesh kumar
rakesh kumar

Posted on

Rate Limiting Middleware in Actix-Web (Rust)

What rate limiting does(What/When/Why/Flow of a rate-limited request)
Request flow (middleware → handler → DB → middleware)
Step 1: Decide which headers to set
Step 2: Create rate limiter middleware
Step 3: Wire middleware in main.rs
Step 4: Handler example (login) showing DB flow
Step 5: Make rate limiting stricter only for login (best practice)
Production notes (very important)

Rate limiting means “don’t allow one client to hit the same API too many times in a short time.” It protects your server from brute-force login attempts, abusive bots, and accidental traffic spikes. In session-based apps, rate limiting is especially useful for endpoints like /login, /register, /otp, /search, and any expensive DB query route.

In this blog, we’ll build a custom rate limiting middleware (no external service needed), wire it in main.rs, and show how it blocks requests before they reach handler + DB.

What rate limiting does

What

It limits requests per key (IP, user_id, or API token) per time window.

Why

Stops brute-force attacks on /login

Reduces DB load

Avoids abuse (spam, scraping)

Improves uptime
Enter fullscreen mode Exit fullscreen mode

When

Always on login and OTP routes

On public listing endpoints that can be scraped

On file upload endpoints (to avoid storage abuse)
Enter fullscreen mode Exit fullscreen mode

Flow of a rate-limited request
Request flow

Client hits /api/login

Middleware runs first

Middleware checks “how many requests from this key in last X seconds”

If allowed → request goes to handler → handler calls DB

If blocked → middleware returns 429 Too Many Requests and handler/DB never runs
Enter fullscreen mode Exit fullscreen mode

3) Step 1: Add required dependencies

In Cargo.toml:

# For time + thread-safe shared state
chrono = "0.4"
tokio = { version = "1", features = ["sync"] }

Enter fullscreen mode Exit fullscreen mode

(You already use tokio and chrono; if yes, no changes.)

4) Step 2: Create rate limiter middleware

Create file:

src/middleware/rate_limit.rs
Enter fullscreen mode Exit fullscreen mode

This version implements a fixed window counter:

window: 60 seconds
Enter fullscreen mode Exit fullscreen mode

max requests: 10 per window per IP per route (customizable)

use actix_service::{Service, Transform};
use actix_web::{
    dev::{ServiceRequest, ServiceResponse},
    error,
    http::StatusCode,
    Error, HttpResponse,
};
use chrono::Utc;
use futures_util::future::{ready, LocalBoxFuture, Ready};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;

/// Simple in-memory rate limiter state.
/// Key example: "IP|METHOD|PATH"
#[derive(Clone)]
pub struct RateLimiter {
    pub state: Arc<Mutex<HashMap<String, Counter>>>,
    pub window_secs: i64,
    pub max_requests: u32,
}

#[derive(Debug, Clone)]
pub struct Counter {
    pub window_start: i64,
    pub count: u32,
}

impl RateLimiter {
    pub fn new(window_secs: i64, max_requests: u32) -> Self {
        Self {
            state: Arc::new(Mutex::new(HashMap::new())),
            window_secs,
            max_requests,
        }
    }
}

/// Middleware wrapper object
pub struct RateLimitMiddleware {
    limiter: RateLimiter,
}

impl RateLimitMiddleware {
    pub fn new(limiter: RateLimiter) -> Self {
        Self { limiter }
    }
}

impl<S, B> Transform<S, ServiceRequest> for RateLimitMiddleware
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type Transform = RateLimitMiddlewareService<S>;
    type InitError = ();
    type Future = Ready<Result<Self::Transform, Self::InitError>>;

    fn new_transform(&self, service: S) -> Self::Future {
        ready(Ok(RateLimitMiddlewareService {
            service,
            limiter: self.limiter.clone(),
        }))
    }
}

pub struct RateLimitMiddlewareService<S> {
    service: S,
    limiter: RateLimiter,
}

impl<S, B> Service<ServiceRequest> for RateLimitMiddlewareService<S>
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;

    fn call(&self, req: ServiceRequest) -> Self::Future {
        let limiter = self.limiter.clone();

        // Identify client (basic: IP). If behind proxy, you’ll read X-Forwarded-For safely.
        let ip = req
            .connection_info()
            .realip_remote_addr()
            .unwrap_or("unknown")
            .to_string();

        // Per endpoint key
        let key = format!("{}|{}|{}", ip, req.method(), req.path());

        let fut = self.service.call(req);

        Box::pin(async move {
            let now = Utc::now().timestamp();

            // Check + update counter
            let mut map = limiter.state.lock().await;
            let counter = map.entry(key).or_insert(Counter {
                window_start: now,
                count: 0,
            });

            // Reset window if expired
            if now - counter.window_start >= limiter.window_secs {
                counter.window_start = now;
                counter.count = 0;
            }

            // Increment and decide
            counter.count += 1;

            if counter.count > limiter.max_requests {
                // Block request with 429
                let res = HttpResponse::build(StatusCode::TOO_MANY_REQUESTS).json(serde_json::json!({
                    "success": false,
                    "message": "Too many requests. Please try again later.",
                    "code": 429
                }));
                return Err(error::InternalError::from_response("", res).into());
            }

            // Allowed → continue
            fut.await
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Notes (simple, practical)

This is in-memory rate limiting. It works well for single server.

For multi-server (multiple instances), you’d store counters in Redis (same logic, shared store).

5) Step 3: Wire middleware in main.rs

Create module file:

src/middleware/mod.rs
Enter fullscreen mode Exit fullscreen mode

pub mod rate_limit;

Now in main.rs:

mod middleware;
use middleware::rate_limit::{RateLimiter, RateLimitMiddleware};
Enter fullscreen mode Exit fullscreen mode

Add it to your app. Example: allow 10 requests per 60 seconds per IP per endpoint.

HttpServer::new(move || {
    let limiter = RateLimiter::new(60, 10);

    App::new()
        .app_data(state.clone())
        .wrap(cors)
        .wrap(
            SessionMiddleware::builder(CookieSessionStore::default(), key.clone())
                .cookie_secure(false)
                .cookie_same_site(SameSite::Lax)
                .build(),
        )
        // Rate limit runs before handlers
        .wrap(RateLimitMiddleware::new(limiter))
        .service(
            web::scope("/api")
                .route("/login", web::post().to(handlers::login))
                .route("/shops", web::get().to(handlers::list_shops_handler))
        )
        .service(Files::new("/uploads", "./uploads"))
})
Enter fullscreen mode Exit fullscreen mode

Important middleware order

CORS and Session can be before rate limit.

Rate limit should be early enough that it blocks before heavy work.

6) Step 4: Handler example (login) showing DB flow
Handler

src/handlers/auth.rs (simplified)

use actix_session::Session;
use actix_web::{error, web, HttpResponse, Result};
use crate::db::{find_user_by_username, AppState};
use crate::models::LoginForm;
use super::common::ApiResponse;

pub async fn login(
    body: web::Json<LoginForm>,
    state: web::Data<AppState>,
    session: Session,
) -> Result<HttpResponse> {
    // If rate limit blocks, this handler will never run.

    let user = find_user_by_username(&state.pool, &body.username)
        .await
        .map_err(error::ErrorInternalServerError)?;

    if let Some(user) = user {
        let valid = user.password == body.password;
        if valid {
            session.insert("user_id", user.id).map_err(error::ErrorInternalServerError)?;
            session.insert("username", user.username.clone()).map_err(error::ErrorInternalServerError)?;
            session.insert("role", user.role.clone()).map_err(error::ErrorInternalServerError)?;

            return Ok(HttpResponse::Ok().json(ApiResponse {
                message: "Login successful".to_string(),
                data: true,
            }));
        }
    }

    Err(error::ErrorUnauthorized("Invalid credentials"))
}
Enter fullscreen mode Exit fullscreen mode

DB function

src/db/user.rs

use sqlx::MySqlPool;

#[derive(sqlx::FromRow, Debug)]
pub struct DbUser {
    pub id: i64,
    pub username: String,
    pub password: String,
    pub role: String,
}

pub async fn find_user_by_username(
    pool: &MySqlPool,
    username: &str
) -> Result<Option<DbUser>, sqlx::Error> {
    let user = sqlx::query_as::<_, DbUser>(
        "SELECT id, username, password, role FROM users WHERE username = ? LIMIT 1"
    )
    .bind(username)
    .fetch_optional(pool)
    .await?;

    Ok(user)
}
Enter fullscreen mode Exit fullscreen mode

7) Step 5: Make rate limiting stricter only for login (best practice)

Instead of limiting every route equally, you usually want:

/login: 5 requests / minute

/uploads: 10 requests / minute

/home/vehicles: higher limit (like 120/min)
Enter fullscreen mode Exit fullscreen mode

Simple way: apply middleware to a specific scope

.service(
    web::scope("/api")
        // Public routes
        .route("/home/vehicles", web::get().to(handlers::list_home_vehicle_cards_handler))
        // Login scope rate limited stricter
        .service(
            web::scope("")
                .wrap(RateLimitMiddleware::new(RateLimiter::new(60, 5)))
                .route("/login", web::post().to(handlers::login))
        )
)
Enter fullscreen mode Exit fullscreen mode

This keeps your site usable while protecting sensitive endpoints.

8) Production notes (very important)
In-memory limiter limitations

Works per server instance only.

If you run 3 servers, each has its own counters (rate limiting becomes weaker).

Production upgrade

Replace HashMap with Redis counter (shared store).

Same “key/window/count” logic, but stored in Redis.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)