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:


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:

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


Changelog

0.1.0