Why strong parameters matter
Mass assignment vulnerabilities have taken down production systems. In Rails, strong parameters means treating user input as hostile until proven safe. We've seen e-commerce platforms accidentally expose admin fields, ride-hailing services leak driver data, and SaaS applications allow privilege escalation—all because a single permit call was too permissive.
Strong parameters aren't optional security theater. They're your first line of defense against attackers who will send crafted requests targeting every field in your database schema. The cost of getting this wrong is high; the cost of implementing it correctly is near zero.
The damage happens fast. A developer builds a user update endpoint, forgets to whitelist fields, and suddenly an attacker adds admin: true to their profile update request. In a trading platform, an attacker posts a commission rate alongside their trade. In a healthcare SaaS, they inject a patient ID that doesn't belong to them. None of these require sophisticated exploits—they just require sending extra parameters in a POST body.
Understanding the basic require and permit pattern
The foundation of strong parameters starts with two methods: require and permit. These work together to whitelist exactly what your controller will accept.
class ProductsController < ApplicationController
def create
@product = Product.create(product_params)
redirect_to @product, notice: 'Product created'
end
private
def product_params
params.require(:product).permit(:name, :price, :description)
end
endrequire(:product) enforces that a product key exists in the params hash. If it doesn't, Rails raises an ActionController::ParameterMissing exception. This fails loudly and immediately, which is what you want. In production, this exception triggers your error handling—log it, alert on it if needed, but let it fail rather than silently accepting garbage data.
permit(:name, :price, :description) creates a whitelist. Any other fields—even if sent by the client—are stripped out. An attacker could send admin: true or role_id: 5 or internal_notes: 'backdoor', but those fields simply won't exist in the returned hash. Rails returns an ActionController::Parameters object that contains only the permitted keys. When you call Product.create(product_params), the mass assignment protection sees only the whitelisted fields.
In production systems handling sensitive data, we always extract parameters into a private method like product_params. This keeps your controller actions clean and makes the whitelist easy to audit during security reviews. When a new team member joins, they know exactly where to look. When security requirements change, there's one place to update.
Here's what happens without strong parameters:
# DANGEROUS - do not do this
class ProductsController < ApplicationController
def create
@product = Product.create(params[:product])
end
endIf your Product model has an admin: boolean column and someone sends POST /products { product: { name: "Shoe", price: 29.99, admin: true } }, the record is created with admin set to true. You've just handed them administrative privileges through a form field.
Permit only what users can actually change
The most common mistake is permitting too much. Every field you add to your permit list is a potential attack surface. Think carefully: what should this user role be allowed to modify in this specific context?
Consider a ride-hailing application with a user updating their profile:
class UsersController < ApplicationController
def update
@user = User.find(params[:id])
if @user.update(user_params)
render json: @user, status: :ok
else
render json: @user.errors, status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:email, :phone_number, :preferred_language)
end
endNotice what we don't permit: driver_license_verified, account_status, payment_method_id, is_premium, suspended_at, customer_rating, or total_rides. Those are backend-controlled fields that users should never be able to change directly through the update endpoint. An attacker who permits account_status could change themselves from suspended to active. One who permits customer_rating could inflate their own rating.
In a trading platform, we've seen developers accidentally permit fee_percentage in a trade creation endpoint. A clever attacker realized they could submit trades with a fee of 0.1% instead of the standard 2%. By executing thousands of tiny trades with custom fees, they undercut the market and exposed the platform's pricing as manipulable. The developer assumed the fee would always come from a hardcoded calculation based on account tier; they didn't account for an attacker crafting raw HTTP requests and sending arbitrary numeric values.
The rule: if a field should be set by business logic, background jobs, or administrative actions, don't permit it from user input. Use setter methods, callbacks, or separate admin endpoints for backend-controlled updates.
Example of correct separation:
class UsersController < ApplicationController
def update
@user = User.find(params[:id])
@user.update(user_params)
end
private
def user_params
# Only email and preferences; not account status or ratings
params.require(:user).permit(:email, :preferred_language)
end
end
class AdminController < ApplicationController
before_action :authorize_admin!
def update_user_status
@user = User.find(params[:id])
@user.update_column(:account_status, params[:status])
# Logs this action for audit trail
AuditLog.create(action: 'user_status_changed', user_id: @user.id, new_status: params[:status])
end
endNow status changes go through an admin-only endpoint with logging. Regular users simply cannot change it, no matter what they send.
Handling nested attributes explicitly
When you need to permit nested data structures, be equally cautious. Rails makes nested attributes convenient, but convenience can breed carelessness.
Imagine a CRM where a user creates an account and adds associated contacts:
class Account < ApplicationRecord
has_many :contacts
accepts_nested_attributes_for :contacts, allow_destroy: true
end
class AccountsController < ApplicationController
def create
@account = Account.new(account_params)
if @account.save
render json: @account, status: :created
else
render json: @account.errors, status: :unprocessable_entity
end
end
private
def account_params
params.require(:account).permit(
:name,
:industry,
contacts_attributes: [:id, :first_name, :last_name, :email, :_destroy]
)
end
endThe contacts_attributes: syntax tells Rails to permit nested contact data. The :id allows updates to existing contacts; :_destroy allows deletion.
But look what we don't permit inside contacts_attributes: we don't include :account_id, :created_by_user_id, :is_primary, or any system-managed fields. Those are set elsewhere, not by user input.
A subtle mistake appears here too. What if someone sends an id of a contact that belongs to a different account? Without additional validation, they could update another company's contacts. The strong parameter whitelist is only half the battle; you also need business logic validation:
class Account < ApplicationRecord
has_many :contacts
accepts_nested_attributes_for :contacts,
allow_destroy: true,
reject_if: :reject_contact
private
def reject_contact(attributes)
# Ensure contact belongs to this account before updating
if attributes['id'].present?
contact = Contact.find(attributes['id'])
contact.account_id != self.id
end
end
endIn a travel booking system, we once saw a developer permit booking_attributes: [:all] for a deeply nested structure spanning passengers, flights, and payments. It looked harmless until someone discovered they could nest a payment with an arbitrary amount and bypass the normal checkout flow. The :all pseudo-symbol should never appear in production code—it's always an explicit list.
Never use permit! or permit(:all)
Rails provides permit!, a dangerous method that permits all parameters. Don't use it.
# DO NOT DO THIS
def product_params
params.require(:product).permit!
end
# OR THIS
def product_params
params.require(:product).permit(:all)
endpermit! exists because legacy Rails applications and rapid prototypes sometimes have dozens of fields. The temptation is real: "Just use permit! to move fast. We'll refactor later." But in production, this is the equivalent of posting your database schema publicly and saying "change whatever you want."
We've seen it happen in startups under deadline pressure. They deploy with permit! intending to refactor later. Later never comes. Six months in, an engineer discovers the application assigns arbitrary database columns. A customer's payment method accidentally gets overwritten because an attacker sent an extra field. A support staff member's password gets reset by a user who shouldn't have access. Security becomes a code-red incident, the team scrambles to patch, and customers lose trust.
Even if you're working quickly, invest thirty seconds in listing the fields you actually need. It becomes muscle memory that saves you:
def product_params
params.require(:product).permit(:name, :price, :description, :sku, :category_id, :weight)
endThat's it. Seven fields. Takes one line, takes no more time than typing permit!, and it's infinitely safer.
Common mistakes
Forgetting require. Some developers use only permit without require. This works if the key exists, but it's sloppy and unsafe. Always require the top-level key so you fail fast if the structure is wrong.
# Okay but incomplete - returns nil if product key missing
params.permit(:name, :price)
# Better - raises ParameterMissing if product key missing
params.require(:product).permit(:name, :price)When you skip require, you're silently accepting bad input and your code might crash downstream with a confusing error. Force it to fail loudly at the boundary.
Permitting IDs you shouldn't. If a user can submit an id in nested attributes, they might reassign ownership of existing records. In a multi-tenant SaaS, an attacker could update contacts belonging to a different account. In a project management tool, they could modify tasks from other teams. Always think about ownership before permitting :id. Consider whether the resource belongs to the current user or organization.
Trusting frontend validation. Frontend form libraries often only submit permitted fields. This creates a false sense of security. An attacker uses curl or Postman and sends whatever they want. Strong parameters is your server-side enforcement; it's not optional because the frontend is "well-behaved." Treat the frontend as an honest UI tool for users, but treat the API endpoint as exposed to anyone with network access.
# Attacker bypasses any frontend form by sending raw JSON
curl -X POST http://api.example.com/users \
-H "Content-Type: application/json" \
-d '{"user": {"email": "attacker@example.com", "admin": true}}'
Your strong parameters are what stops this.
Permitting timestamps. Fields like :created_at and :updated_at are managed by Rails and the database. Don't permit them. Allowing user-controlled timestamps breaks audit trails, sorting, and business logic that depends on accurate timestamps. If you discover a record with a creation date six months in the future, you've got either a bug or a sign someone was tampering.
Permitting JSON or serialized fields without parsing. If you have a metadata column that stores JSON, don't permit it as a raw string. Parse it first, validate its structure, and only then store it. An attacker might inject malicious scripts or overwhelming data structures.
Hardcoding params in views. Some developers copy the permitted fields list into a view as a list of form fields. If one drifts from the other, you've got either a security hole or a confusing bug. Keep your whitelist in the controller. If you're building a form generator, use Rails form helpers or schema introspection, not manual copying.
Key takeaways
- Use
requireto enforce the presence of a top-level parameter key, andpermitto whitelist exactly which nested fields are allowed. They're a pair; use both. - Permit only the fields that a user should legitimately change in that specific endpoint. Backend-controlled fields belong in business logic, not in parameter whitelists. When in doubt, don't permit.
- For nested attributes, explicitly list each permitted field. Don't use
:allorpermit!. Enumerate your fields, even if it takes an extra line. - Audit your permit lists during code review and security sweeps. They're often overlooked because they look like boilerplate, but they're your perimeter defense against mass assignment.
- Remember that client-side validation is UX; strong parameters are security. Never conflate the two. Assume the client is an attacker.
- Test your parameter filtering. Write specs that verify non-permitted fields are ignored. Catch drift between code and intent early.






Comments
Loading comments…