Elixir and Phoenix are safe by default in the ways that matter most: Ecto parameterizes queries, Phoenix.HTML escapes output, and CSRF protection is wired into forms. So Elixir breaches tend to come from two places — BEAM-specific footguns that no other ecosystem has, and the Erlang/OTP runtime the whole stack sits on. The footguns are :erlang.binary_to_term, which will reconstruct arbitrary terms from untrusted bytes; atom exhaustion, where converting user input to atoms slowly kills the node; and dynamic code evaluation. The runtime risk became impossible to ignore in April 2025, when CVE-2025-32433, a pre-authentication remote-code-execution flaw in the Erlang/OTP SSH server, was disclosed at CVSS 10.0 and quickly exploited in the wild. This guide covers both layers and the hex.pm hygiene that ties them together.
Why is binary_to_term the classic Elixir footgun?
Because it reconstructs arbitrary Erlang terms — including references to functions and processes — from a binary you may not control, and the default form does no validation. Feeding it untrusted input (a cookie, a cache entry, a message from another system) is the BEAM equivalent of native deserialization elsewhere:
# DANGEROUS -- reconstructs arbitrary terms from untrusted bytes
term = :erlang.binary_to_term(untrusted)
# SAFE -- refuse unsafe constructs, and never create new atoms
term = :erlang.binary_to_term(untrusted, [:safe])
The :safe flag refuses to create new atoms and rejects terms that could execute code, so it must be present on every call that touches data from outside the node. Even with :safe, treat the result as untrusted and pattern-match it into a known shape rather than assuming its structure.
What is atom exhaustion, and how do I avoid it?
Atoms are never garbage-collected, and the BEAM enforces a hard limit on how many can exist (just over one million by default). Any code path that turns user input into atoms lets an attacker create new ones until the node crashes:
# DANGEROUS -- unbounded atom creation from user input
status = String.to_atom(params["status"])
# SAFE -- only resolves to atoms that already exist
status = String.to_existing_atom(params["status"])
Use String.to_existing_atom/1 (and its raises-on-miss behavior) so the input can only map to atoms your code already defined. The same caution applies to dynamic code: never pass user input to Code.eval_string/2 or build EEx templates from it, both of which are direct code-execution sinks.
Does Ecto stop SQL injection?
Yes, as long as you let it build the query. Ecto's query DSL and parameterized fragment/1 bind values safely; the danger is raw SQL assembled by hand.
# DANGEROUS -- interpolated string reaches SQL directly
Repo.query("SELECT * FROM users WHERE email = '#{email}'")
# SAFE -- values are bound parameters
Repo.query("SELECT * FROM users WHERE email = $1", [email])
from(u in User, where: u.email == ^email)
The pin operator ^ and the parameter list are the safe paths; a string built with interpolation is raw SQL and fully exposed, exactly as in every other language.
How do I secure the runtime and the Hex supply chain?
Patch the runtime and audit the dependencies. CVE-2025-32433 lived in the Erlang/OTP SSH daemon, not in Elixir — so if you expose that daemon (for remote shells or clustering), upgrade to OTP-27.3.3, OTP-26.2.5.11, or OTP-25.3.2.20 or later, and restrict or disable it where it is not needed. For dependencies, Elixir pins resolved versions in mix.lock; commit it, and run the community auditors in CI:
mix hex.audit # flags retired packages
mix deps.audit # checks mix.lock against known advisories (mix_audit)
Pin dependencies to explicit versions, review new transitive packages, and treat a retired or advisory-flagged dependency as a build failure rather than a warning.
Elixir security checklist
| Practice | Why it matters |
|---|---|
Always pass [:safe] to :erlang.binary_to_term | Blocks untrusted-term reconstruction |
Use String.to_existing_atom, never String.to_atom, on input | Prevents atom-exhaustion DoS |
Never Code.eval_string or build EEx from user input | Direct code-execution sinks |
Bind Ecto values with ^ / parameter lists | Eliminates SQL injection |
| Patch Erlang/OTP; restrict the SSH daemon | Closes CVE-2025-32433 (CVSS 10.0) |
Commit mix.lock; run mix deps.audit + hex.audit in CI | Contains the Hex supply chain |
How Safeguard Helps
Safeguard's software composition analysis resolves your full mix.lock — the transitive Hex packages behind Phoenix, Ecto, and Plug included — and maps known advisories to the exact versions you ship, so a retired or vulnerable dependency surfaces before it reaches production. When a patched release exists, auto-fix opens a tested pull request with the version bump applied, and developers can run the same analysis locally with the Safeguard CLI before pushing. Because Phoenix behavior like CSRF protection and security headers only proves out at runtime, a dynamic scan that exercises the deployed app is the most reliable confirmation those controls hold. The BEAM discipline — safe deserialization, existing atoms, bound queries — stays yours; Safeguard secures the dependency and runtime layers around it.
Bring your Elixir supply chain under control — start for free or read the documentation.
Frequently Asked Questions
Is :erlang.binary_to_term safe to call on request data?
Not in its default form — it will reconstruct arbitrary terms, which is an untrusted-deserialization risk. Always pass the [:safe] option so it refuses to create new atoms or code-bearing terms, and still validate the decoded value by pattern-matching it into the shape you expect rather than trusting its structure.
What is atom exhaustion and can it really crash a node?
Yes. Atoms are never garbage-collected and the BEAM caps the atom table at just over a million entries by default, so any path that turns user input into atoms lets an attacker fill the table and crash the node. Use String.to_existing_atom/1 so input can only resolve to atoms your code already defined.
Does the Erlang/OTP SSH CVE affect my Phoenix app?
Only if you run the Erlang/OTP SSH daemon — for example for remote shells, provisioning, or clustering. CVE-2025-32433 is a pre-authentication RCE at CVSS 10.0; upgrade to OTP-27.3.3, OTP-26.2.5.11, or OTP-25.3.2.20 or later, and disable or firewall the daemon where it is not needed. A typical HTTP-only Phoenix deployment that never starts the SSH daemon is not exposed through this path.
How do I audit Elixir dependencies?
Commit mix.lock and run mix deps.audit (from the mix_audit project) to check your locked versions against known advisories, plus mix hex.audit to flag retired packages. Wire both into CI as failing gates, and for transitive depth and reachability use an SCA tool that resolves the full graph rather than only your direct dependencies.