diff --git a/.env.template b/.env.template
index ca6962b4f7abe9bca7448270aa260a1c86113ac3..7fcbbfcb4687a95f1fc6da3c1b4a72af72b893af 100644
--- a/.env.template
+++ b/.env.template
@@ -268,6 +268,17 @@
 ## Multiple values must be separated with a whitespace.
 # ALLOWED_IFRAME_ANCESTORS=
 
+## Number of seconds, on average, between login requests from the same IP address before rate limiting kicks in.
+# LOGIN_RATELIMIT_SECONDS=60
+## Allow a burst of requests of up to this size, while maintaining the average indicated by `LOGIN_RATELIMIT_SECONDS`.
+## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2.
+# LOGIN_RATELIMIT_MAX_BURST=10
+
+## Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in.
+# ADMIN_RATELIMIT_SECONDS=300
+## Allow a burst of requests of up to this size, while maintaining the average indicated by `ADMIN_RATELIMIT_SECONDS`.
+# ADMIN_RATELIMIT_MAX_BURST=3
+
 ## Yubico (Yubikey) Settings
 ## Set your Client ID and Secret Key for Yubikey OTP
 ## You can generate it here: https://upgrade.yubico.com/getapikey/
diff --git a/Cargo.lock b/Cargo.lock
index df13985724e7d256543c66c3c1eb58c9dc89251f..8d5d1b6839c6a00c70572fcdadfa35b9381cc8b8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -17,6 +17,12 @@ version = "1.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
 
+[[package]]
+name = "ahash"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8fd72866655d1904d6b0997d0b07ba561047d070fbe29de039031c641b61217"
+
 [[package]]
 name = "aho-corasick"
 version = "0.7.18"
@@ -412,6 +418,16 @@ dependencies = [
  "subtle",
 ]
 
+[[package]]
+name = "dashmap"
+version = "4.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e77a43b28d0668df09411cb0bc9a8c2adc40f9a048afe863e05fd43251e8e39c"
+dependencies = [
+ "cfg-if 1.0.0",
+ "num_cpus",
+]
+
 [[package]]
 name = "data-encoding"
 version = "2.3.2"
@@ -731,6 +747,12 @@ version = "0.3.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "dabf1872aaab32c886832f2276d2f5399887e2bd613698a02359e4ea83f8de12"
 
+[[package]]
+name = "futures-timer"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e64b03909df88034c26dc1547e8970b91f98bdb65165d6a4e9110d94263dbb2c"
+
 [[package]]
 name = "futures-util"
 version = "0.3.18"
@@ -802,6 +824,23 @@ version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574"
 
+[[package]]
+name = "governor"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06c5d2f987ee8f6dff3fa1a352058dc59b990e447e4c7846aa7d804971314f7b"
+dependencies = [
+ "dashmap",
+ "futures",
+ "futures-timer",
+ "no-std-compat",
+ "nonzero_ext",
+ "parking_lot 0.11.2",
+ "quanta",
+ "rand 0.8.4",
+ "smallvec 1.7.0",
+]
+
 [[package]]
 name = "h2"
 version = "0.3.7"
@@ -842,6 +881,16 @@ dependencies = [
  "walkdir",
 ]
 
+[[package]]
+name = "hashbrown"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91b62f79061a0bc2e046024cb7ba44b08419ed238ecbd9adbd787434b9e8c25"
+dependencies = [
+ "ahash",
+ "autocfg",
+]
+
 [[package]]
 name = "hashbrown"
 version = "0.11.2"
@@ -1042,7 +1091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bc633605454125dec4b66843673f01c7df2b89479b32e0ed634e43a91cff62a5"
 dependencies = [
  "autocfg",
- "hashbrown",
+ "hashbrown 0.11.2",
 ]
 
 [[package]]
@@ -1480,6 +1529,15 @@ version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54"
 
+[[package]]
+name = "no-std-compat"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
+dependencies = [
+ "hashbrown 0.8.2",
+]
+
 [[package]]
 name = "nom"
 version = "4.1.1"
@@ -1500,6 +1558,12 @@ dependencies = [
  "version_check 0.9.3",
 ]
 
+[[package]]
+name = "nonzero_ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44a1290799eababa63ea60af0cbc3f03363e328e58f32fb0294798ed3e85f444"
+
 [[package]]
 name = "ntapi"
 version = "0.3.6"
@@ -1966,11 +2030,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "292972edad6bbecc137ab84c5e36421a4a6c979ea31d3cc73540dd04315b33e1"
 dependencies = [
  "byteorder",
- "hashbrown",
+ "hashbrown 0.11.2",
  "idna 0.2.3",
  "psl-types",
 ]
 
+[[package]]
+name = "quanta"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98dc777a7a39b76b1a26ae9d3f691f4c1bc0455090aa0b64dfa8cb7fc34c135"
+dependencies = [
+ "libc",
+ "winapi 0.3.9",
+]
+
 [[package]]
 name = "quick-error"
 version = "1.2.3"
