HIGH password sprayingaxumbasic auth

Password Spraying in Axum with Basic Auth

Password Spraying in Axum with Basic Auth — how this specific combination creates or exposes the vulnerability

Password spraying is an authentication attack technique that attempts a small number of common or compromised passwords across many accounts to avoid account lockouts. When an Axum service exposes a login endpoint protected only by HTTP Basic Auth and lacks effective rate limiting or other anti-automation controls, this technique becomes practical.

Basic Auth sends credentials in an Authorization header encoded as Base64, not encrypted. If the transport is not protected by TLS, credentials are exposed in transit. Even with TLS, the server must enforce strict protections; otherwise, an attacker can repeatedly submit different usernames with a single password (e.g., Password123) against multiple user identities discovered via user enumeration or predictable identifiers.

In Axum, a handler that validates credentials inline without leveraging a hardened authentication framework can inadvertently enable spraying. For example, if the handler performs a simple comparison without constant-time checks and does not track failed attempts per user or IP, automated tools can iterate through passwords while staying under simple threshold-based lockout mechanisms. The combination of predictable usernames (e.g., derived from email or user IDs), permissive request rates, and informative responses (such as distinguishing between "user not found" and "invalid password") gives an attacker actionable feedback to refine their spray.

Consider an endpoint that decodes Basic Auth and queries a user store directly. If the authentication logic uses a non-constant-time comparison and the service responds with slightly different messages depending on whether the user exists, an attacker learns which usernames are valid. They can then spray weak passwords like "Password1", "Welcome1", or known breached passwords against those accounts. MiddleBrick’s LLM/AI Security checks include active prompt injection testing and system prompt leakage detection, but for API authentication, continuous monitoring via the Pro plan helps detect abnormal authentication patterns that may indicate an ongoing spray.

Without proper mitigation, password spraying against Basic Auth in Axum can lead to compromised accounts, lateral movement, and data exposure. Remediation should focus on making sprayed credentials ineffective through multi-factor authentication, adaptive rate limiting, per-user attempt tracking, and ensuring responses do not disclose user existence.

Basic Auth-Specific Remediation in Axum — concrete code fixes

To reduce the risk of password spraying, Axum handlers should avoid leaking user existence, enforce rate limits per user and IP, and use constant-time comparison for credentials. The following examples assume you store user credentials securely (e.g., hashed passwords with a strong KDF such as Argon2id).

1. Always use TLS. Basic Auth over HTTP is unsafe regardless of other protections.

2. Return uniform responses for missing users and invalid credentials to prevent user enumeration.

3. Track failed attempts per user and IP, and introduce incremental delays or lockouts after thresholds.

4. Use constant-time comparison to avoid timing leaks.

use axum::{
    async_trait, body::Body, extract::Request, http::header, response::Response, routing::post,
    Extension, Json, Router,
};
use base64::Engine;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use subtle::ConstantTimeEq;
use tokio::time::{sleep, Duration};

// Simple in-memory attempt tracking (replace with Redis or another durable store in production)
type AttemptStore = Arc>>; // (username, ip) -> count

async fn authenticate(
    Extension(store): Extension,
    request: Request,
) -> Result {
    let auth_header = request
        .headers()
        .get(header::AUTHORIZATION)
        .ok_or((axum::http::StatusCode::UNAUTHORIZED, "invalid credentials"))?;

    let auth_str = auth_header
        .to_str()
        .map_err(|_| (axum::http::StatusCode::UNAUTHORIZED, "invalid credentials"))?
        .strip_prefix("Basic ")
        .ok_or((axum::http::StatusCode::UNAUTHORIZED, "invalid credentials"))?;

    let decoded = base64::engine::general_purpose::STANDARD
        .decode(auth_str)
        .map_err(|_| (axum::http::StatusCode::UNAUTHORIZED, "invalid credentials"))?;

    let parts: Vec<_> = decoded.splitn(2, |&b| b == b':').collect();
    if parts.len() != 2 {
        return Err((axum::http::StatusCode::UNAUTHORIZED, "invalid credentials"));
    }
    let (username, password) = (parts[0], parts[1]);
    let username = String::from_utf8_lossy(username).into_owned();
    let ip = request
        .extensions()
        .get::()
        .cloned()
        .unwrap_or_else(|| "unknown".to_string());

    // Rate limiting per (username, IP)
    let mut store = store.lock().unwrap();
    let key = (username.clone(), ip.clone());
    let attempts = store.entry(key.clone()).or_insert(0);
    if *attempts >= 5 {
        sleep(Duration::from_secs(2)).await; // progressive delay
    }

    // Dummy user store lookup (replace with real user retrieval and Argon2id verification)
    let stored_hash = match get_user_hash(&username).await {
        Some(hash) => hash,
        None => {
            // Still count the attempt and return generic error to avoid user enumeration
            *attempts += 1;
            sleep(Duration::from_millis(500)).await;
            return Err((axum::http::StatusCode::UNAUTHORIZED, "invalid credentials"));
        }
    };

    // Constant-time password verification
    if !verify_password_stored(stored_hash, password).await {
        *attempts += 1;
        sleep(Duration::from_millis(500)).await;
        return Err((axum::http::StatusCode::UNAUTHORIZED, "invalid credentials"));
    }

    // Reset on success
    *attempts = 0;
    Ok(Json(serde_json::json!({ "status": "ok" })))
}

async fn verify_password_stored(stored_hash: Vec<u8>, password: &[u8]) -> bool {
    // Example using subtle::ConstantTimeEq for comparison after deriving a key
    // Replace with proper Argon2id/Scrypt/PBKDF2 verification in real code
    let expected = &stored_hash[..];
    let input_derived = pbkdf2_hmac_sha256(password, &stored_hash[..16]); // simplified
    input_derived.ct_eq(expected).into()
}

async fn get_user_hash(_username: &str) -> Option<Vec<u8>> {
    // Replace with database lookup returning a stored hash
    Some(vec![0u8; 32])
}

fn pbkdf2_hmac_sha256(password: &[u8], salt: &[u8]) -> Vec<u8> {
    use hmac::{Hmac, Mac};
    use sha2::Sha256;
    type HmacSha256 = Hmac<Sha256>;
    let mut mac = HmacSha256::new_from_slice(salt).unwrap();
    mac.update(password);
    mac.finalize().into_bytes().to_vec()
}

#[tokio::main]
async fn main() {
    let store: AttemptStore = Arc::new(Mutex::new(HashMap::new()));
    let app = Router::new()
        .route("/login", post(login))
        .layer(Extension(store));
    // axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()).serve(app.into_make_service()).await.unwrap();
}

This approach ensures that attackers gain no useful feedback from repeated requests, limits the effectiveness of spraying, and aligns with secure authentication practices.

Frequently Asked Questions

Why does Basic Auth over HTTP make password spraying easier to exploit?
Because Basic Auth encodes credentials in reversible Base64, sending them in the clear if TLS is not used. Without TLS, any network observer can harvest usernames and passwords, enabling offline spraying. Even with TLS, weak server-side protections allow attackers to iterate through passwords rapidly.
How does constant-time comparison help mitigate password spraying in Axum?
Constant-time comparison prevents timing side-channels that reveal whether a username exists or how much of a password is correct. By ensuring each authentication attempt takes roughly the same time regardless of input, attackers cannot infer valid usernames or partial credentials from response timings.