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
| Elixir | OTP 26 | OTP 27 |
|---|---|---|
| 1.15 | ✓ | — |
| 1.16 | ✓ | ✓ |
| 1.17 | ✓ | ✓ |
| 1.18+ | ✓ | ✓ |
Notes:
- This library has no Phoenix dependency. It works in any Elixir/OTP application.
import Bitwisewas deprecated in Elixir 1.16. The library usesuse Bitwisethroughout 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 fromx-forwarded-foror any proxy header. See Security Notes below.
Configuration Reference
All keys live under config :breakglass.
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
:email | String.t() | Yes | — | Break-glass email address |
:password_hash | String.t() | Yes | — | Bcrypt hash of the break-glass password |
:user_provider | module() | Yes | — | Module implementing BreakGlass.UserProvider |
:sentinel_id | integer() | No | 0 | ID 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_attempts | pos_integer() | No | 5 | Failed attempts before lockout |
:lockout_seconds | pos_integer() | No | 900 | Lockout window duration in seconds (15 minutes) |
:mailer | module() | No | — | Swoosh mailer module (required for email OTP delivery and alert emails) |
:from_email | String.t() | No | — | Sender address for outbound emails |
:alert_emails | [String.t()] | No | [] | Recipients for break-glass alert emails sent on every successful login |
:alert_webhook_url | String.t() | nil | No | nil | URL to POST a JSON alert payload on every successful login |
:dev_otp_log | boolean() | No | false | Log 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
- Generate a bcrypt hash:
mix break_glass.gen_hash "your_password" - Store the printed hash in an environment variable or secrets manager (e.g. AWS Secrets Manager, Vault)
- Reference it in
runtime.exsviaSystem.fetch_env!/1 - 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.UserProviderbehaviour for host-app integrationBreakGlass.DefaultUserProviderfor zero-config test use- Structured
Logger.warningon every successful break-glass login mix break_glass.gen_hashMix task for safe credential generation- Full ExDoc documentation