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.