Authentication
Documentation / Authentication / LoginGuard — Brute-force Protection

LoginGuard — Brute-force Protection

LoginGuard tracks failed login attempts per username, independently of the IP address.

Full usage with `effective_key`

effective_key automatically determines the right key based on whether a username was submitted:

  • Username submitted → key by account (targeted protection)
  • Empty username → "anonym:{ip}" (per-IP protection, independent counters)
use axum::Extension;
use runique::middleware::security::trusted_proxies::ClientIp;
use runique::prelude::*;
use std::sync::LazyLock;

static GUARD: LazyLock<LoginGuard> = LazyLock::new(|| {
    LoginGuard::new()
        .max_attempts(5)
        .lockout_secs(300)
});

pub async fn sign_in(
    client_ip: Option<Extension<ClientIp>>,
    mut request: Request,
) -> AppResult<Response> {
    let form: LoginForm = request.form("login");
    let validated = match ValidationForm::try_form(form, &request).await {
        Ok(validated) => validated,
        Err(form) => {
            context_update!(request => { "login_form" => &form });
            return request.render("login.html");
        }
    };
    let username = validated.cleaned::<String>("username").unwrap_or_default();
    let password = validated.cleaned::<String>("password").unwrap_or_default();
    // The real client IP, resolved by the trusted proxies middleware
    let ip = client_ip.map(|Extension(ClientIp(ip))| ip.to_string()).unwrap_or_default();

    // username submitted → "alice"  |  empty username → "anonym:1.2.3.4"
    let key = LoginGuard::effective_key(&username, &ip);

    if GUARD.is_locked(&key) {
        let remaining = GUARD.remaining_lockout_secs(&key).unwrap_or(0);
        return Ok((StatusCode::TOO_MANY_REQUESTS, format!("Try again in {remaining}s")).into_response());
    }

    if let Some(user) = authenticate_user(&request.engine.db, &username, &password).await
        && login(&request.session, &user, None, false).await.is_ok()
    {
        GUARD.record_success(&key);
        return Ok(Redirect::to("/dashboard").into_response());
    }

    GUARD.record_failure(&key);
    context_update!(request => {
        "login_form" => &*validated,
        "messages" => flash_now!(error => "Invalid credentials"),
    });
    request.render("login.html")
}

Why in the handler and not middleware?

A middleware runs before the handler and cannot read the body without consuming it. request.form("name") extracts the form exactly once — the username is only available after that extraction.

The session knows the authentication state, but at login time the user is not yet authenticated: request.user is None on this route, which does not protect the targeted account.

SourceAvailable in middlewareReliable for LoginGuard
IP address✅✅ (used by effective_key for anon)
Signed-in user (request.user)✅❌ (None on /login, falls back to anon)
Username (form body)❌ (consumes the body)✅ (via request.form("name") in the handler)

Configuration

LoginGuard::new().max_attempts(5).lockout_secs(300)    // 5 failures → 5 minute lockout
LoginGuard::new().max_attempts(3).lockout_secs(900)    // 3 failures → 15 minute lockout
LoginGuard::new().max_attempts(10).lockout_secs(3600)  // 10 failures → 1 hour lockout

API

LoginGuard::new()

Creates a LoginGuard with default values (5 attempts / 300 s).

.max_attempts(max: u32)

Number of failures before account lockout.

.lockout_secs(secs: u64)

Lockout duration in seconds.

LoginGuard::effective_key(username, ip) -> Cow<str>

Returns the key to use based on context:

  • Non-empty username → key by username (targeted account protection)
  • Empty or missing username → "anonym:{ip}" (anonymous protection per IP)

Guarantees that two different anonymous IPs have independent counters — locking "anonym:1.2.3.4" does not affect "anonym:5.6.7.8".

Why not a global "anonym" key? A single abusive attempt would lock out every anonymous user worldwide. The per-IP key isolates each attacker.

record_failure(key)

Increments the failure counter for this key. Call after each failed authentication attempt.

record_success(key)

Resets the counter. Call after a successful login.

is_locked(key) -> bool

Returns true if the failure count exceeds max_attempts and the lockout duration has not elapsed. An expired entry is removed when accessed. Periodic cleanup removes entries that are no longer accessed.

attempts(key) -> u32

Current failure count for this key.

remaining_lockout_secs(key) -> Option<u64>

Seconds remaining until unlock. None if not locked.

Periodic cleanup

The admin builder automatically starts cleanup for its configured LoginGuard. For standalone use, call spawn_cleanup once while a Tokio runtime is active; the effective period is clamped between 1 and 60 seconds. This purge limits retention of expired entries but does not impose a strict entry-count cap.


Combining with IP Rate Limiting

Both mechanisms cover different attack vectors and are complementary:

RateLimiterLoginGuard
KeyIP addressUsername or anonym:{ip}
WhereMiddleware (automatic)Handler (manual)
TargetAll routesLogin only
Protects againstVolume attack per IPBrute-force per account
False positivesPossible (NAT)None (per account)
static IP_LIMITER: LazyLock<RateLimiter> = LazyLock::new(|| RateLimiter::new().max_requests(10).retry_after(60));
static GUARD:      LazyLock<LoginGuard>  = LazyLock::new(|| LoginGuard::new().max_attempts(5).lockout_secs(300));

// In the sign-in handler above, before authenticating:

// 1. Volume per IP → RateLimiter (as middleware on the route, or by hand)
if !IP_LIMITER.is_allowed(&ip) { /* 429 */ }

// 2. Brute force per account or anonymous IP → LoginGuard
let key = LoginGuard::effective_key(&username, &ip);
if GUARD.is_locked(&key) { /* 429 */ }

// 3. Authentication: authenticate_user, then record_success / record_failure

Known limit: an account can be locked by someone else

The lock is keyed by username, not by IP: that's what stops one attacker spreading guesses over many addresses. The flip side: anyone who knows a username (an admin's, for instance) can lock that account by failing on purpose, without knowing its password, and keep it locked by retrying.

To reduce it:

  • keep the IP rate limiter in front (section above): it caps how many attempts one address can make, so locking accounts in a loop costs the attacker much more;
  • block abusive addresses before the app, at the reverse proxy;
  • choose admin usernames that aren't public (not admin, not the name shown on the site).

The counters live in the memory of each instance: see Rate limiting — several instances.


Current limit: no memory cap

LoginGuard keeps one entry per username typed, including names that don't exist (otherwise the lockout would reveal which accounts exist), with no maximum number of entries. Made-up names sent in bulk therefore grow the memory until they expire. The reasons, and what to do until the cap is handled (a reverse proxy that rate-limits requests, a per-IP limiter in front): see Rate limiting — current limit.


← Protection Middlewares | Complete Example →