Security essentials
Skill j-morgan6/elixir-phoenix-guide/skills/security-essentials
Use when code touches user input, tokens, redirects, raw SQL, or logging — injection, XSS, atom exhaustion, timing attacks, audit tooling.From its SKILL.md
npx -y skills add j-morgan6/elixir-phoenix-guide --skill security-essentialsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
SKILL.md
7.7 KB, as published. Nobody here has run it
Security Essentials
RULES — Follow these with no exceptions
- Never use
String.to_atom/1on user input — atoms are never garbage collected; user-controlled atoms exhaust the atom table and crash the BEAM VM.String.to_existing_atom/1avoids that risk but still raisesArgumentErroron unknown input (unhandled 500) — whitelist with acaseinstead of either - Never interpolate strings into
fragment()orSQL.query()— always use?parameters for fragments and$1for raw SQL - Never redirect to user-controlled URLs — validate against a whitelist or use verified routes (
~p"...") - Avoid
raw/1in templates — Phoenix auto-escapes for a reason; if HTML is required, sanitize first with a library like HtmlSanitizeEx - Never log sensitive data — passwords, tokens, secrets, API keys, and credentials must never appear in Logger calls
- Use
Plug.Crypto.secure_compare/2for token comparison — never==, which enables timing attacks - Run dependency audits after changes —
mix hex.auditchecks for retired/deprecated packages (checksum verification against Hex is already automatic viamix.lock);mix deps.audit(requires adding{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}) checks for known CVEs;mix sobelowdoes static security analysis of your code
Atom Table Exhaustion
The 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}
SQL Injection
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()
Open Redirects
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
Cross-Site Scripting (XSS)
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):
- All
<%= %>output is HTML-escaped - CSRF tokens in forms (
<.form>handles this) - Content Security Policy headers (add in your endpoint or Plug pipeline)
Sensitive Data in Logs
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])
Timing Attacks
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
Dependency Auditing
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
CSRF Protection
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.