towerops/docs/plans/2026-01-31-hibp-password-breach-checking-design.md

12 KiB

Have I Been Pwned Password Breach Checking

Date: 2026-01-31 Status: Approved

Overview

Integrate Have I Been Pwned (HIBP) API to check user passwords against known data breaches. Provides real-time warnings when users enter compromised passwords during registration, password reset, and password changes.

Requirements

  • Check passwords against HIBP Pwned Passwords API
  • Trigger check when user leaves password field (blur event)
  • Show warning if password found in breaches, but allow user to proceed
  • Apply to all password entry points: registration, reset, settings
  • Backend-only implementation (no frontend API calls)
  • Privacy-preserving k-anonymity approach
  • Fail open on API errors (never block users due to API issues)

Architecture

Have I Been Pwned k-Anonymity API

The HIBP Passwords API uses k-anonymity to protect user privacy:

  1. Hash the password with SHA-1
  2. Send only the first 5 characters of the hash to the API
  3. API returns all hash suffixes that match that prefix (with breach counts)
  4. Check locally if the full hash appears in the results

Example flow:

Password: "password123"
SHA-1 hash: "482C811DA5D5B4BC6D497FFA98491E38"
Send to API: "482C8" (first 5 chars)
Receive: List of hash suffixes + breach counts
Check if "11DA5D5B4BC6D497FFA98491E38" is in results
Result: Found with count = 123,456

API Endpoint: https://api.pwnedpasswords.com/range/{hash-prefix}

Response format:

11DA5D5B4BC6D497FFA98491E38:123456
21AB3C4D5E6F7890ABCDEF123456:789
...

LiveView Integration

On blur event flow:

  1. User types password in field and tabs away (blur event)
  2. LiveView sends phx-blur="check_password_breach" event to server
  3. Server extracts password from params
  4. Server calls HIBP.check_password/1
  5. HIBP module hashes password, calls API, parses response
  6. Server updates socket assigns with password_breach_count
  7. LiveView re-renders, warning banner appears if count > 0
  8. User can still submit form (warning only, not blocking)

Where it applies:

  • User registration (convert controller to LiveView)
  • Password reset (convert controller to LiveView)
  • User settings password change (already LiveView)

Technical Implementation

New Module: lib/towerops/accounts/hibp.ex

defmodule Towerops.Accounts.HIBP do
  @moduledoc """
  Have I Been Pwned password breach checking using k-anonymity API.

  Uses the HIBP Pwned Passwords API v3 to check if a password appears
  in known data breaches. Implements k-anonymity by only sending the
  first 5 characters of the SHA-1 hash to the API.

  See: https://haveibeenpwned.com/API/v3#PwnedPasswords
  """

  require Logger

  @api_url "https://api.pwnedpasswords.com/range/"
  @timeout 5_000  # 5 second timeout

  @doc """
  Checks if a password appears in known data breaches.

  Returns:
  - `{:ok, 0}` - Password not found in breaches
  - `{:ok, count}` - Password found in `count` breaches
  - `{:ok, :unknown}` - API error, status unknown (fail open)

  ## Examples

      iex> check_password("correct-horse-battery-staple")
      {:ok, 0}

      iex> check_password("password123")
      {:ok, 123456}
  """
  def check_password(password) when is_binary(password) and byte_size(password) > 0 do
    hash = hash_password(password)
    prefix = String.slice(hash, 0, 5)
    suffix = String.slice(hash, 5..-1)

    case fetch_breaches(prefix) do
      {:ok, results} -> check_suffix(results, suffix)
      {:error, reason} ->
        Logger.warning("HIBP API error: #{inspect(reason)}")
        {:ok, :unknown}  # Fail open on API errors
    end
  end

  def check_password(_), do: {:ok, :unknown}

  # Hash password to SHA-1 (HIBP uses SHA-1, not for security but for API compatibility)
  defp hash_password(password) do
    :crypto.hash(:sha, password)
    |> Base.encode16()
    |> String.upcase()
  end

  # Fetch breaches from HIBP API
  defp fetch_breaches(prefix) do
    url = @api_url <> prefix

    case Req.get(url, receive_timeout: @timeout) do
      {:ok, %Req.Response{status: 200, body: body}} ->
        {:ok, body}

      {:ok, %Req.Response{status: 429}} ->
        {:error, :rate_limited}

      {:ok, %Req.Response{status: status}} ->
        {:error, {:http_error, status}}

      {:error, reason} ->
        {:error, reason}
    end
  end

  # Parse API response and check if suffix exists
  defp check_suffix(results, suffix) do
    results
    |> String.split("\r\n", trim: true)
    |> Enum.find_value({:ok, 0}, fn line ->
      case String.split(line, ":") do
        [hash_suffix, count] when hash_suffix == suffix ->
          {:ok, String.to_integer(count)}
        _ ->
          nil
      end
    end)
  end
