The Parameter You Didn't Mean to Accept

Mass assignment turns a convenient framework feature into a privilege-escalation path. How over-permissive param lists happen, and how to review the permit list like it matters.

Saverole=adminserverpermit(:name, :email)
Rick
Senior Security Engineer
Apr 8, 2026
5 min read

In 2012, a researcher named Egor Homakov demonstrated a Rails mass assignment issue by pushing a commit to the Rails repository itself — timestamped years in the future, authored by someone who hadn't written it — after adding his own public key to another account on GitHub via an unfiltered parameter. No exploit kit, no memory corruption. He just sent a form field the server didn't expect and the framework helpfully wrote it to the database.

Fourteen years later, I still find this bug in code review. The frameworks got safer — Rails added strong parameters, most stacks grew some equivalent — but the vulnerability didn't die. It just moved into the permit list, where it waits for a developer in a hurry.

The convenience that bites

Mass assignment is a genuinely great feature. Instead of copying fields one by one from a request into a model, you hand the whole hash over:

def update
  @user = current_user
  @user.update(user_params)
end

The entire security question compresses into one method: what does user_params permit? And that's where things go wrong, in a few recurring ways.

Here's the version that shows up when someone adds an admin flag to the users table and wants the admin UI to be able to set it:

def user_params
  params.require(:user).permit(
    :name, :email, :avatar_url, :role, :organization_id
  )
end

If this method serves the user-facing profile endpoint, any user can now send role=admin alongside their name change and promote themselves. There's no missing authentication, no injection, nothing a scanner flags. The code does exactly what it says — the problem is what it says. The permit list is an authorization policy, written in the least policy-looking syntax imaginable, and most reviewers read it as plumbing.

How permit lists rot

Nobody writes permit(:role) on a public endpoint on purpose. It accretes.

The shared params method. One user_params serves both the admin controller and the profile controller, because duplicating it felt wasteful. The admin needs :role, so :role gets added — and the profile endpoint inherits it. Sharing param definitions across trust levels is the single most common way this bug enters a codebase. Different actors deserve different permit lists, full stop, even if that means writing two methods that overlap 80%.

The catch-all. Under deadline, or in generated scaffolding, you'll meet params.require(:user).permit! — permit everything — or its cousin in other stacks: Object.assign(user, req.body) in Node, **request.json splatted into a constructor in Python, a struct unmarshaled straight from JSON and saved in Go. Mass assignment isn't a Rails problem; it's a "bind request body to persistent object" problem, and every framework has the footgun with different ergonomics.

The column that arrived later. The permit list was fine when the table had six harmless columns. Then a migration added credits_balance, or verified, or stripe_customer_id, and nobody re-read the param lists that write to that table. This is the sneaky one, because the dangerous diff — the migration — contains no parameter code at all. When I review a migration that adds a sensitive column, I go look at every permit list for that model in the same review. The two changes are one change.

What "sensitive" actually means here

It's worth being precise about which attributes can never be client-writable, because the list is longer than "role":

  • Privilege and identity: role, admin, permissions, anything feeding authorization decisions.
  • Ownership and tenancy: user_id, organization_id, tenant_id. Letting a client set organization_id on an update is a tenant-isolation break wearing a mass-assignment costume — the record walks across the boundary.
  • Money and state machines: balance, status, plan, discount_percent. Anything where the value should only change via business logic, not via edit.
  • Trust markers: email_verified, approved_at, two_factor_enabled.

The pattern underneath: an attribute is unsafe to permit when the server is supposed to decide its value. Mass assignment hands the pen to the client. The permit list decides which sentences they're allowed to write.

The fix for these is never "validate harder." It's structural: sensitive attributes get set by explicit code paths that carry their own authorization — a promote_to_admin! method called from an admin-only controller — and simply never appear in any permit list reachable from user input.

# Profile endpoint: users edit what's theirs to edit
def user_params
  params.require(:user).permit(:name, :avatar_url)
end

# Role changes go through a verb, not an attribute
def promote
  authorize @user, :promote?
  @user.promote_to_admin!
end

Generated scaffolds and the permissive default

A pattern worth watching for in AI-assisted diffs: scaffolding tools and coding assistants generate permit lists by reading the schema. Ask for a CRUD endpoint for a model and the generated permit(...) frequently includes every column, because the assistant's notion of "complete" is "all the fields." It looks thorough. It is thorough — that's the problem. Thoroughness in a permit list is the vulnerability.

Hand-written permit lists tend to start minimal and grow. Generated ones start maximal and are supposed to be trimmed, and "supposed to be trimmed" is a step that lives entirely in code review. When a diff includes a generated-looking endpoint, read the permit list against the schema and ask about every field: should the requester control this? Any hesitation means it comes out.

The review pass, concretely

Permit lists are quick to review well once you treat them as policy. My routine:

  • Read every field in the permit list and ask: is the server supposed to decide this value? If yes, it can't be here.
  • Check who calls this params method. One params definition serving multiple trust levels is a finding by itself.
  • Grep the diff for the catch-alls: permit!, whole-body assignment, splatted request params.
  • On migrations adding sensitive columns, review the model's permit lists in the same breath.
  • Want it enforced without vigilance? A test that posts a forbidden field (role: "admin") to each public endpoint and asserts it didn't stick is cheap and permanent.

Mass assignment is what convenience looks like ten minutes before it becomes privilege escalation. The feature is fine. The permit list just needs to be read the way an attacker reads it: not as plumbing, but as the complete list of things you've agreed to let strangers write into your database.

mass-assignmentstrong-parametersprivilege-escalationapi-securityrails
Written by
Rick
Senior Security Engineer · Firetrail review team
More Security