Repository: pubky/pubky-core
Pull request #124: feat: Flexible endpoint rate limiting
Author: login: SeverinAlexB
Created: 2025-05-09T14:12:10Z | Merged: 2025-05-13T08:06:23Z

Adds the ability to rate limit any endpoint in the homeserver core api depending on the config.toml configuration.

- Rate limits requests or download/upload speed.
- Rate limits by IP or by user pubkey.

Example config:
```toml
# Rate limit endpoints dynamically.
# `path` is a glob pattern of the path. See syntax in https://crates.io/crates/fast-glob
# `method` is the HTTP method. Examples: GET, POST, PUT, HEAD, DELETE
# `quota` defines the limit itself in the format $rate$rate_unit/$time_unit.
#  - $rate is a positive integer, max 4'294'967'296.
#  - $rate_unit is either "r" for requests, "kb" for kilobytes, 
#    "mb" for megabytes, "gb" for gigabytes.
#  Speed limits limit download and upload.
#  - $time_unit is either "s" for second, "m" for minute.
# `key` defines what who is rate limited. 
#  - "ip" limits based on the IP address.
#  - "user" limits based on the user pubkey. Requires the endpoint to have authentication.
# `burst` is a temporary allowance of quota that is added to the limit. 
#   By default, burst is equal the quota rate.
#
# Limit login attempts to 20 requests per minute per IP. 
[[drive.rate_limits]]
path = "/session"
method = "POST"
quota = "20r/m"
key = "ip"
#
# Limit file uploads to 1 megabyte per second per user with a temporary burst of 10 megabytes.
[[drive.rate_limits]]
path = "/pub/**" 
method = "PUT"
quota = "1mb/s"
key = "user"
burst = 10
```


Diff:
diff --git a/Cargo.lock b/Cargo.lock
index 40e61659b..2bd83b9f7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -36,6 +36,12 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
 [[package]]
 name = "anstream"
 version = "0.6.18"
@@ -923,6 +929,15 @@ dependencies = [
  "windows-sys 0.59.0",
 ]
 
+[[package]]
+name = "fast-glob"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39ea3f6bbcf4dbe2076b372186fc7aeecd5f6f84754582e56ee7db262b15a6f0"
+dependencies = [
+ "arrayvec",
+]
+
 [[package]]
 name = "fastrand"
 version = "2.3.0"
@@ -953,6 +968,12 @@ version = "1.0.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
 
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
 [[package]]
 name = "foreign-types"
 version = "0.3.2"
@@ -1185,6 +1206,29 @@ dependencies = [
  "spinning_top",
 ]
 
+[[package]]
+name = "governor"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cbe789d04bf14543f03c4b60cd494148aa79438c8440ae7d81a7778147745c3"
+dependencies = [
+ "cfg-if",
+ "dashmap",
+ "futures-sink",
+ "futures-timer",
+ "futures-util",
+ "getrandom 0.3.1",
+ "hashbrown 0.15.2",
+ "nonzero_ext",
+ "parking_lot",
+ "portable-atomic",
+ "quanta",
+ "rand 0.9.0",
+ "smallvec",
+ "spinning_top",
+ "web-time",
+]
+
 [[package]]
 name = "h2"
 version = "0.4.8"
@@ -1224,6 +1268,11 @@ name = "hashbrown"
 version = "0.15.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
 
 [[package]]
 name = "headers"
@@ -1321,9 +1370,9 @@ checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2"
 
 [[package]]
 name = "http"