end

Schema Changes: lib/towerops/accounts/user.ex

Add virtual field to store breach count:

schema "users" do
  # ... existing fields ...
  field :password_breach_count, :integer, virtual: true
end

Changeset Integration

The breach check will be called in LiveView event handlers, not in the changeset itself. The changeset remains unchanged - breach status is stored in socket assigns, not in the changeset.

Rationale: Keeping the check outside the changeset allows us to:

  • Control when the API is called (only on blur, not on every keystroke)
  • Handle async API calls without blocking changeset validation
  • Show warnings without failing validation

LiveView Conversion

New LiveView: lib/towerops_web/live/user_registration_live.ex

Convert existing controller to LiveView:

defmodule ToweropsWeb.UserRegistrationLive do
  use ToweropsWeb, :live_view

  alias Towerops.Accounts
  alias Towerops.Accounts.HIBP

  def mount(params, _session, socket) do
    invitation_token = params["invitation_token"] || params["token"]
    invitation = invitation_token && Organizations.get_invitation_by_token(invitation_token)

    changeset = Accounts.change_user_registration(%User{})

    {:ok,
     socket
     |> assign(:form, to_form(changeset))
     |> assign(:invitation, invitation)
     |> assign(:invitation_token, invitation_token)
     |> assign(:password_breach_count, nil)}
  end

  def handle_event("check_password_breach", %{"user" => %{"password" => password}}, socket) do
    case HIBP.check_password(password) do
      {:ok, count} when is_integer(count) ->
        {:noreply, assign(socket, :password_breach_count, count)}
      {:ok, :unknown} ->
        {:noreply, assign(socket, :password_breach_count, nil)}
    end
  end

  def handle_event("validate", %{"user" => user_params}, socket) do
    changeset =
      %User{}
      |> Accounts.change_user_registration(user_params)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, :form, to_form(changeset))}
  end

  def handle_event("save", %{"user" => user_params}, socket) do
    # Registration logic (with/without invitation)
  end
end

New LiveView: lib/towerops_web/live/user_reset_password_live.ex

Similar conversion for password reset controller.

Update existing: lib/towerops_web/live/user_settings_live.ex

Add breach checking to existing password change functionality.

Template Changes

Add phx-blur event to password input and warning banner:

<.input
  field={@form[:password]}
  type="password"
  label="Password"
  phx-blur="check_password_breach"
  required
/>

<%= if @password_breach_count && @password_breach_count > 0 do %>
  <div class="mt-2 rounded-md bg-yellow-50 p-4 dark:bg-yellow-900/20">
    <div class="flex">
      <div class="flex-shrink-0">
        <.icon name="hero-exclamation-triangle" class="h-5 w-5 text-yellow-400" />
      </div>
      <div class="ml-3">
        <h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
          Password found in data breaches
        </h3>
        <div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
          <p>
            This password has appeared <%= @password_breach_count %>
            <%= if @password_breach_count == 1, do: "time", else: "times" %>
            in known data breaches. We strongly recommend choosing a different,
            more unique password to protect your account.
          </p>
        </div>
      </div>
    </div>
  </div>
