modlue# BreakGlassEx

A standalone, production-ready Elixir library that provides an in-process emergency access subsystem (break-glass authentication) for any Elixir or Phoenix application. When all normal admin accounts are locked out, a pre-configured break-glass credential can be used to regain access through a two-factor authentication flow (password → emailed OTP), with mandatory brute-force rate limiting, IP whitelisting, tamper-resistant audit logging, and out-of-band alerting.

The library is fully self-contained — it ships its own OTP supervision tree and has no Phoenix dependency.


Compatibility

ElixirOTP 26OTP 27
1.15
1.16
1.17
1.18+

Notes:

  • This library has no Phoenix dependency. It works in any Elixir/OTP application.
  • import Bitwise was deprecated in Elixir 1.16. The library uses use Bitwise throughout for clean compilation across all supported versions.

Quick Start

1. Add to mix.exs

def deps do
  [
    {:breakglass, "~> 0.1"}
  ]
end

2. Implement UserProvider

Create a module in your host application that implements the BreakGlass.UserProvider behaviour:

defmodule MyApp.BreakGlassUserProvider do
  @behaviour BreakGlass.UserProvider

  @impl true
  def build_user(attrs) do
    # attrs contains: :email, :sentinel_id, :authenticated_at, :break_glass (true)
    %MyApp.User{
      id:          attrs.sentinel_id,
      email:       attrs.email,
      break_glass: true
    }
  end
end

3. Configure config/runtime.exs

config :breakglass,
  # Required
  email:         System.fetch_env!("BREAK_GLASS_EMAIL"),
  password_hash: System.fetch_env!("BREAK_GLASS_PASSWORD_HASH"),
  user_provider: MyApp.BreakGlassUserProvider,

  # IP allowlist (exact IPs or CIDR ranges)
  allowed_ips: ~w[10.0.0.0/8 192.168.1.0/24 127.0.0.1],

  # Rate limiting
  max_attempts:    5,
  lockout_seconds: 900,

  # Email alerting (uses your existing Swoosh mailer)
  mailer:       MyApp.Mailer,
  from_email:   "noreply@example.com",
  alert_emails: ["security@example.com", "ops@example.com"],

  # Webhook alerting
  alert_webhook_url: System.get_env("BREAK_GLASS_WEBHOOK_URL"),

  # Development: log OTP at warning level (never use in production)
  dev_otp_log: false

Generate the bcrypt hash for your password using the provided Mix task:

mix break_glass.gen_hash "my_secure_password"
# => $2b$12$...

Store the output in the BREAK_GLASS_PASSWORD_HASH environment variable or your secrets manager. Never store the plaintext password in source control.

4. Add BreakGlass.Supervisor to your supervision tree

In your Application.start/2 callback, add the supervisor before your endpoint or router:

children = [
  MyApp.Repo,
  {BreakGlass.Supervisor, []},
  MyAppWeb.Endpoint
]

Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)

5. Controller integration

Typical controller flow:

defmodule MyAppWeb.BreakGlassController do
  use MyAppWeb, :controller

  # Step 1: authenticate (password check)
  def authenticate(conn, %{"email" => email, "password" => password}) do
    ip = conn.remote_ip |> :inet.ntoa() |> to_string()

    case BreakGlass.authenticate(email, password, ip) do
      {:ok, :otp_required} ->
        redirect(conn, to: ~p"/break-glass/otp")

      {:error, :rate_limited} ->
        conn
        |> put_flash(:error, "Too many attempts. Try again later.")
        |> redirect(to: ~p"/break-glass")

      {:error, :ip_not_allowed} ->
        conn
        |> put_flash(:error, "Access denied.")
        |> redirect(to: ~p"/break-glass")

      :error ->
        conn
        |> put_flash(:error, "Invalid credentials.")
        |> redirect(to: ~p"/break-glass")
    end
  end

  # Step 2: verify OTP
  def verify_otp(conn, %{"code" => code}) do
    ip = conn.remote_ip |> :inet.ntoa() |> to_string()

    case BreakGlass.verify_otp(code, ip) do
      {:ok, user} ->
        # Log the user in using your existing session mechanism
        conn
        |> MyApp.UserAuth.log_in_user(user)
        |> redirect(to: ~p"/admin/dashboard")

      {:error, :invalid_otp} ->
        conn
        |> put_flash(:error, "Invalid or expired OTP.")
        |> redirect(to: ~p"/break-glass/otp")
    end
  end
end

Always use conn.remote_ip — never derive the IP from x-forwarded-for or any proxy header. See Security Notes below.


Configuration Reference

All keys live under config :breakglass.

KeyTypeRequiredDefaultDescription
:emailString.t()YesBreak-glass email address
:password_hashString.t()YesBcrypt hash of the break-glass password
:user_providermodule()YesModule implementing BreakGlass.UserProvider
:sentinel_idinteger()No0ID used for the synthetic user struct; should be a value that cannot match a real database primary key
:allowed_ips[String.t()]No["127.0.0.1", "::1"]Exact IPs or CIDR ranges allowed to authenticate; if absent a Logger.warning is emitted at startup
:max_attemptspos_integer()No5Failed attempts before lockout
:lockout_secondspos_integer()No900Lockout window duration in seconds (15 minutes)
:mailermodule()NoSwoosh mailer module (required for email OTP delivery and alert emails)
:from_emailString.t()NoSender address for outbound emails
:alert_emails[String.t()]No[]Recipients for break-glass alert emails sent on every successful login
:alert_webhook_urlString.t() | nilNonilURL to POST a JSON alert payload on every successful login
:dev_otp_logboolean()NofalseLog OTP at Logger.warning level in development (never enable in production)

Security Notes

Physical IP requirement

Always pass conn.remote_ip (the TCP-level transport IP) to BreakGlass.authenticate/3 and BreakGlass.verify_otp/2:

ip = conn.remote_ip |> :inet.ntoa() |> to_string()

x-forwarded-for WARNING

Never derive the IP address from the x-forwarded-for, x-real-ip, or any other proxy header. These headers are trivially spoofable by any client and completely defeat the IP whitelist. The library cannot enforce this — it is a documented contract with the host controller.

Password hash storage

  1. Generate a bcrypt hash: mix break_glass.gen_hash "your_password"
  2. Store the printed hash in an environment variable or secrets manager (e.g. AWS Secrets Manager, Vault)
  3. Reference it in runtime.exs via System.fetch_env!/1
  4. Never commit the plaintext password to source control

Lockout defaults rationale

The defaults of 5 attempts / 900-second lockout are intentionally conservative:

  • 5 attempts: enough to accommodate genuine typos while blocking automated credential stuffing
  • 900 seconds (15 minutes): long enough to add significant friction to any attack while short enough to unblock a legitimate operator

Adjust :max_attempts and :lockout_seconds in your configuration to match your security policy.


Changelog

0.1.0

  • Initial release
  • Two-factor authentication flow: password → emailed OTP
  • Per-IP rate limiting with configurable thresholds and lockout window
  • CIDR-aware IP whitelist supporting IPv4 and IPv6
  • In-memory ETS-backed OTP store (single-use, 600-second TTL)
  • In-memory ETS-backed token store (invalidated on node restart)
  • Out-of-band alerting via Swoosh email and/or HTTP webhook
  • BreakGlass.UserProvider behaviour for host-app integration
  • BreakGlass.DefaultUserProvider for zero-config test use
  • Structured Logger.warning on every successful break-glass login
  • mix break_glass.gen_hash Mix task for safe credential generation
  • Full ExDoc documentation