-version = "1.2.0"
+version = "1.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea"
+checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565"
 dependencies = [
  "bytes",
  "fnv",
@@ -2157,7 +2206,7 @@ dependencies = [
  "bytes",
  "clap",
  "dirs-next",
- "governor",
+ "governor 0.8.0",
  "http",
  "httpdate",
  "pkarr",
@@ -2343,9 +2392,11 @@ dependencies = [
  "clap",
  "dirs",
  "dyn-clone",
+ "fast-glob",
  "flume",
  "futures-lite",
  "futures-util",
+ "governor 0.10.0",
  "heed",
  "hex",
  "hostname-validator",
@@ -3500,7 +3551,7 @@ checksum = "57a2ccff6830fa835371af7541e561a90e4c07b84f72991ebac4b3cb6790dc0d"
 dependencies = [
  "axum",
  "forwarded-header-value",
- "governor",
+ "governor 0.8.0",
  "http",
  "pin-project",
  "thiserror 2.0.12",
diff --git a/pubky-client/src/wasm/mod.rs b/pubky-client/src/wasm/mod.rs
index 87d262990..8f6a089bb 100644
--- a/pubky-client/src/wasm/mod.rs
+++ b/pubky-client/src/wasm/mod.rs
@@ -49,7 +49,7 @@ impl Client {
                 .expect("testnet relays are valid urls")
         });
 
-        let mut client = builder.build().expect("testnet build should be infallibl");
+        let mut client = builder.build().expect("testnet build should be infallible");
 
         client.testnet = true;
 
diff --git a/pubky-homeserver/Cargo.toml b/pubky-homeserver/Cargo.toml
index ed93c7df8..514e7e43a 100644
--- a/pubky-homeserver/Cargo.toml
+++ b/pubky-homeserver/Cargo.toml
@@ -53,7 +53,10 @@ axum-test = "17.2.0"
 tempfile = { version = "3.10.1" }
 dyn-clone = "1.0.19"
 reqwest = "0.12.15"
+governor = "0.10.0"
+fast-glob = "0.4.5"
 
 
 [dev-dependencies]
 futures-lite = "2.6.0"
+
diff --git a/pubky-homeserver/config.sample.toml b/pubky-homeserver/config.sample.toml
index 2ae4fe252..9e4d4a5ab 100644
--- a/pubky-homeserver/config.sample.toml
+++ b/pubky-homeserver/config.sample.toml
@@ -24,6 +24,35 @@ pubky_listen_socket = "127.0.0.1:6287"
 # May be put behind a reverse proxy with TLS enabled.
 icann_listen_socket = "127.0.0.1:6286"
 
+# Rate limit endpoints dynamically.
+# `path` is a glob pattern of the path. See syntax in https://crates.io/crates/fast-glob
+# `method` is the HTTP method. Examples: GET, POST, PUT, HEAD, DELETE
+# `quota` defines the limit itself in the format $rate$rate_unit/$time_unit.
+#  - $rate is a positive integer, max 4'294'967'296.
+#  - $rate_unit is either "r" for requests, "kb" for kilobytes, 
+#    "mb" for megabytes, "gb" for gigabytes.
+#  Speed limits limit download and upload.
+#  - $time_unit is either "s" for second, "m" for minute.
+# `key` defines what who is rate limited. 
+#  - "ip" limits based on the IP address.
+#  - "user" limits based on the user pubkey. Requires the endpoint to have authentication.
+# `burst` is a temporary allowance of quota that is added to the limit. 
+#   By default, burst is equal the quota rate.
+#
+# Limit login attempts to 20 requests per minute per IP. 
+[[drive.rate_limits]]
+path = "/session"
+method = "POST"
+quota = "20r/m"
+key = "ip"
+#
+# Limit file uploads to 1 megabyte per second per user with a temporary burst of 10 megabytes.
+[[drive.rate_limits]]
+path = "/pub/**" 
+method = "PUT"
+quota = "1mb/s"
+key = "user"
+burst = 10
 
 [admin]
 # The port number to run the admin HTTP (clear text) server on.
diff --git a/pubky-homeserver/src/core/homeserver_core.rs b/pubky-homeserver/src/core/homeserver_core.rs
index 80bb642d2..02f8b3c9d 100644
--- a/pubky-homeserver/src/core/homeserver_core.rs
+++ b/pubky-homeserver/src/core/homeserver_core.rs
@@ -151,7 +151,7 @@ impl HomeserverCore {
             signup_mode: context.config_toml.general.signup_mode.clone(),
             user_quota_bytes: quota_bytes,
         };
-        super::routes::create_app(state.clone())
+        super::routes::create_app(state.clone(), context)
     }
 
     /// Start the ICANN HTTP server
diff --git a/pubky-homeserver/src/core/layers/mod.rs b/pubky-homeserver/src/core/layers/mod.rs
index cceacd26e..80b820a25 100644
--- a/pubky-homeserver/src/core/layers/mod.rs
+++ b/pubky-homeserver/src/core/layers/mod.rs
@@ -1,3 +1,4 @@
 pub mod authz;
 pub mod pubky_host;
+pub mod rate_limiter;
 pub mod trace;
diff --git a/pubky-homeserver/src/core/layers/rate_limiter/extract_ip.rs b/pubky-homeserver/src/core/layers/rate_limiter/extract_ip.rs
new file mode 100644
index 000000000..1fa6844a2
--- /dev/null
+++ b/pubky-homeserver/src/core/layers/rate_limiter/extract_ip.rs
@@ -0,0 +1,37 @@
+use axum::extract::Request;
+use axum::http::HeaderMap;
+use std::net::{IpAddr, SocketAddr};
+
+// From https://github.com/benwis/tower-governor/blob/main/src/key_extractor.rs#L121
+const X_REAL_IP: &str = "x-real-ip";
+const X_FORWARDED_FOR: &str = "x-forwarded-for";
+
+/// Tries to parse the `x-forwarded-for` header
+fn maybe_x_forwarded_for(headers: &HeaderMap) -> Option<IpAddr> {
+    headers
+        .get(X_FORWARDED_FOR)
+        .and_then(|hv| hv.to_str().ok())
+        .and_then(|s| s.split(',').find_map(|s| s.trim().parse::<IpAddr>().ok()))
+}
+
+/// Tries to parse the `x-real-ip` header
+fn maybe_x_real_ip(headers: &HeaderMap) -> Option<IpAddr> {
+    headers
+        .get(X_REAL_IP)
+        .and_then(|hv| hv.to_str().ok())
+        .and_then(|s| s.parse::<IpAddr>().ok())
+}
+
+fn maybe_connect_info<T>(req: &Request<T>) -> Option<IpAddr> {
+    req.extensions()
+        .get::<axum::extract::ConnectInfo<SocketAddr>>()
+        .map(|addr| addr.ip())
+}
+
+pub fn extract_ip<T>(req: &Request<T>) -> anyhow::Result<IpAddr> {
+    let headers = req.headers();
+    maybe_x_forwarded_for(headers)
+        .or_else(|| maybe_x_real_ip(headers))
+        .or_else(|| maybe_connect_info(req))
+        .ok_or(anyhow::anyhow!("Failed to extract ip."))
+}
diff --git a/pubky-homeserver/src/core/layers/rate_limiter/layer.rs b/pubky-homeserver/src/core/layers/rate_limiter/layer.rs
new file mode 100644
index 000000000..bcbfe71bb
--- /dev/null
+++ b/pubky-homeserver/src/core/layers/rate_limiter/layer.rs
@@ -0,0 +1,539 @@
+//!
+//! Implements rate limiting with governor.
+//!
+//! Would love to use tower_governor but I can't type it properly due to
+//! https://github.com/benwis/tower-governor/issues/49.
+//!
+//! So we implement our own rate limiter here.
+//!
+use axum::response::{IntoResponse, Response};
+use axum::{
+    body::Body,
+    http::{Request, StatusCode},
+};
+use futures_util::future::BoxFuture;
+use governor::clock::QuantaClock;
+use governor::state::keyed::DashMapStateStore;
+use std::num::NonZero;
+use std::sync::Arc;
+use std::time::Duration;
+use std::{convert::Infallible, task::Poll};
+use tower::{Layer, Service};
+
+use crate::core::error::Result;
+use crate::core::extractors::PubkyHost;
+use crate::core::Error;
+use crate::quota_config::{LimitKey, PathLimit, RateUnit};
+use futures_util::StreamExt;
+use governor::{Jitter, Quota, RateLimiter};
+
+use super::extract_ip::extract_ip;
+
+/// A Tower Layer to handle general rate limiting.
+///
+/// Supports rate limiting by request count and by upload/download speed.
+///
+/// Requires a `PubkyHostLayer` to be applied first.
+/// Used to extract the user pubkey as the key for the rate limiter.
+///
+/// Returns 400 BAD REQUEST if the user pubkey aka pubky-host cannot be extracted.
+///
+#[derive(Debug, Clone)]
+pub struct RateLimiterLayer {
+    limits: Vec<PathLimit>,
+}
+
+impl RateLimiterLayer {
+    /// Create a new rate limiter layer with the given quota.
+    ///
+    /// If quota is None, rate limiting is disabled.
+    pub fn new(limits: Vec<PathLimit>) -> Self {
+        if limits.is_empty() {
+            tracing::info!("Rate limiting is disabled.");
+        } else {
+            let limits_str = limits
+                .iter()
+                .map(|limit| format!("\"{limit}\""))
+                .collect::<Vec<String>>();
+            tracing::info!("Rate limits configured: {}", limits_str.join(", "));
+        }
+        Self { limits }
+    }
+}
+
+impl<S> Layer<S> for RateLimiterLayer {
+    type Service = RateLimiterMiddleware<S>;
+
+    fn layer(&self, inner: S) -> Self::Service {
+        let tuples = self
+            .limits
+            .iter()
+            .map(|path| LimitTuple::new(path.clone()))
+            .collect();
+
+        RateLimiterMiddleware {
+            inner,
+            limits: tuples,
+        }
+    }
+}
+
+/// A tuple of a path limit and the actual governor rate limiter.
+#[derive(Debug, Clone)]
+struct LimitTuple(
+    pub PathLimit,
+    pub Arc<RateLimiter<String, DashMapStateStore<String>, QuantaClock>>,
+);
+
+impl LimitTuple {
+    pub fn new(path_limit: PathLimit) -> Self {
+        let quota: Quota = path_limit.clone().into();
+        let limiter = Arc::new(RateLimiter::keyed(quota));
+        Self(path_limit, limiter)
+    }
+
+    /// Extract the key from the request.
+    ///
+    /// The key is either the ip address of the client
+    /// or the user pubkey.
+    fn extract_key(&self, req: &Request<Body>) -> anyhow::Result<String> {
+        match self.0.key {
+            LimitKey::Ip => extract_ip(req).map(|ip| ip.to_string()),
+            LimitKey::User => {
+                // Extract the user pubkey from the request.
+                req.extensions()
+                    .get::<PubkyHost>()
+                    .map(|pk| pk.public_key().to_string())
+                    .ok_or(anyhow::anyhow!("Failed to extract user pubkey."))
+            }
+        }
+    }
+
+    /// Check if the request matches the limit.
+    pub fn is_match(&self, req: &Request<Body>) -> bool {
+        self.0.path.is_match(req.uri().path()) && self.0.method.0 == req.method()
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct RateLimiterMiddleware<S> {
+    inner: S,
+    limits: Vec<LimitTuple>,
+}
+
+impl<S> RateLimiterMiddleware<S> {
+    /// Throttle the upload body.
+    fn throttle_upload(
+        req: Request<Body>,
+        key: &str,
+        limiter: &Arc<RateLimiter<String, DashMapStateStore<String>, QuantaClock>>,
+    ) -> Request<Body> {
+        let (parts, body) = req.into_parts();
+        let new_body = Self::throttle_body(body, key, limiter);
+        Request::from_parts(parts, new_body)
+    }
+
+    /// Throttle the download body.
+    fn throttle_download(
+        res: Response<Body>,
+        key: &str,
+        limiter: &Arc<RateLimiter<String, DashMapStateStore<String>, QuantaClock>>,
+    ) -> Response<Body> {
+        let (parts, body) = res.into_parts();
+        let new_body = Self::throttle_body(body, key, limiter);
+        Response::from_parts(parts, new_body)
+    }
+
+    /// Throttle the up or download body.
+    ///
+    /// Important: The speed quotas are always in kilobytes, not bytes.
+    /// Counting bytes is not practical.
+    ///
+    fn throttle_body(
+        body: Body,
+        key: &str,
+        limiter: &Arc<RateLimiter<String, DashMapStateStore<String>, QuantaClock>>,
+    ) -> Body {
+        let body_stream = body.into_data_stream();
+        let limiter = limiter.clone();
+        let key = key.to_string();
+        let throttled = body_stream
+            .map(move |chunk| {
+                let limiter = limiter.clone();
+                let key = key.to_string();
+                // When the rate limit is exceeded, we wait between 25ms and 500ms before retrying.
+                // This is to avoid overwhelming the server with requests when the rate limit is exceeded.
+                // Randomization is used to avoid thundering herd problem.
+                let jitter = Jitter::new(
+                    Duration::from_millis(25),
+                    Duration::from_millis(500),
+                );
+                async move {
+                    let bytes = match chunk {
+                        Ok(actual_chunk) => {
+                            actual_chunk
+                        }
+                        Err(e) => return Err(e),
+                    };
+
+                    // --- Round up to the nearest kilobyte. ---
+                    // Important: If the chunk is < 1KB, it will be rounded up to 1 kb.
+                    // Many small uploads will be counted as more than they actually are.
+                    // I am not too concerned about this though because small random disk writes are stressing 
+                    // the disk more anyway compared to larger writes.
+                    // Why are we doing this? governor::Quota is defined as a u32. u32 can only count up to 4GB.
+                    // To support 4GB/s+ limits we need to count in kilobytes.
+                    //
+                    // --- Chunk Size ---
+                    // The chunk size is determined by the client library.
+                    // Common chunk sizes: 16KB to 10MB. 
+                    // HTTP based uploads are usually between 256KB and 1MB.
+                    // Asking the limiter for 1KB packets is tradeoff between
+                    // - Not calling the limiter too much
+                    // - Guaranteeing the call size (1kb) is low enough to not cause race condition issues.
+                    let chunk_kilobytes = bytes.len().div_ceil(1024);
+                    for _ in 0..chunk_kilobytes {
+                        // Check each kilobyte
+                        if limiter
+                            .until_key_n_ready_with_jitter(
+                                &key,
+                                NonZero::new(1).expect("1 is always non zero"),
+                                jitter,
+                            )
+                            .await.is_err()
+                        {
+                            // Requested rate (1kb) is higher then the set limit (x kb/s).
+                            // This should never happen.
+                            unreachable!("Rate limiting is based on the number of kilobytes, not bytes. So 1 kb should always be allowed.");
+                        };
+                    }
+                    Ok(bytes)
+                }
+            })
+            .buffered(1);
+
+        Body::from_stream(throttled)
+    }
+
+    /// Get the limits that match the request.
+    fn get_limit_matches(&self, req: &Request<Body>) -> Vec<&LimitTuple> {
+        self.limits
+            .iter()
+            .filter(|limit| limit.is_match(req))
+            .collect()
+    }
+}
+
+impl<S> Service<Request<Body>> for RateLimiterMiddleware<S>
+where
+    S: Service<Request<Body>, Response = axum::response::Response, Error = Infallible>
+        + Send
+        + 'static
+        + Clone,
+    S::Future: Send + 'static,
+{
+    type Response = S::Response;
+    type Error = S::Error;
+    type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
+
+    fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> Poll<Result<(), Self::Error>> {
+        self.inner.poll_ready(cx).map_err(|_| unreachable!()) // `Infallible` conversion
+    }
+
+    fn call(&mut self, mut req: Request<Body>) -> Self::Future {
+        let mut inner = self.inner.clone();
+
+        // Match the request path + method to the defined limits.
+        let limits = self.get_limit_matches(&req);
+        if limits.is_empty() {
+            // No limits matched, so we can just call the next layer.
+            return Box::pin(async move { inner.call(req).await.map_err(|_| unreachable!()) });
+        }
+
+        // Go through all the limits and check if we need to throttle or reject the request.
+        for limit in limits.clone() {
+            let key = match limit.extract_key(&req) {
+                Ok(key) => key,
+                Err(e) => {
+                    // Failed to extract the key, so we reject the request.
+                    // This should only happen if the pubky-host couldn't be extracted.
+                    tracing::warn!(
+                        "{} {} Failed to extract key for rate limiting: {}",
+                        limit.0.path.0,
+                        limit.0.method.0,
+                        e
+                    );
+                    return Box::pin(async move {
+                        Ok(Error::new(
+                            StatusCode::BAD_REQUEST,
+                            Some("Failed to extract key for rate limiting".to_string()),
+                        )
+                        .into_response())
+                    });
+                }
+            };
+
+            match limit.0.quota.rate_unit {
+                RateUnit::SpeedRateUnit(_) => {
+                    // Speed limiting is enabled, so we need to throttle the upload.
+                    req = Self::throttle_upload(req, &key, &limit.1);
+                }
+                RateUnit::Request => {
+                    // Request limiting is enabled, so we need to limit the number of requests.
+                    if let Err(e) = limit.1.check_key(&key) {
+                        tracing::debug!(
+                            "Rate limit of {} exceeded for {key}: {}",
+                            limit.0.quota,
+                            e
+                        );
+                        return Box::pin(async move {
+                            Ok(Error::new(
+                                StatusCode::TOO_MANY_REQUESTS,
+                                Some("Rate limit exceeded".to_string()),
+                            )
+                            .into_response())
+                        });
+                    };
+                }
+            };
+        }
+
+        // Create a clone of the request without the body.
+        // This way, we can extract the keys for the response too.
+        let (parts, body) = req.into_parts();
+        let req_clone = Request::from_parts(parts.clone(), Body::empty());
+        let req = Request::from_parts(parts, body);
+
+        let speed_limits = limits
+            .into_iter()
+            .filter(|limit| limit.0.quota.rate_unit.is_speed_rate_unit())
+            .cloned()
+            .collect::<Vec<_>>();
+        Box::pin(async move {
+            // Call the next layer and receive the response.
+            let mut response = match inner.call(req).await.map_err(|_| unreachable!()) {
+                Ok(response) => response,
+                Err(e) => return Err(e),
+            };
+            // Rate limit the download speed.
+            for limit in speed_limits {
+                if let Ok(key) = limit.extract_key(&req_clone) {
+                    response = Self::throttle_download(response, &key, &limit.1);
+                };
+            }
+            Ok(response)
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
+
+    use axum::http::Method;
+    use axum::{
+        routing::{get, post},
+        Router,
+    };
+    use axum_server::Server;
+    use pkarr::{Keypair, PublicKey};
+    use reqwest::{Client, Response};
+    use tokio::{task::JoinHandle, time::Instant};
+
+    use crate::{core::layers::pubky_host::PubkyHostLayer, quota_config::GlobPattern};
+
+    use super::*;
+
+    // Fake upload handler that just consumes the body.
+    pub async fn upload_handler(body: Body) -> Result<impl IntoResponse> {
+        let mut stream = body.into_data_stream();
+        while let Some(chunk) = stream.next().await.transpose()? {
+            // Consume body
+            let _ = chunk;
+        }
+        Ok((StatusCode::CREATED, ()))
+    }
+
+    // Fake upload handler that just consumes the body.
+    pub async fn download_handler() -> Result<impl IntoResponse> {
+        let response_body = vec![0u8; 3 * 1024]; // 3kb
+        Ok((StatusCode::OK, response_body))
+    }
+
+    // Start a server with the given quota config on a random port.
+    async fn start_server(config: Vec<PathLimit>) -> SocketAddr {
+        let app = Router::new()
+            .route("/upload", post(upload_handler))
+            .route("/download", get(download_handler))
+            .layer(RateLimiterLayer::new(config))
+            .layer(PubkyHostLayer);
+
+        // Create a TCP listener to bind to the socket first
+        // Use port 0 to let the OS assign a random available port
+        let listener = tokio::net::TcpListener::bind(SocketAddr::new(
+            IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
+            0,
+        ))
+        .await
+        .unwrap();
+        // Get the actual socket address with the OS-assigned port
+        let socket = listener.local_addr().unwrap();
+
+        // Use the listener with axum_server
+        let server = Server::from_tcp(listener.into_std().unwrap());
+
+        tokio::spawn(async move {
+            server
+                .serve(app.into_make_service_with_connect_info::<SocketAddr>())
+                .await
+                .unwrap();
+        });
+
+        socket
+    }
+
+    #[tokio::test]
+    async fn test_throttle_upload() {
+        let path_limit = PathLimit::new(
+            GlobPattern::new("/upload"),
+            Method::POST,
+            "1kb/s".parse().unwrap(),
+            LimitKey::Ip,
+            None,
+        );
+        let socket = start_server(vec![path_limit]).await;
+
+        fn upload_data(socket: SocketAddr, kilobytes: usize) -> JoinHandle<()> {
+            tokio::spawn(async move {
+                let client = Client::new();
+                let data = vec![0u8; kilobytes * 1024];
+                let response = client
+                    .post(format!("http://{}/upload", socket))
+                    .body(data.clone())
+                    .send()
+                    .await
+                    .unwrap();
+                assert_eq!(response.status(), StatusCode::CREATED);
+            })
+        }
+
+        let start = Instant::now();
+        // Spawn in the background to test 2 uploads in parallel
+        // Upload 3kb each
+        let handle1 = upload_data(socket, 3);
+        let handle2 = upload_data(socket, 3);
+
+        // Wait for the uploads to finish
+        let _ = tokio::try_join!(handle1, handle2);
+
+        let time_taken = start.elapsed();
+        assert!(time_taken > Duration::from_secs(6), "Should at least take 6s because uploads are limited to 1kb/s and the sum of the uploads is 6kb");
+    }
+
+    #[tokio::test]
+    async fn test_throttle_download() {
+        let path_limit = PathLimit::new(
+            GlobPattern::new("/download"),
+            Method::GET,
+            "1kb/s".parse().unwrap(),
+            LimitKey::Ip,
+            None,
+        );
+        let socket = start_server(vec![path_limit]).await;
+
+        fn download_data(socket: SocketAddr) -> JoinHandle<()> {
+            tokio::spawn(async move {
+                let client = Client::new();
+                let response = client
+                    .get(format!("http://{}/download", socket))
+                    .send()
+                    .await
+                    .unwrap();
+                assert_eq!(response.status(), StatusCode::OK);
+                response.bytes().await.unwrap(); // Download the body
+            })
+        }
+
+        let start = Instant::now();
+        // Spawn in the background to test 2 downloads in parallel
+        // Download 3kb each
+        let handle1 = download_data(socket);
+        let handle2 = download_data(socket);
+
+        // Wait for the uploads to finish
+        let _ = tokio::try_join!(handle1, handle2);
+
+        let time_taken = start.elapsed();
+        assert!(time_taken > Duration::from_secs(6), "Should at least take 6s because downloads are limited to 1kb/s and the sum of the downloads is 6kb");
+    }
+
+    #[tokio::test]
+    async fn test_limit_parallel_requests_with_ip_key() {
+        let path_limit = PathLimit::new(
+            GlobPattern::new("/upload"),
+            Method::POST,
+            "1r/m".parse().unwrap(),
+            LimitKey::Ip,
+            None,
+        );
+        let socket = start_server(vec![path_limit]).await;
+
+        fn send_request(socket: SocketAddr) -> JoinHandle<Response> {
+            tokio::spawn(async move {
+                let client = Client::new();
+                let response = client
+                    .post(format!("http://{}/upload", socket))
+                    .send()
+                    .await
+                    .unwrap();
+                response
+            })
+        }
+
+        // Spawn in the background to test 2 uploads in parallel
+        let handle1 = send_request(socket);
+        let handle2 = send_request(socket);
+
+        // Wait for the uploads to finish
+        let (res1, res2) = tokio::try_join!(handle1, handle2).unwrap();
+        assert_eq!(res1.status(), StatusCode::CREATED);
+        assert_eq!(res2.status(), StatusCode::TOO_MANY_REQUESTS);
+    }
+
+    #[tokio::test]
+    async fn test_limit_parallel_requests_with_user_key() {
+        let path_limit = PathLimit::new(
+            GlobPattern::new("/upload"),
+            Method::POST,
+            "1r/m".parse().unwrap(),
+            LimitKey::User,
+            None,
+        );
+        let socket = start_server(vec![path_limit]).await;
+
+        fn send_request(socket: SocketAddr, user_pubkey: PublicKey) -> JoinHandle<Response> {
+            tokio::spawn(async move {
+                let client = Client::new();
+                let response = client
+                    .post(format!("http://{}/upload?pubky-host={user_pubkey}", socket))
+                    .send()
+                    .await
+                    .unwrap();
+                response
+            })
+        }
+
+        // Spawn in the background to test 2 uploads in parallel
+        let user1_pubkey = Keypair::random().public_key();
+        let handle1 = send_request(socket, user1_pubkey.clone());
+        let handle2 = send_request(socket, user1_pubkey.clone());
+        let user2_pubkey = Keypair::random().public_key();
+        let handle3 = send_request(socket, user2_pubkey.clone());
+
+        // Wait for the uploads to finish
+        let (res1, res2, res3) = tokio::try_join!(handle1, handle2, handle3).unwrap();
+        assert_eq!(res1.status(), StatusCode::CREATED);
+        assert_eq!(res2.status(), StatusCode::TOO_MANY_REQUESTS);
+        assert_eq!(res3.status(), StatusCode::CREATED);
+    }
+}
diff --git a/pubky-homeserver/src/core/layers/rate_limiter/mod.rs b/pubky-homeserver/src/core/layers/rate_limiter/mod.rs
new file mode 100644
index 000000000..ee7d7367a
--- /dev/null
+++ b/pubky-homeserver/src/core/layers/rate_limiter/mod.rs
@@ -0,0 +1,3 @@
+mod extract_ip;
+mod layer;
+pub use layer::*;
diff --git a/pubky-homeserver/src/core/routes/mod.rs b/pubky-homeserver/src/core/routes/mod.rs
index 19f8b4092..b1bfc120f 100644
--- a/pubky-homeserver/src/core/routes/mod.rs
+++ b/pubky-homeserver/src/core/routes/mod.rs
@@ -13,9 +13,11 @@ use tower::ServiceBuilder;
 use tower_cookies::CookieManagerLayer;
 use tower_http::cors::CorsLayer;
 
-use crate::core::AppState;
+use crate::{core::AppState, AppContext};
 
-use super::layers::{pubky_host::PubkyHostLayer, trace::with_trace_layer};
+use super::layers::{
+    pubky_host::PubkyHostLayer, rate_limiter::RateLimiterLayer, trace::with_trace_layer,
+};
 
 mod auth;
 mod feed;
@@ -37,16 +39,20 @@ fn base() -> Router<AppState> {
     // TODO: maybe add to a separate router (drive router?).
 }
 
-pub fn create_app(state: AppState) -> Router {
+pub fn create_app(state: AppState, context: &AppContext) -> Router {
     let app = base()
         .merge(tenants::router(state.clone()))
         .layer(CookieManagerLayer::new())
         .layer(CorsLayer::very_permissive())
         .layer(ServiceBuilder::new().layer(middleware::from_fn(add_server_header)))
+        .layer(PubkyHostLayer)
+        .layer(RateLimiterLayer::new(
+            context.config_toml.drive.rate_limits.clone(),
+        ))
         .with_state(state);
 
     // Apply trace and pubky host layers to the complete router.
-    with_trace_layer(app, &TRACING_EXCLUDED_PATHS).layer(PubkyHostLayer)
+    with_trace_layer(app, &TRACING_EXCLUDED_PATHS)
 }
 
 // Middleware to add a `Server` header to all responses
diff --git a/pubky-homeserver/src/data_directory/config.default.toml b/pubky-homeserver/src/data_directory/config.default.toml
index c7da0fa32..23f2cc8d2 100644
--- a/pubky-homeserver/src/data_directory/config.default.toml
+++ b/pubky-homeserver/src/data_directory/config.default.toml
@@ -3,9 +3,11 @@ signup_mode = "token_required"
 lmdb_backup_interval_s = 0
 user_storage_quota_mb = 0
 
+
 [drive]
 pubky_listen_socket = "127.0.0.1:6287"
 icann_listen_socket = "127.0.0.1:6286"
+rate_limits = []
 
 [admin]
 listen_socket = "127.0.0.1:6288"
diff --git a/pubky-homeserver/src/data_directory/config_toml.rs b/pubky-homeserver/src/data_directory/config_toml.rs
index 9e7c647e2..9d8a479f0 100644
--- a/pubky-homeserver/src/data_directory/config_toml.rs
+++ b/pubky-homeserver/src/data_directory/config_toml.rs
@@ -4,7 +4,7 @@
 //! This module embeds that file at compile-time, parses it once,
 //! and lets callers optionally layer their own TOML on top.
 
-use super::{domain_port::DomainPort, Domain, SignupMode};
+use super::{domain_port::DomainPort, quota_config::PathLimit, Domain, SignupMode};
 use serde::{Deserialize, Serialize};
 use std::{
     fmt::Debug,
@@ -54,6 +54,7 @@ pub struct PkdnsToml {
 pub struct DriveToml {
     pub pubky_listen_socket: SocketAddr,
     pub icann_listen_socket: SocketAddr,
+    pub rate_limits: Vec<PathLimit>,
 }
 
 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
@@ -144,9 +145,8 @@ impl ConfigToml {
             .lines()
             .map(|line| {
                 let trimmed = line.trim_start();
-                let is_title = trimmed.starts_with('[');
                 let is_comment = trimmed.starts_with('#');
-                if !is_title && !is_comment && !trimmed.is_empty() {
+                if !is_comment && !trimmed.is_empty() {
                     format!("# {}", line)
                 } else {
                     line.to_string()
@@ -216,12 +216,13 @@ mod tests {
         assert_eq!(c.pkdns.dht_bootstrap_nodes, None);
         assert_eq!(c.pkdns.dht_relay_nodes, None);
         assert_eq!(c.pkdns.dht_request_timeout_ms, None);
+        assert_eq!(c.drive.rate_limits, vec![]);
     }
 
     #[test]
     fn test_sample_config() {
         // Validate that the sample config can be parsed
-        ConfigToml::from_str(SAMPLE_CONFIG).expect("Embedded config.default.toml must be valid");
+        ConfigToml::from_str(SAMPLE_CONFIG).expect("Embedded config.sample.toml must be valid");
     }
 
     #[test]
diff --git a/pubky-homeserver/src/data_directory/mod.rs b/pubky-homeserver/src/data_directory/mod.rs
index bf947dfb0..cc3fc1e17 100644
--- a/pubky-homeserver/src/data_directory/mod.rs
+++ b/pubky-homeserver/src/data_directory/mod.rs
@@ -4,6 +4,8 @@ mod domain;
 mod domain_port;
 mod mock_data_dir;
 mod persistent_data_dir;
+/// Quota configuration for the TomlConfig.
+pub mod quota_config;
 mod signup_mode;
 
 pub use config_toml::{ConfigReadError, ConfigToml};
diff --git a/pubky-homeserver/src/data_directory/quota_config/glob_pattern.rs b/pubky-homeserver/src/data_directory/quota_config/glob_pattern.rs
new file mode 100644
index 000000000..7583965d9
--- /dev/null
+++ b/pubky-homeserver/src/data_directory/quota_config/glob_pattern.rs
@@ -0,0 +1,89 @@
+use serde::{Deserialize, Serialize};
+use std::{fmt::Display, str::FromStr};
+
+/// A wrapper around fast_glob to allow serialize/deserialize.
+/// Pattern matches glob patterns.
+///
+/// Syntax - Meaning
+/// `?` - Matches any single character.
+/// `*` - Matches zero or more characters, except for path separators (e.g. /).
+/// `**` - Matches zero or more characters, including path separators. Must match a complete path segment (i.e. followed by a / or the end of the pattern).
+/// `[ab]` - Matches one of the characters contained in the brackets. Character ranges, e.g. `[a-z]` are also supported. Use `[!ab]` or `[^ab]` to match any character except those contained in the brackets.
+/// `{a,b}` - Matches one of the patterns contained in the braces. Any of the wildcard characters can be used in the sub-patterns. Braces may be nested up to 10 levels deep.
+/// `!` - When at the start of the glob, this negates the result. Multiple `!` characters negate the glob multiple times.
+/// `\` - A backslash character may be used to escape any of the above special characters.
+#[derive(Debug, Clone)]
+pub struct GlobPattern(pub String);
+
+impl GlobPattern {
+    /// Create a new glob pattern.
+    pub fn new(pattern: &str) -> Self {
+        Self(pattern.to_string())
+    }
+
+    /// Check if the path matches the glob pattern.
+    pub fn is_match(&self, path: &str) -> bool {
+        fast_glob::glob_match(&self.0, path)
+    }
+}
+
+impl std::hash::Hash for GlobPattern {
+    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+        self.0.as_str().hash(state);
+    }
+}
+
+impl FromStr for GlobPattern {
+    type Err = String;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(GlobPattern(s.to_string()))
+    }
+}
+
+impl Display for GlobPattern {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.0.as_str())
+    }
+}
+
+impl Serialize for GlobPattern {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        serializer.serialize_str(self.0.as_str())
+    }
+}
+
+impl<'de> Deserialize<'de> for GlobPattern {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let s = String::deserialize(deserializer)?;
+        GlobPattern::from_str(&s).map_err(serde::de::Error::custom)
+    }
+}
+
+impl PartialEq for GlobPattern {
+    fn eq(&self, other: &Self) -> bool {
+        self.0.as_str() == other.0.as_str()
+    }
+}
+
+impl Eq for GlobPattern {}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_glob_pattern() {
+        let glob_pattern = GlobPattern::from_str("/pub/**").unwrap();
+        assert!(glob_pattern.is_match("/pub/test.txt"));
+        assert!(glob_pattern.is_match("/pub/test/test.txt"));
+        assert!(!glob_pattern.is_match("/priv/test.pdf"));
+        assert!(!glob_pattern.is_match("/session/test.txt"));
+    }
+}
diff --git a/pubky-homeserver/src/data_directory/quota_config/http_method.rs b/pubky-homeserver/src/data_directory/quota_config/http_method.rs
new file mode 100644
index 000000000..23ed3a443
--- /dev/null
+++ b/pubky-homeserver/src/data_directory/quota_config/http_method.rs
@@ -0,0 +1,55 @@
+use std::{fmt::Display, str::FromStr};
+
+use axum::http::Method;
+use serde::{Deserialize, Serialize};
+
+/// A wrapper around http::Method to implement serde traits
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct HttpMethod(pub Method);
+
+impl From<Method> for HttpMethod {
+    fn from(method: Method) -> Self {
+        HttpMethod(method)
+    }
+}
+
+impl From<HttpMethod> for Method {
+    fn from(method: HttpMethod) -> Self {
+        method.0
+    }
+}
+
+impl FromStr for HttpMethod {
+    type Err = String;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Method::from_str(s.to_uppercase().as_str())
+            .map(HttpMethod)
+            .map_err(|_| format!("Invalid method: {}", s))
+    }
+}
+
+impl Display for HttpMethod {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.0)
+    }
+}
+
+impl Serialize for HttpMethod {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        serializer.serialize_str(&self.to_string())
+    }
+}
+
+impl<'de> Deserialize<'de> for HttpMethod {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let s = String::deserialize(deserializer)?;
+        HttpMethod::from_str(&s).map_err(serde::de::Error::custom)
+    }
+}
diff --git a/pubky-homeserver/src/data_directory/quota_config/limit_key.rs b/pubky-homeserver/src/data_directory/quota_config/limit_key.rs
new file mode 100644
index 000000000..567dcb3f9
--- /dev/null
+++ b/pubky-homeserver/src/data_directory/quota_config/limit_key.rs
@@ -0,0 +1,55 @@
+use std::fmt;
+use std::str::FromStr;
+
+/// The key to limit the quota on.
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum LimitKey {
+    /// Limit on the user id    
+    User,
+    /// Limit on the ip address
+    Ip,
+}
+
+impl fmt::Display for LimitKey {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(
+            f,
+            "{}",
+            match self {
+                LimitKey::User => "user",
+                LimitKey::Ip => "ip",
+            }
+        )
+    }
+}
+
+impl FromStr for LimitKey {
+    type Err = String;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "user" => Ok(LimitKey::User),
+            "ip" => Ok(LimitKey::Ip),
+            _ => Err(format!("Invalid limit key: {}", s)),
+        }
+    }
+}
+
+impl serde::Serialize for LimitKey {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        serializer.serialize_str(self.to_string().as_str())
+    }
+}
+
+impl<'de> serde::Deserialize<'de> for LimitKey {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let s = String::deserialize(deserializer)?;
+        LimitKey::from_str(&s).map_err(serde::de::Error::custom)
+    }
+}
diff --git a/pubky-homeserver/src/data_directory/quota_config/mod.rs b/pubky-homeserver/src/data_directory/quota_config/mod.rs
new file mode 100644
index 000000000..f88122007
--- /dev/null
+++ b/pubky-homeserver/src/data_directory/quota_config/mod.rs
@@ -0,0 +1,15 @@
+mod glob_pattern;
+mod http_method;
+mod limit_key;
+mod path_limit;
+mod quota_value;
+mod rate_unit;
+mod time_unit;
+
+pub use glob_pattern::GlobPattern;
+pub use http_method::HttpMethod;
+pub use limit_key::LimitKey;
+pub use path_limit::*;
+pub use quota_value::QuotaValue;
+pub use rate_unit::RateUnit;
+pub use time_unit::TimeUnit;
diff --git a/pubky-homeserver/src/data_directory/quota_config/path_limit.rs b/pubky-homeserver/src/data_directory/quota_config/path_limit.rs
new file mode 100644
index 000000000..c1c27225b
--- /dev/null
+++ b/pubky-homeserver/src/data_directory/quota_config/path_limit.rs
@@ -0,0 +1,77 @@
+use super::{GlobPattern, HttpMethod, LimitKey, QuotaValue};
+use axum::http::Method;
+use serde::{Deserialize, Serialize};
+use std::num::NonZeroU32;
+
+/// A limit on a path for a specific method.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
+pub struct PathLimit {
+    /// The path glob pattern to match against.
+    pub path: GlobPattern,
+    /// The method to limit.
+    pub method: HttpMethod,
+    /// The limit to apply.
+    pub quota: QuotaValue,
+    /// The key to limit.
+    pub key: LimitKey,
+    /// The burst to apply.
+    pub burst: Option<NonZeroU32>,
+}
+
+impl PathLimit {
+    /// Create a new path limit.
+    pub fn new(
+        path: GlobPattern,
+        method: Method,
+        quota: QuotaValue,
+        key: LimitKey,
+        burst: Option<NonZeroU32>,
+    ) -> Self {
+        Self {
+            path,
+            method: HttpMethod(method),
+            quota,
+            key,
+            burst,
+        }
+    }
+}
+
+impl std::fmt::Display for PathLimit {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{} {}: {}-{} by {}",
+            self.method,
+            self.path,
+            self.quota,
+            self.burst.map_or_else(String::new, |b| b.to_string()),
+            self.key
+        )
+    }
+}
+
+impl From<PathLimit> for governor::Quota {
+    fn from(value: PathLimit) -> Self {
+        let quota: governor::Quota = value.quota.into();
+        if let Some(burst) = value.burst {
+            quota.allow_burst(burst);
+        }
+        quota
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_http_method_serde() {
+        let method = Method::GET;
+        let http_method = HttpMethod(method);
+        assert_eq!(http_method.to_string(), "GET");
+
+        let deserialized: HttpMethod = "GET".parse().unwrap();
+        assert_eq!(deserialized, http_method);
+    }
+}
diff --git a/pubky-homeserver/src/data_directory/quota_config/quota_value.rs b/pubky-homeserver/src/data_directory/quota_config/quota_value.rs
new file mode 100644
index 000000000..f68e78895
--- /dev/null
+++ b/pubky-homeserver/src/data_directory/quota_config/quota_value.rs
@@ -0,0 +1,201 @@
+use std::fmt;
+use std::str::FromStr;
+use std::{num::NonZeroU32, time::Duration};
+
+use super::{RateUnit, TimeUnit};
+
+/// Quota value
+///
+/// Examples:
+/// - 5r/m
+/// - 5r/s
+/// - 5kb/m
+/// - 5mb/m
+/// - 5gb/s
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct QuotaValue {
+    /// The rate.
+    pub rate: NonZeroU32,
+    /// The unit of the rate.
+    pub rate_unit: RateUnit,
+    /// The unit of the time.
+    pub time_unit: TimeUnit,
+}
+
+impl From<QuotaValue> for governor::Quota {
+    /// Get the quota to do the actual rate limiting.
+    ///
+    /// Important: The speed quotas are always in kilobytes, not bytes.
+    /// Counting bytes is not practical.
+    ///
+    fn from(value: QuotaValue) -> Self {
+        let rate_count = value.rate.get();
+        let rate_unit = value.rate_unit.multiplier().get();
+        let rate = NonZeroU32::new(rate_count * rate_unit)
+            .expect("Is always non-zero because rate count and rate unit multiplier are non-zero");
+        let time_unit = Duration::from_secs(value.time_unit.multiplier_in_seconds().get() as u64);
+        let replenish_1_per = time_unit / rate.get();
+
+        let base_quota = governor::Quota::with_period(replenish_1_per)
+            .expect("Is always non-zero because replenish_1_per is non-zero");
+        base_quota.allow_burst(rate)
+    }
+}
+
+impl fmt::Display for QuotaValue {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{}{}/{}", self.rate, self.rate_unit, self.time_unit)
+    }
+}
+
+impl FromStr for QuotaValue {
+    type Err = String;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        // Split rate part by '/' to get rate+unit and time unit
+        let rate_parts: Vec<&str> = s.split('/').collect();
+        if rate_parts.len() != 2 {
+            return Err(format!(
+                "Invalid rate format: '{}', expected {{rate}}{{unit}}/{{time}}",
+                s
+            ));
+        }
+
+        let rate_with_unit = rate_parts[0];
+        let time_unit = TimeUnit::from_str(rate_parts[1])?;
+
+        // Find the boundary between rate digits and unit
+        let rate_digit_end = rate_with_unit
+            .chars()
+            .position(|c| !c.is_ascii_digit())
+            .unwrap_or(rate_with_unit.len());
+
+        if rate_digit_end == 0 {
+            return Err(format!("Missing rate value in '{}'", rate_with_unit));
+        }
+
+        let rate_str = &rate_with_unit[..rate_digit_end];
+        let rate_unit_str = &rate_with_unit[rate_digit_end..];
+
+        let rate = rate_str
+            .parse::<NonZeroU32>()
+            .map_err(|_| format!("Failed to parse rate from '{}'", rate_str))?;
+        let rate_unit = RateUnit::from_str(rate_unit_str)?;
+
+        Ok(QuotaValue {
+            rate,
+            rate_unit,
+            time_unit,
+        })
+    }
+}
+
+impl serde::Serialize for QuotaValue {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        serializer.serialize_str(&self.to_string())
+    }
+}
+
+impl<'de> serde::Deserialize<'de> for QuotaValue {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let s = String::deserialize(deserializer)?;
+
+        // Parse the quota string
+        QuotaValue::from_str(&s).map_err(serde::de::Error::custom)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::quota_config::rate_unit::SpeedRateUnit;
+
+    use super::*;
+
+    #[test]
+    fn test_get_quota() {
+        let quota = QuotaValue::from_str("5r/s").unwrap();
+        assert_eq!(
+            governor::Quota::from(quota),
+            governor::Quota::per_second(NonZeroU32::new(5).unwrap())
+        );
+
+        let quota = QuotaValue::from_str("5r/m").unwrap();
+        assert_eq!(
+            governor::Quota::from(quota),
+            governor::Quota::per_minute(NonZeroU32::new(5).unwrap())
+        );
+
+        let quota = QuotaValue::from_str("5kb/s").unwrap();
+        assert_eq!(
+            governor::Quota::from(quota),
+            governor::Quota::per_second(NonZeroU32::new(5).unwrap())
+        );
+
+        let quota = QuotaValue::from_str("5mb/m").unwrap();
+        assert_eq!(
+            governor::Quota::from(quota),
+            governor::Quota::per_minute(NonZeroU32::new(5 * 1024).unwrap())
+        );
+    }
+
+    #[test]
+    fn test_quota_value_from_str() {
+        // Test without burst
+        let quota = QuotaValue::from_str("5r/s").unwrap();
+        assert_eq!(quota.rate, NonZeroU32::new(5).unwrap());
+        assert_eq!(quota.rate_unit, RateUnit::Request);
+        assert_eq!(quota.time_unit, TimeUnit::Second);
+
+        // Test with burst (should fail or be handled differently)
+        let quota = QuotaValue::from_str("10mb/m").unwrap();
+        assert_eq!(quota.rate, NonZeroU32::new(10).unwrap());
+        assert_eq!(
+            quota.rate_unit,
+            RateUnit::SpeedRateUnit(SpeedRateUnit::Megabyte)
+        );
+        assert_eq!(quota.time_unit, TimeUnit::Minute);
+    }
+
+    #[test]
+    fn test_quota_value_display() {
+        // Test without burst
+        let quota = QuotaValue {
+            rate: NonZeroU32::new(5).unwrap(),
+            rate_unit: RateUnit::Request,
+            time_unit: TimeUnit::Second,
+        };
+        assert_eq!(quota.to_string(), "5r/s");
+
+        // Test with burst (should be displayed without burst)
+        let quota = QuotaValue {
+            rate: NonZeroU32::new(10).unwrap(),
+            rate_unit: RateUnit::SpeedRateUnit(SpeedRateUnit::Megabyte),
+            time_unit: TimeUnit::Minute,
+        };
+        assert_eq!(quota.to_string(), "10mb/m");
+    }
+
+    #[test]
+    fn test_quota_value_invalid_formats() {
+        // Invalid format: missing /
+        assert!(QuotaValue::from_str("5rs").is_err());
+
+        // Invalid format: missing rate
+        assert!(QuotaValue::from_str("r/s").is_err());
+
+        // Invalid format: invalid rate unit
+        assert!(QuotaValue::from_str("5x/s").is_err());
+
+        // Invalid format: invalid time unit
+        assert!(QuotaValue::from_str("5r/x").is_err());
+
+        // Invalid format: invalid burst (this test case might need to be removed or updated)
+        assert!(QuotaValue::from_str("5r/s-2burst").is_err());
+    }
+}
diff --git a/pubky-homeserver/src/data_directory/quota_config/rate_unit.rs b/pubky-homeserver/src/data_directory/quota_config/rate_unit.rs
new file mode 100644
index 000000000..7eee078a6
--- /dev/null
+++ b/pubky-homeserver/src/data_directory/quota_config/rate_unit.rs
@@ -0,0 +1,112 @@
+use std::{num::NonZeroU32, str::FromStr};
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum SpeedRateUnit {
+    /// Kilobyte
+    Kilobyte,
+    /// Megabyte
+    Megabyte,
+    /// Gigabyte
+    Gigabyte,
+}
+
+impl SpeedRateUnit {
+    /// Returns the number of bytes for this unit
+    pub const fn multiplier(&self) -> NonZeroU32 {
+        match self {
+            // Speed quotas are always in kilobytes.
+            // Because counting bytes is not practical and we are limited to u32 = 4GB max.
+            // Counting in kb as more practical and we can count up to 4GB*1024 = 4TB.
+            SpeedRateUnit::Kilobyte => NonZeroU32::new(1).expect("Is always non-zero"),
+            SpeedRateUnit::Megabyte => NonZeroU32::new(1024).expect("Is always non-zero"),
+            SpeedRateUnit::Gigabyte => NonZeroU32::new(1024 * 1024).expect("Is always non-zero"),
+        }
+    }
+}
+
+impl std::fmt::Display for SpeedRateUnit {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{}",
+            match self {
+                SpeedRateUnit::Kilobyte => "kb",
+                SpeedRateUnit::Megabyte => "mb",
+                SpeedRateUnit::Gigabyte => "gb",
+            }
+        )
+    }
+}
+
+impl FromStr for SpeedRateUnit {
+    type Err = String;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s.to_lowercase().as_str() {
+            "kb" => Ok(SpeedRateUnit::Kilobyte),
+            "mb" => Ok(SpeedRateUnit::Megabyte),
+            "gb" => Ok(SpeedRateUnit::Gigabyte),
+            _ => Err(format!("Invalid speedrate unit: {}", s)),
+        }
+    }
+}
+
+/// The unit of the rate.
+///
+/// Examples:
+/// - "r" -> request
+/// - "kb" -> kilobyte
+/// - "mb" -> megabyte
+/// - "gb" -> gigabyte
+/// - "tb" -> terabyte
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum RateUnit {
+    /// Request
+    Request,
+    /// Speed rate unit
+    SpeedRateUnit(SpeedRateUnit),
+}
+
+impl RateUnit {
+    /// Returns true if the rate unit is a speed rate unit.
+    pub fn is_speed_rate_unit(&self) -> bool {
+        matches!(self, RateUnit::SpeedRateUnit(_))
+    }
+}
+
+impl std::fmt::Display for RateUnit {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{}",
+            match self {
+                RateUnit::Request => "r".to_string(),
+                RateUnit::SpeedRateUnit(unit) => unit.to_string(),
+            }
+        )
+    }
+}
+
+impl FromStr for RateUnit {
+    type Err = String;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s.to_lowercase().as_str() {
+            "r" => Ok(RateUnit::Request),
+            other => match SpeedRateUnit::from_str(other) {
+                Ok(unit) => Ok(RateUnit::SpeedRateUnit(unit)),
+                Err(_) => Err(format!("Invalid rate unit: {}", s)),
+            },
+        }
+    }
+}
+
+impl RateUnit {
+    /// Returns the number of bytes for this unit
+    pub const fn multiplier(&self) -> NonZeroU32 {
+        match self {
+            RateUnit::Request => NonZeroU32::new(1).expect("Is always non-zero"),
+            RateUnit::SpeedRateUnit(unit) => unit.multiplier(),
+        }
+    }
+}
diff --git a/pubky-homeserver/src/data_directory/quota_config/time_unit.rs b/pubky-homeserver/src/data_directory/quota_config/time_unit.rs
new file mode 100644
index 000000000..796141c43
--- /dev/null
+++ b/pubky-homeserver/src/data_directory/quota_config/time_unit.rs
@@ -0,0 +1,49 @@
+use std::num::NonZeroU32;
+
+/// The time unit of the quota.
+///
+/// Examples:
+/// - "s" -> second
+/// - "m" -> minute
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum TimeUnit {
+    /// Second
+    Second,
+    /// Minute
+    Minute,
+}
+
+impl std::fmt::Display for TimeUnit {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{}",
+            match self {
+                TimeUnit::Second => "s",
+                TimeUnit::Minute => "m",
+            }
+        )
+    }
+}
+
+impl std::str::FromStr for TimeUnit {
+    type Err = String;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "s" => Ok(TimeUnit::Second),
+            "m" => Ok(TimeUnit::Minute),
+            _ => Err(format!("Invalid time unit: {}", s)),
+        }
+    }
+}
+
+impl TimeUnit {
+    /// Returns the number of seconds for each unit
+    pub const fn multiplier_in_seconds(&self) -> NonZeroU32 {
+        match self {
+            TimeUnit::Second => NonZeroU32::new(1).expect("Is always non-zero"),
+            TimeUnit::Minute => NonZeroU32::new(60).expect("Is always non-zero"),
+        }
+    }
+}