<% end %>

Router Changes

Update routes in lib/towerops_web/router.ex:

# Old (controllers)
get "/users/register", UserRegistrationController, :new
post "/users/register", UserRegistrationController, :create
get "/users/reset_password/:token", UserResetPasswordController, :edit
put "/users/reset_password/:token", UserResetPasswordController, :update

# New (LiveView)
live "/users/register", UserRegistrationLive, :new
live "/users/reset_password/:token", UserResetPasswordLive, :edit

User settings is already LiveView, no route changes needed.

Error Handling

Fail Open Philosophy: API failures should never block users from registering or changing passwords.

Error scenarios:

  • API timeout (> 5 seconds) → Return {:ok, :unknown}, no warning shown
  • Network error → Return {:ok, :unknown}, no warning shown
  • Rate limiting (429) → Return {:ok, :unknown}, no warning shown
  • Malformed API response → Return {:ok, :unknown}, no warning shown
  • HIBP service down (500/503) → Return {:ok, :unknown}, no warning shown

Logging: All API errors logged at :warning level for monitoring, but never exposed to user.

Edge Cases

Scenario Behavior
Empty password on blur Don't call API, wait for form validation
Very long password SHA-1 handles any length, no special handling
Unicode/special characters SHA-1 works on binary, handles all UTF-8
API returns malformed data Log error, fail open, allow password
Rapid blur events Each triggers separate API call (acceptable)
User changes password after warning Next blur clears old warning, checks new password
Password field cleared after blur Warning remains until next blur or form submit

Privacy & Security

Privacy Considerations:

  • Password never sent to HIBP in plaintext
  • Only first 5 characters of SHA-1 hash sent (k-anonymity)
  • No logging of actual passwords or full hashes in application logs
  • API calls don't include user identification (no cookies, no IP tracking)

Security Notes:

  • SHA-1 used for HIBP compatibility (not for password storage)
  • Passwords still hashed with Argon2 for database storage
  • Breach check is advisory only, doesn't prevent weak passwords meeting length requirements
  • Consider adding additional password strength requirements in future

Testing Strategy

Unit Tests: test/towerops/accounts/hibp_test.exs

defmodule Towerops.Accounts.HIBPTest do
  use ExUnit.Case, async: true

  describe "check_password/1" do
    test "returns breach count for compromised password"
    test "returns 0 for clean password"
    test "handles API timeout gracefully"
    test "handles network errors gracefully"
    test "handles rate limiting gracefully"
    test "handles malformed API response"
    test "only sends 5-character hash prefix"
    test "handles unicode passwords"
  end
end

Use Mox to mock Req HTTP calls.

LiveView Tests

test "shows warning when password is breached"
test "hides warning when password is clean"
test "clears warning when password changed"
test "form submits even with breached password"
test "API failure doesn't block registration"
test "blur event triggers breach check"

Integration Tests

  • Test complete registration flow with breached password
  • Test password reset with breached password
  • Test password change in settings with breached password
  • Verify all warnings appear correctly
  • Verify form submission works in all cases

Implementation Tasks

  1. Create HIBP module with k-anonymity implementation
  2. Add virtual field to User schema
  3. Convert UserRegistrationController to LiveView
  4. Convert UserResetPasswordController to LiveView
  5. Update UserSettingsLive with breach checking
  6. Update templates with blur events and warning banners
  7. Update routes
  8. Write comprehensive tests
  9. Manual testing with known breached passwords

Future Enhancements

  • Add password strength meter (zxcvbn library)
  • Require minimum password entropy score
  • Track breach check metrics (how often users hit warnings)
  • Cache API responses temporarily (5-minute TTL)
  • Add "Show password" toggle for better UX

References