Why Ecto.Changeset.cast/3 Is Your First Line of Defense Against Bad Input
Ecto.Changeset.cast/3 whitelists permitted fields and coerces types, blocking mass-assignment attacks by construction — but it silently drops unknown keys and does no business validation. Here's how to use it well.
01 Jul 2025, 20:43 UTC

Every Phoenix form eventually hands you a map of strings from the outside world. Some of those strings are what you expect; some are attempts to set is_admin: true on their own account. Ecto's answer to this is Ecto.Changeset.cast/3, and understanding exactly what it does — and what it deliberately does not do — is the difference between a validation layer you can trust and one that only looks like one.
The short version: cast/3 is a whitelist filter plus a type coercer. It takes untrusted parameters, keeps only the fields you explicitly allow, converts them to your schema's types, and records everything else as either ignored or erroneous. Business rules come after, in separate validation functions.
The mass-assignment problem cast solves
Mass assignment is the vulnerability where an attacker submits extra form fields — say, role=admin — and a naive update_attributes(params)-style function writes them straight to the database. Frameworks that auto-bind request params to models have been burned by this repeatedly.
cast/3 makes the dangerous thing impossible by construction. Its third argument is a list of permitted fields, and anything not in that list is silently dropped:
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :display_name, :string
field :role, :string, default: "member"
end
def registration_changeset(user, attrs) do
user
|> cast(attrs, [:email, :display_name])
|> validate_required([:email])
|> validate_format(:email, ~r/@/)
end
endIf a request body contains %{"email" => "[contact removed]", "role" => "admin"}, the role key never makes it into changeset.changes. There is no code path where it reaches the repo. Promoting a user requires a separate changeset function — one that only internal code calls — which is exactly the separation you want.
Casting is coercion, not validation
The second thing cast/3 does is convert string input into schema types. HTML forms only send strings, so cast turns "42" into the integer 42 for an :integer field, or parses "2026-10-14" into a Date. When coercion fails — "abc" for an integer field — the changeset becomes invalid and the error lands in changeset.errors as {"is invalid", [type: :integer, validation: :cast]}.
What it will not do is enforce your business rules. It won't check that an email is unique, that an age is over 18, or that a slug doesn't collide with a reserved word. That work belongs to the validate_* functions you pipe immediately after, or to database constraints via unique_constraint/3 and friends. Treating cast as "the validation step" is the most common conceptual mistake; it's really just the bouncer checking IDs at the door, not the manager deciding who gets a table.
One changeset per context
Because the permitted-field list lives in the changeset function, the idiomatic pattern is to write one changeset per use case rather than one giant validator:
registration_changeset/2— email, password; no role, no internal flags.profile_changeset/2— display name, avatar URL; email changes go through a confirmation flow instead.admin_changeset/2— can touchrole, but is only reachable from admin code paths.
This keeps the security decision ("what may this caller change?") explicit and co-located with the validation rules for that context, instead of scattered across controller if statements.
The silent-drop trade-off
The same behavior that protects you — ignoring unlisted keys — can bite you during development. Rename a schema field, forget to update the cast list, and the form submits happily while the field never saves. No error, no log line. The params just vanish.
Two habits help. First, when a field "isn't saving," inspect changeset.changes in iex before anything else; a missing key there almost always means a cast-list mismatch or atom/string key confusion. Second, for internal-only data where you control the input entirely, consider Ecto.Changeset.change/2, which applies changes without filtering — but never point it at request params.
Verify it yourself
You can confirm all of this in a minute in iex -S mix:
alias MyApp.Accounts.User
cs = User.registration_changeset(%User{}, %{
"email" => "[contact removed]",
"role" => "admin"
})
cs.changes # => %{email: "[contact removed]"} — role is gone
cs.valid? # => true
bad = User.registration_changeset(%User{}, %{"email" => 123})
bad.errors # => cast/type error on :emailIf role shows up in changes, your cast list is wrong — fix it before it ships. That thirty-second check, run against every changeset that touches request data, is the cheapest security review you'll ever do.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.