Reviews:
user: login: SHAcollision
body: Very in depth/flexible method for rate limiting. I think it's very user friendly and allows maybe way more flexibility that users need (will users know the routes of the homeserver to do regex match? Should they only be able to "limit_upload" and "limit_signup"?). Anyway it's cool and certainly super useful for the homeserver we will manage, we'll learn more as we use it.

Code is clean and clear. I left a few comments, hit me and I do another pass in more depth.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T13:08:10Z
user: login: SeverinAlexB
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T13:12:34Z
user: login: SeverinAlexB
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T13:15:17Z
user: login: SHAcollision
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T13:25:56Z
user: login: SeverinAlexB
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T13:58:26Z
user: login: SeverinAlexB
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-13T06:28:29Z
user: login: SHAcollision
body: ACK looks good to merge to me!

We'll have to make sure our nginx does not mess incorrectly with headers (e.g., our pkarr proxy had a global limit because all IP's were the proxy's IP).
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T07:28:38Z
user: login: SHAcollision
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T07:29:22Z
user: login: SeverinAlexB
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-13T07:33:09Z

Review comments:
diff_hunk: @@ -53,7 +54,10 @@ axum-test = "17.2.0"
 tempfile = { version = "3.10.1" }
 dyn-clone = "1.0.19"
 reqwest = "0.12.15"
