From elixir-phoenix-guide
Guards against common security vulnerabilities in Elixir/Phoenix: atom exhaustion, SQL injection, XSS, timing attacks, and unsafe redirects. Includes dependency audit commands.
How this skill is triggered — by the user, by Claude, or both
Slash command
/elixir-phoenix-guide:security-essentialsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
1. **Never use `String.to_atom/1` on user input** — atoms are never garbage collected; user-controlled atoms exhaust the atom table and crash the BEAM VM. `String.to_existing_atom/1` avoids that risk but still raises `ArgumentError` on unknown input (unhandled 500) — whitelist with a `case` instead of either
String.to_atom/1 on user input — atoms are never garbage collected; user-controlled atoms exhaust the atom table and crash the BEAM VM. String.to_existing_atom/1 avoids that risk but still raises ArgumentError on unknown input (unhandled 500) — whitelist with a case instead of eitherfragment() or SQL.query() — always use ? parameters for fragments and $1 for raw SQL~p"...")raw/1 in templates — Phoenix auto-escapes for a reason; if HTML is required, sanitize first with a library like HtmlSanitizeExPlug.Crypto.secure_compare/2 for token comparison — never ==, which enables timing attacksmix hex.audit checks for retired/deprecated packages (checksum verification against Hex is already automatic via mix.lock); mix deps.audit (requires adding {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}) checks for known CVEs; mix sobelow does static security analysis of your codeThe BEAM atom table has a fixed limit (default ~1M atoms) and is never garbage collected. If an attacker can create arbitrary atoms, they crash the entire VM.
Bad:
# User controls the atom — creates atoms forever: atom table exhaustion, VM crash
role = String.to_atom(params["role"])
# Never creates atoms, but raises ArgumentError on unknown input (unhandled 500)
status = String.to_existing_atom(params["status"])
Good:
# Whitelist approach — only known values become atoms
case params["role"] do
"admin" -> :admin
"user" -> :user
"moderator" -> :moderator
_ -> {:error, :invalid_role}
end
# Or keep as strings throughout
def authorize(%{"role" => "admin"}), do: :ok
def authorize(%{"role" => _}), do: {:error, :unauthorized}
Ecto's query DSL is safe by default. Danger arises with fragment/1 and Ecto.Adapters.SQL.query/3.
Bad:
# String interpolation in fragment — SQL injection
from(u in User, where: fragment("lower(#{field}) = ?", ^value))
# String interpolation in raw SQL
Ecto.Adapters.SQL.query(Repo, "SELECT * FROM users WHERE id = #{id}")
# String concatenation in queries
query = "SELECT * FROM users WHERE name = '" <> name <> "'"
Good:
# Parameterized fragment — safe
from(u in User, where: fragment("lower(?) = ?", field(u, ^field_name), ^value))
# Parameterized raw SQL — safe
Ecto.Adapters.SQL.query(Repo, "SELECT * FROM users WHERE id = $1", [id])
# Ecto query DSL — always safe
from(u in User, where: u.name == ^name) |> Repo.one()
Redirecting to a user-supplied URL lets attackers craft phishing links that appear to come from your domain.
Bad:
# User controls redirect destination
def create(conn, %{"redirect_to" => redirect_to} = params) do
# ... create resource ...
redirect(conn, to: redirect_to)
end
Good:
# Use verified routes
redirect(conn, to: ~p"/dashboard")
# Or validate against known paths
@allowed_redirects ["/dashboard", "/profile", "/settings"]
def create(conn, %{"redirect_to" => redirect_to} = params) do
# ... create resource ...
if redirect_to in @allowed_redirects do
redirect(conn, to: redirect_to)
else
redirect(conn, to: ~p"/dashboard")
end
end
# Phoenix's built-in approach for auth redirects
defp maybe_store_return_to(conn) do
# Only store relative paths
return_to = conn.request_path
if String.starts_with?(return_to, "/") and not String.starts_with?(return_to, "//") do
put_session(conn, :user_return_to, return_to)
else
conn
end
end
Phoenix auto-escapes all template output by default. Using raw/1 bypasses this protection.
Bad:
# In HEEx template — bypasses escaping
<%= raw(@user_bio) %>
<%= Phoenix.HTML.raw(@comment_body) %>
Good:
# Let Phoenix auto-escape (default behavior)
<%= @user_bio %>
# If HTML rendering is required, sanitize first
<%= raw(HtmlSanitizeEx.html5(@user_bio)) %>
# Or use Phoenix.HTML.Format for simple formatting
<%= text_to_html(@user_bio) %>
Phoenix's built-in protections (already active):
<%= %> output is HTML-escaped<.form> handles this)Logs are stored in plaintext, shipped to third-party services, and often retained for months. Never log secrets.
Bad:
Logger.info("User login", email: email, password: password)
Logger.debug("API call", token: api_token, response: resp)
Logger.error("Auth failed", credentials: credentials, secret: secret)
Good:
Logger.info("User login", email: email, user_id: user.id)
Logger.debug("API call", endpoint: url, status: resp.status)
Logger.error("Auth failed", user_id: user_id, reason: :invalid_credentials)
# Use Logger metadata for request correlation (no secrets)
Logger.metadata(request_id: conn.assigns[:request_id])
Standard == comparison short-circuits on the first different byte, leaking information about the secret through response timing.
Bad:
# Timing-unsafe — leaks token value byte by byte
def verify_token(provided_token, stored_token) do
provided_token == stored_token
end
# Also bad — pattern match is also timing-unsafe
def verify(%{token: token}, token), do: :ok
Good:
# Constant-time comparison — same duration regardless of input
def verify_token(provided_token, stored_token) do
Plug.Crypto.secure_compare(provided_token, stored_token)
end
# Phoenix already uses this for CSRF and session tokens
# Apply the same principle to your own token comparisons
Run these commands after adding or updating dependencies:
# Check for known CVEs in dependencies (requires mix_audit — see below)
mix deps.audit
# Check for retired/deprecated packages
# (checksum verification against Hex is automatic via mix.lock — no separate step needed)
mix hex.audit
# Static security analysis of your code
mix sobelow
# All three in sequence
mix deps.audit && mix hex.audit && mix sobelow
mix deps.audit requires adding the dependency:
# mix.exs
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}
Add to CI pipeline:
# In mix.exs aliases
defp aliases do
[
"security.check": ["deps.audit", "hex.audit", "sobelow --config"]
]
end
Phoenix includes CSRF protection by default. Don't disable it.
# Phoenix forms automatically include CSRF tokens
# <.form> component handles this — never use raw <form> tags
# If building a JSON API, CSRF is handled differently:
# API pipeline in router.ex should NOT include :protect_from_forgery
pipeline :api do
plug :accepts, ["json"]
# No :protect_from_forgery — APIs use Bearer tokens instead
end
See elixir-essentials skill for general Elixir patterns.
See phoenix-authorization-patterns skill for access control patterns.
See telemetry-essentials skill for secure logging practices.
npx claudepluginhub j-morgan6/elixir-phoenix-guide --plugin elixir-phoenix-guideEnforces Elixir/Phoenix security patterns for auth, OAuth, sessions, CSRF, XSS, SQL injection, input validation, and secrets. Activates when editing auth files, login flows, RBAC, or API keys.
Reviews Elixir code for security vulnerabilities like code injection, atom exhaustion, secrets in config, ETS exposure, and process dictionary risks. Use for code handling user input, external data, or sensitive config.
Covers application security best practices including input validation, SQL injection prevention, CSRF protection, and Content Security Policy. Provides code examples in TypeScript and Python.