@@ -3213,6 +3287,7 @@ dependencies = [
  "diesel_migrations",
  "dotenv",
  "fern",
+ "governor",
  "handlebars",
  "html5ever",
  "idna 0.2.3",
diff --git a/Cargo.toml b/Cargo.toml
index 5d4617ce9ab05aa8c478ccf7230b9be1caa00a81..5f8e16b070434a8a0755db6454637edf3259cd50 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -139,6 +139,7 @@ backtrace = "0.3.63"
 
 # Macro ident concatenation
 paste = "1.0.6"
+governor = "0.3.2"
 
 [patch.crates-io]
 # Use newest ring
diff --git a/src/api/admin.rs b/src/api/admin.rs
index 74fd6d8a26e651d433600f49f8f657f2b98a69d2..60f6aad48982ee33bc864a9cd771175961e8571f 100644
--- a/src/api/admin.rs
+++ b/src/api/admin.rs
@@ -166,6 +166,10 @@ fn post_admin_login(
 ) -> Result<Redirect, Flash<Redirect>> {
     let data = data.into_inner();
 
+    if crate::ratelimit::check_limit_admin(&ip.ip).is_err() {
+        return Err(Flash::error(Redirect::to(admin_url(referer)), "Too many requests, try again later."));
+    }
+
     // If the token is invalid, redirect to login page
     if !_validate_token(&data.token) {
         error!("Invalid admin token. IP: {}", ip.ip);
diff --git a/src/api/identity.rs b/src/api/identity.rs
index 356364b153595e7fc9922b54550b6c3a3a6c78cb..3cb26ba3f91a078de9580e2117f8ec238567e543 100644
--- a/src/api/identity.rs
+++ b/src/api/identity.rs
@@ -84,6 +84,9 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult
         err!("Scope not supported")
     }
 
+    // Ratelimit the login
+    crate::ratelimit::check_limit_login(&ip.ip)?;
+
     // Get the user
     let username = data.username.as_ref().unwrap();
     let user = match User::find_by_mail(username, &conn) {
diff --git a/src/config.rs b/src/config.rs
index 9639b3c42c71a7374f154ce37c023e74ccd7eb77..5bbe8575c392850affe4a2fd80dab07d4490ddb5 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -511,6 +511,16 @@ make_config! {
 
         /// Allowed iframe ancestors (Know the risks!) |> Allows other domains to embed the web vault into an iframe, useful for embedding into secure intranets
         allowed_iframe_ancestors: String, true, def,    String::new();
+
+        /// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in
+        login_ratelimit_seconds:       u64, false, def, 60;
+        /// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
+        login_ratelimit_max_burst:     u32, false, def, 10;
+
+        /// Seconds between admin requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
+        admin_ratelimit_seconds:       u64, false, def, 300;
+        /// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
+        admin_ratelimit_max_burst:     u32, false, def, 3;
     },
 
     /// Yubikey settings
diff --git a/src/main.rs b/src/main.rs
index e23b2e4cfd32b37eb73f01a4694b90a6842eed5c..dd9fa51e68dad5af338cd4685fd239a9ed5353a2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -32,6 +32,7 @@ mod crypto;
 #[macro_use]
 mod db;
 mod mail;
+mod ratelimit;
 mod util;
 
 pub use config::CONFIG;
diff --git a/src/ratelimit.rs b/src/ratelimit.rs
new file mode 100644
index 0000000000000000000000000000000000000000..c85ce7ad4cd3ee6f9d2f7ec947686ad0d4c38429
--- /dev/null
+++ b/src/ratelimit.rs
@@ -0,0 +1,38 @@
+use once_cell::sync::Lazy;
+use std::{net::IpAddr, num::NonZeroU32, time::Duration};
+
+use governor::{clock::DefaultClock, state::keyed::DashMapStateStore, Quota, RateLimiter};
+
+use crate::{Error, CONFIG};
+
+type Limiter<T = IpAddr> = RateLimiter<T, DashMapStateStore<T>, DefaultClock>;
+
+static LIMITER_LOGIN: Lazy<Limiter> = Lazy::new(|| {
+    let seconds = Duration::from_secs(CONFIG.login_ratelimit_seconds());
+    let burst = NonZeroU32::new(CONFIG.login_ratelimit_max_burst()).expect("Non-zero login ratelimit burst");
+    RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst))
+});
+
+static LIMITER_ADMIN: Lazy<Limiter> = Lazy::new(|| {
+    let seconds = Duration::from_secs(CONFIG.admin_ratelimit_seconds());
+    let burst = NonZeroU32::new(CONFIG.admin_ratelimit_max_burst()).expect("Non-zero admin ratelimit burst");
+    RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
+});
+
+pub fn check_limit_login(ip: &IpAddr) -> Result<(), Error> {
+    match LIMITER_LOGIN.check_key(ip) {
+        Ok(_) => Ok(()),
+        Err(_e) => {
+            err_code!("Too many login requests", 429);
+        }
+    }
+}
+
+pub fn check_limit_admin(ip: &IpAddr) -> Result<(), Error> {
+    match LIMITER_ADMIN.check_key(ip) {
+        Ok(_) => Ok(()),
+        Err(_e) => {
+            err_code!("Too many admin requests", 429);
+        }
+    }
+}