+governor = "0.10.0"
+http = "1.3.1"
path: pubky-homeserver/Cargo.toml
user: login: SHAcollision
body: Is there some reason to use `http` as direct import instead of `axum::http` re-export that we are already using?
created_at: 2025-05-12T10:24:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,6 +33,7 @@ httpdate = "1.0.3"
 postcard = { version = "1.1.1", features = ["alloc"] }
 pkarr = { workspace = true, features = ["dht", "lmdb-cache", "tls"] }
 pubky-common = { version = "0.4.0-rc.0", path = "../pubky-common" }
+regex = "1.10.4"
path: pubky-homeserver/Cargo.toml
user: login: SHAcollision
body: `regex` is a fairly heavy crate. Since you’re compiling arbitrary user-supplied patterns at startup, we could consider to restrict patterns (e.g. only prefix/suffix, no backtracking) or compile them once with `lazy_static!` to avoid repeated cost (correct me if I did not get this well). All in all, I tried compiling this branch with and without `regex` using `--release` optimizations and the binary grows by 10% (rough proxy for the amount of entropy this crate brings with it).
created_at: 2025-05-12T10:26:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+use axum::extract::Request;
+use http::HeaderMap;
path: pubky-homeserver/src/core/layers/rate_limiter/extract_ip.rs
user: login: SHAcollision
body: There is a risk of using 2 different versions of the `http` crate as we are also using `axum::http::HeaderMap;` in `core/routes/tenants/read.rs`
created_at: 2025-05-12T12:45:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+use axum::extract::Request;
+use http::HeaderMap;
+use std::net::{IpAddr, SocketAddr};
+
+// From https://github.com/benwis/tower-governor/blob/main/src/key_extractor.rs#L121
path: pubky-homeserver/src/core/layers/rate_limiter/extract_ip.rs
user: login: SHAcollision
body: Could we use `tower-governor` instead of `governor` ? Is there a chance we've effectively re-implemented parts of tower-governor?
created_at: 2025-05-12T12:46:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,112 @@
+use std::{num::NonZeroU32, str::FromStr};
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum SpeedRateUnit {
+    /// Kilobyte
+    Kilobyte,
+    /// Megabyte
+    Megabyte,
+    /// Gigabyte
+    Gigabyte,
+}
+
+impl SpeedRateUnit {
+    /// Returns the number of bytes for this unit
+    pub const fn multiplier(&self) -> NonZeroU32 {
+        match self {
+            // Speed quotas are always in kilobytes.
+            // Because counting bytes is not practical and we are limited to u32 = 4GB max.
+            // Counting in kb as more practical and we can count up to 4GB*1024 = 4TB.
+            SpeedRateUnit::Kilobyte => NonZeroU32::new(1).expect("Is always non-zero"),
+            SpeedRateUnit::Megabyte => NonZeroU32::new(1024).expect("Is always non-zero"),
+            SpeedRateUnit::Gigabyte => NonZeroU32::new(1024 * 1024).expect("Is always non-zero"),
+        }
+    }
+}
+
+impl std::fmt::Display for SpeedRateUnit {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{}",
+            match self {
+                SpeedRateUnit::Kilobyte => "kb",
+                SpeedRateUnit::Megabyte => "mb",
+                SpeedRateUnit::Gigabyte => "gb",
+            }
+        )
+    }
+}
+
+impl FromStr for SpeedRateUnit {
+    type Err = String;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "kb" => Ok(SpeedRateUnit::Kilobyte),
path: pubky-homeserver/src/data_directory/quota_config/rate_unit.rs
user: login: SHAcollision
body: NIT: It's all clear, at least to me, will comment this anyway:

In many contexts, a lowercase “b” stands for bits, and an uppercase “B” for bytes (so “Kb” is a kilobit, “KB” a kilobyte). Since you’re clearly working in bytes (your code even rounds chunks up to kilobytes), you might choose to render them as "kB", "MB", "GB" (SI (decimal) prefixes for mega and giga are ciptal) to avoid any possible confusion. It could even be acceptable to go for case‐insensitivity in this case, whatever!

```rust
match &*s.to_lowercase() {
    "kb" => Ok(Kilobyte),
    "mb" => Ok(Megabyte),
    "gb" => Ok(Gigabyte),
    _    => Err(format!("Invalid speed rate unit: {}", s)),
}
```
created_at: 2025-05-12T12:58:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -53,7 +54,10 @@ axum-test = "17.2.0"
 tempfile = { version = "3.10.1" }
 dyn-clone = "1.0.19"
 reqwest = "0.12.15"
+governor = "0.10.0"
+http = "1.3.1"
path: pubky-homeserver/Cargo.toml
user: login: SeverinAlexB
body: Good catch
created_at: 2025-05-12T13:12:34Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,37 @@
+use axum::extract::Request;
+use http::HeaderMap;
+use std::net::{IpAddr, SocketAddr};
+
+// From https://github.com/benwis/tower-governor/blob/main/src/key_extractor.rs#L121
path: pubky-homeserver/src/core/layers/rate_limiter/extract_ip.rs
user: login: SeverinAlexB
body: This is just a reference where I copied the code from.

Why would we add tower-governor as a dependency to the homeserver?
created_at: 2025-05-12T13:15:17Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,37 @@
+use axum::extract::Request;
+use http::HeaderMap;
+use std::net::{IpAddr, SocketAddr};
+
+// From https://github.com/benwis/tower-governor/blob/main/src/key_extractor.rs#L121
path: pubky-homeserver/src/core/layers/rate_limiter/extract_ip.rs
user: login: SHAcollision
body: I don't know much about governor, just trying to learn more with my questions. I was wondering whether we could be using `tower-governor` instead of `governor`. 
created_at: 2025-05-12T13:25:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,6 +33,7 @@ httpdate = "1.0.3"
 postcard = { version = "1.1.1", features = ["alloc"] }
 pkarr = { workspace = true, features = ["dht", "lmdb-cache", "tls"] }
 pubky-common = { version = "0.4.0-rc.0", path = "../pubky-common" }
+regex = "1.10.4"
path: pubky-homeserver/Cargo.toml
user: login: SeverinAlexB
body: Replaced regex with fast_glob. Binary size is down again. It's also easier for the user to use.
created_at: 2025-05-12T13:58:26Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,37 @@
+use axum::extract::Request;
+use http::HeaderMap;
+use std::net::{IpAddr, SocketAddr};
+
+// From https://github.com/benwis/tower-governor/blob/main/src/key_extractor.rs#L121
path: pubky-homeserver/src/core/layers/rate_limiter/extract_ip.rs
user: login: SeverinAlexB
body: 2 reasons I didn't use `tower-governor`:
- It doesn't export it's types. So I can't store it in a struct and therefore can't make it reusable aka make it clean. https://github.com/benwis/tower-governor/issues/49
- It only supports rate limiting by request, not by transfer speed.

I did try using tower-governor initially.
created_at: 2025-05-13T06:28:29Z
author_association: COLLABORATOR
diff_hunk: @@ -24,6 +24,35 @@ pubky_listen_socket = "127.0.0.1:6287"
 # May be put behind a reverse proxy with TLS enabled.
 icann_listen_socket = "127.0.0.1:6286"
 
+# Rate limit endpoints dynamically.
+# `path` is a glob pattern of the path. See syntax in https://crates.io/crates/fast-glob
+# `method` is the HTTP method. Examples: GET, POST, PUT, HEAD, DELETE
+# `quota` defines the limit itself in the format $rate$rate_unit/$time_unit.
+#  - $rate is a positive integer, max 4'294'967'296.
+#  - $rate_unit is either "r" for requests, "kb" for kilobytes, 
+#    "mb" for megabytes, "gb" for gigabytes.
+#  Speed limits limit download and upload.
+#  - $time_unit is either "s" for second, "m" for minute.
+# `key` defines what who is rate limited. 
+#  - "ip" limits based on the IP address.
+#  - "user" limits based on the user pubkey. Requires the endpoint to have authentication.
+# `burst` is a temporary allowance of quota that is added to the limit. 
+#   By default, burst is equal the quota rate.
+#
+# Limit login attempts to 20 requests per minute per IP. 
+[[drive.rate_limits]]
+path = "/session"
+method = "POST"
+quota = "20r/m"
+key = "ip"
+#
+# Limit file uploads to 1 megabyte per second per user with a temporary burst of 10 megabytes.
+[[drive.rate_limits]]
+path = "/pub/**" 
+method = "PUT"
+quota = "1mb/s"
+key = "user"
+burst = 10
path: pubky-homeserver/config.sample.toml
user: login: SHAcollision
body: Just curiosity, how long does the burst last, is it for the first second?
created_at: 2025-05-13T07:08:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -216,12 +216,13 @@ mod tests {
         assert_eq!(c.pkdns.dht_bootstrap_nodes, None);
         assert_eq!(c.pkdns.dht_relay_nodes, None);
         assert_eq!(c.pkdns.dht_request_timeout_ms, None);
+        assert_eq!(c.drive.rate_limits, vec![]);
     }
 
     #[test]
     fn test_sample_config() {
         // Validate that the sample config can be parsed
-        ConfigToml::from_str(SAMPLE_CONFIG).expect("Embedded config.default.toml must be valid");
+        ConfigToml::from_str(SAMPLE_CONFIG).expect("Embedded config.sample.toml must be valid");
path: pubky-homeserver/src/data_directory/config_toml.rs
user: login: SHAcollision
body: good catch
created_at: 2025-05-13T07:16:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -53,7 +53,10 @@ axum-test = "17.2.0"
 tempfile = { version = "3.10.1" }
 dyn-clone = "1.0.19"
 reqwest = "0.12.15"
+governor = "0.10.0"
+fast-glob = "0.4.5"
path: pubky-homeserver/Cargo.toml
user: login: SHAcollision
body: nice alternative
created_at: 2025-05-13T07:24:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+use axum::extract::Request;
+use http::HeaderMap;
+use std::net::{IpAddr, SocketAddr};
+
+// From https://github.com/benwis/tower-governor/blob/main/src/key_extractor.rs#L121
path: pubky-homeserver/src/core/layers/rate_limiter/extract_ip.rs
user: login: SHAcollision
body: Makes sense!
created_at: 2025-05-13T07:29:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -24,6 +24,35 @@ pubky_listen_socket = "127.0.0.1:6287"
 # May be put behind a reverse proxy with TLS enabled.
 icann_listen_socket = "127.0.0.1:6286"
 
+# Rate limit endpoints dynamically.
+# `path` is a glob pattern of the path. See syntax in https://crates.io/crates/fast-glob
+# `method` is the HTTP method. Examples: GET, POST, PUT, HEAD, DELETE
+# `quota` defines the limit itself in the format $rate$rate_unit/$time_unit.
+#  - $rate is a positive integer, max 4'294'967'296.
+#  - $rate_unit is either "r" for requests, "kb" for kilobytes, 
+#    "mb" for megabytes, "gb" for gigabytes.
+#  Speed limits limit download and upload.
+#  - $time_unit is either "s" for second, "m" for minute.
+# `key` defines what who is rate limited. 
+#  - "ip" limits based on the IP address.
+#  - "user" limits based on the user pubkey. Requires the endpoint to have authentication.
+# `burst` is a temporary allowance of quota that is added to the limit. 
+#   By default, burst is equal the quota rate.
+#
+# Limit login attempts to 20 requests per minute per IP. 
+[[drive.rate_limits]]
+path = "/session"
+method = "POST"
+quota = "20r/m"
+key = "ip"
+#
+# Limit file uploads to 1 megabyte per second per user with a temporary burst of 10 megabytes.
+[[drive.rate_limits]]
+path = "/pub/**" 
+method = "PUT"
+quota = "1mb/s"
+key = "user"
+burst = 10
path: pubky-homeserver/config.sample.toml
user: login: SeverinAlexB
body: Think of a bucket of water with a max capacity of 10 drops. It is continuously filled with 1 drop per second. It is filled with 10 drops initially. Any drops that go over the max capacity is wasted.

Each request consumes 1 drop of water of the bucket. If the bucket is empty, requests are rejected.

More details here https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm


created_at: 2025-05-13T07:33:09Z
author_association: COLLABORATOR

PR comments:
user: login: SeverinAlexB
created_at: 2025-05-12T14:55:54Z
body: > I think it's very user friendly and allows maybe way more flexibility that users need (will users know the routes of the homeserver to do regex match? Should they only be able to "limit_upload" and "limit_signup"?)

This is a good question and how I implemented it initially. This created a lot of bloated code though because I would need to program a config for each endpoint. At the same time, the solution was not "do it once and then forget". For each new endpoint, one would need to think about rate limiting config again.

So to reduce complexity on the dev side, I decided for this flexible approach. I do see the "complexity" on the user side though.

> will users know the routes of the homeserver to do regex match? Should they only be able to "limit_upload" and "limit_signup"?

Maybe I should recommended sample configs for all endpoints? This would for sure help the end user with configuring.


author_association: COLLABORATOR
user: login: SHAcollision
created_at: 2025-05-13T07:30:38Z
body: >So to reduce complexity on the dev side, I decided for this flexible approach. I do see the "complexity" on the user side though.

Sounds good. It's an acceptable trade off.

> Maybe I should recommended sample configs for all endpoints? This would for sure help the end user with configuring.

Yes I think having sample configs for the different endpoints is a good idea. For now, having them for `/session` and `/pub` is probably good enough.
author_association: CONTRIBUTOR