Rust · Password breach checks
Check breached passwords in Rust with reqwest
The Rust password example borrows the password as &str and serializes it through a small PasswordQuery struct, so the value is never copied into a long-lived String or formatted into an error. Finowl hashes it with SHA-1, searches by a five-character prefix with k-anonymity and padding, and returns breached and count. The breach_count function returns Result<u64, CheckError>, which keeps the caller's handling explicit.
CheckError is a plain enum with TooLong, BadKey, Limited and Other variants, and the main loop matches on it with a guard that allows two rate-limit retries. Transport errors call without_url before converting to text, a habit worth keeping in any HTTP code. For production command-line tools, read the password with a crate such as rpassword so the terminal does not echo it.
Steps
- 1
Add reqwest, serde and tokio to Cargo.toml and paste the example into src/main.rs.
- 2
Export FINOWL_API_KEY, run cargo run, and type a password followed by Enter.
- 3
Call breach_count from your registration handler before hashing the password with argon2 or bcrypt.
- 4
Map CheckError variants to user messages and to your fail-open or fail-closed policy.
cargo add reqwest --features json
cargo add serde --features derive
cargo add serde_json thiserror
cargo add tokio --features fulluse reqwest::{header::RETRY_AFTER, Client, StatusCode};
use serde::{Deserialize, Serialize};
use std::{io, time::Duration};
#[derive(Serialize)]
struct PasswordQuery<'a> {
password: &'a str,
}
#[derive(Deserialize)]
struct PasswordResult {
breached: bool,
count: u64,
}
#[derive(Debug)]
enum CheckError { TooLong, BadKey, Limited(u64), Other(String) }
// Takes the password by reference and never formats it into an error or log line.
async fn breach_count(client: &Client, key: &str, password: &str) -> Result<u64, CheckError> {
let res = client
.post("https://api.finowl.co/v1/breaches/password")
.bearer_auth(key)
.json(&PasswordQuery { password })
.send()
.await
.map_err(|e| CheckError::Other(e.without_url().to_string()))?;
match res.status() {
StatusCode::OK => {
let r: PasswordResult = res.json().await.map_err(|e| CheckError::Other(e.to_string()))?;
Ok(if r.breached { r.count } else { 0 })
}
StatusCode::BAD_REQUEST => Err(CheckError::TooLong),
StatusCode::UNAUTHORIZED => Err(CheckError::BadKey),
StatusCode::TOO_MANY_REQUESTS => Err(CheckError::Limited(
res.headers().get(RETRY_AFTER).and_then(|v| v.to_str().ok()?.parse().ok()).unwrap_or(5),
)),
s => Err(CheckError::Other(format!("HTTP {s}"))),
}
}
#[tokio::main]
async fn main() {
let key = std::env::var("FINOWL_API_KEY").expect("set FINOWL_API_KEY");
let client = Client::builder().timeout(Duration::from_secs(10)).build().unwrap();
let mut password = String::new();
io::stdin().read_line(&mut password).unwrap();
let password = password.trim_end_matches(['\r', '\n']);
let mut attempts = 0;
loop {
match breach_count(&client, &key, password).await {
Ok(0) => break println!("Not found in known breach data."),
Ok(n) => break println!("Seen {n} times in breaches. Choose another password."),
Err(CheckError::Limited(secs)) if attempts < 2 => {
attempts += 1;
tokio::time::sleep(Duration::from_secs(secs)).await;
}
Err(CheckError::TooLong) => break eprintln!("Password is empty or over 4096 characters."),
Err(CheckError::BadKey) => break eprintln!("Finowl rejected FINOWL_API_KEY."),
Err(e) => break eprintln!("Password check unavailable: {e:?}"),
}
}
}IN PRODUCTION
Production tips
Consider wrapping passwords in secrecy::SecretString inside your app. Its Debug implementation redacts the value, protecting you from accidental {:?} formatting in tracing spans or panic messages.
If you add #[tracing::instrument] to breach_count, include skip(password). The attribute records function arguments by default and would otherwise write the plain password to your logs.
Run the check only when a password is created or changed. Existing hashes cannot be checked, so reach current users through password-change prompts instead of attempting a backfill.
Each lookup costs 1 credit, with 100 free credits every month. See pricing and error codes.
A FEW GOOD QUESTIONS
Common questions
Related guides
- Check email breaches in Rust with reqwest and serde
- Batch gender predictions in Rust with serde
- Check breached passwords in Python at signup
- Screen breached passwords in Node.js
- Check breached passwords in TypeScript
- Check breached passwords in Go before storing them
- Breached password checks for fintech accounts
- Breached password screening for e-commerce accounts
- Breached password checks for SaaS signup and resets
Check breached passwords in Rust with reqwest
Grab an API key, paste the example and run it. 100 free credits every month.