Preventing Mass-Assignment Vulnerabilities with Ecto Changesets
Learn how to use Ecto Changesets to prevent mass-assignment vulnerabilities by implementing strict data casting and separating application validation from database constraints.
22 Jan 2026, 01:44 UTC

The Risk of Direct Data Mapping
When accepting user input from an API or web form, mapping raw parameters directly to a database record creates a mass-assignment vulnerability. An attacker can inject unexpected keys into the request—such as is_admin: true or account_balance: 9999—which, if passed directly to a database update, could grant unauthorized privileges or corrupt data.
The solution in Elixir is the Ecto Changeset. Rather than treating a database record as a mutable object, a Changeset acts as a staging area. It filters raw input, casts types, and validates constraints before any data ever touches the database.
Implementing a Controlled Data Pipeline
The primary mechanism for security in Ecto is the cast/3 function. It requires a explicit list of permitted keys, ensuring that any key provided in the input map that is not on that list is silently ignored.
Example: User Profile Update
In this example, we define a schema for a User. We want to allow users to update their email and bio, but we must strictly prevent them from updating their role or id.
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :bio, :string
field :role, :string, default: "user"
field :id, :id
end
def update_profile_changeset(user, attrs)">
user
# Only :email and :bio are permitted
|> cast(attrs, [:email, :bio])
|> validate_required([:email])
|> validate_length(:bio, max: 500)
end
end
Verification of the Filter
To verify that the security boundary is working, you can run the following in an iex session. Note that we are passing role: "admin" in the attributes, but it is not in the permitted list of cast/3.
# Setup
user = %MyApp.Accounts.User{email: "test@example.com", role: "user"}
params = %{"email" => "new@example.com", "role" => "admin"}
# Process
changeset = MyApp.Accounts.User.update_profile_changeset(user, params)
# Check the result
IO.inspect(changeset.changes)
# Expected Output: %{email: "new@example.com"}
# Note: :role is absent from the changes map
Handling Database Constraints
A common mistake is assuming that cast and validate_ functions handle all data integrity. Changesets perform application-level validation. They cannot know if a value is unique in the database without actually attempting the write.
To handle database-level errors (like a unique index violation on the email field) without crashing the application, you must use constraint functions. These do not check the database immediately; instead, they instruct Ecto to catch the specific database error and convert it into a readable changeset error.
def registration_changeset(user, attrs)">
user
|> cast(attrs, [:email, :password])
|> validate_required([:email, :password])
|> unique_constraint(:email)
end
Limitations and Engineering Trade-offs
- Bloated Schemas: Placing every possible validation rule inside the schema file can lead to massive modules. For complex business logic involving multiple entities, move the validation logic into a dedicated
Contextmodule. - The Cast Requirement: If you call
cast/3with an empty list or omit the permitted keys, Ecto will ignore all input data. This is a safety feature, not a bug. - Performance: Changesets are lightweight structs. However, repeatedly piping large datasets through multiple validation stages in a tight loop can add overhead; use
Ecto.Repo.insert_allfor bulk operations where changeset-level validation is not required.
Summary Comparison: Validation vs. Constraints
| Feature | Mechanism | When it runs | Purpose |
|---|---|---|---|
validate_* |
Pure function | Before DB call | Format, length, presence |
unique_constraint |
DB Error Catch | During DB call | Data integrity/uniqueness |
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.