Skip to content
Finowl

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. 1

    Add reqwest, serde and tokio to Cargo.toml and paste the example into src/main.rs.

  2. 2

    Export FINOWL_API_KEY, run cargo run, and type a password followed by Enter.

  3. 3

    Call breach_count from your registration handler before hashing the password with argon2 or bcrypt.

  4. 4

    Map CheckError variants to user messages and to your fail-open or fail-closed policy.

Install
cargo add reqwest --features json
cargo add serde --features derive
cargo add serde_json thiserror
cargo add tokio --features full
Check breached passwords in Rust with reqwest
use 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

Check breached passwords in Rust with reqwest

Grab an API key, paste the example and run it. 100 free credits every month.