Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 008 / 100

Strong parameters: Ruby on Rails lessons from production

Protect against mass assignment.

Expona Admin9 min read

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.

ruby
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
end

require(: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:

ruby
# DANGEROUS - do not do this
class ProductsController < ApplicationController
  def create
    @product = Product.create(params[:product])
  end
end

If 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:

ruby
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
end

Notice 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:

ruby
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
end

Now 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:

ruby
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
end

The 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:

ruby
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
end

In 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.

ruby
# DO NOT DO THIS
def product_params
  params.require(:product).permit!
end

# OR THIS
def product_params
  params.require(:product).permit(:all)
end

permit! 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:

ruby
def product_params
  params.require(:product).permit(:name, :price, :description, :sku, :category_id, :weight)
end

That'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.

ruby
# 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.

bash
# 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 require to enforce the presence of a top-level parameter key, and permit to 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 :all or permit!. 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles