Skip to content
Finowl

Ruby · Password breach checks

Check passwords against breach data in Ruby

Checking a password from Ruby takes one request to /v1/breaches/password with the password in the JSON body. Finowl hashes it with SHA-1 and searches by a five-character hash prefix using a k-anonymity range search with padding, and the password is processed transiently: never logged, stored or returned. The response tells you whether it was found and how many times through the breached and count fields.

The example reads the password with IO#noecho so it never appears on screen, and its errors describe the problem without repeating the value. In a Rails app using has_secure_password, the same method fits a custom validate method on the user model that adds an error when the count is positive. Keep the call out of loops over existing users and check passwords only when someone sets or changes one.

Steps

  1. 1

    Export FINOWL_API_KEY in the environment where the script or app server runs.

  2. 2

    Save the script as password_check.rb and run it with ruby; the prompt hides what you type.

  3. 3

    Call password_breach_count from your sign-up or password-change flow before you hash and store the password.

  4. 4

    Show a clear message asking for a different password when the count is above zero.

  5. 5

    Decide whether a timeout or server error should accept the password or ask the user to try again.

Check passwords against breach data in Ruby
require "io/console"
require "json"
require "net/http"

PASSWORD_ENDPOINT = URI("https://api.finowl.co/v1/breaches/password")

# Returns how many times the password appears in breach data (0 when never seen).
def password_breach_count(password, attempts: 3)
  attempts.times do
    response = Net::HTTP.start(PASSWORD_ENDPOINT.host, PASSWORD_ENDPOINT.port, use_ssl: true,
                               open_timeout: 5, read_timeout: 10) do |http|
      request = Net::HTTP::Post.new(PASSWORD_ENDPOINT)
      request["Authorization"] = "Bearer #{ENV.fetch("FINOWL_API_KEY")}"
      request["Content-Type"] = "application/json"
      request.body = JSON.generate(password: password)
      http.request(request)
    end

    case response
    when Net::HTTPSuccess
      return JSON.parse(response.body).fetch("count")
    when Net::HTTPBadRequest
      # Never echo the password itself in errors or logs.
      raise ArgumentError, "Password is empty or longer than 4096 characters"
    when Net::HTTPUnauthorized
      raise "FINOWL_API_KEY is missing or invalid"
    when Net::HTTPTooManyRequests
      code = JSON.parse(response.body).dig("error", "code")
      raise "Finowl credits are used up" if code == "quota_exceeded"

      sleep [response["Retry-After"].to_i, 1].max
    else
      raise "Finowl returned HTTP #{response.code}; not retrying"
    end
  end
  raise "Still rate limited after #{attempts} attempts"
end

print "Choose a password: "
password = $stdin.noecho(&:gets).to_s.chomp
puts

count = password_breach_count(password)
if count.positive?
  puts "That password has been seen #{count} times in breach data. Pick another."
else
  puts "Not found in known breach data."
end

IN PRODUCTION

Production tips

  • Keep password in Rails.application.config.filter_parameters, which new Rails apps include by default, so request logs never contain it, and never interpolate the value into exception messages.

  • Check the plain password only when it is created or changed. Stored bcrypt digests cannot be checked later, so there is no backfill for existing accounts beyond prompting a password change.

  • Pair the breach check with a minimum length rule. NIST SP 800-63B recommends both, and the length check costs nothing, so reject short passwords before you spend a credit on them.

Each lookup costs 1 credit, with 100 free credits every month. See pricing and error codes.

A FEW GOOD QUESTIONS

Common questions

Check passwords against breach data in Ruby

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