Securing Production Rails Apps

Rails defaults handle most of OWASP for free. The bugs that bite are the ones you write on top of them: leaky cache keys, unsigned webhooks, mass-assignment shortcuts, and sessions that outlive their tokens.

The live-video creator platform I led engineering at. A German creator tweeted a screenshot of his profile preview on iMessage. The photo wasn’t his. The name wasn’t his. The thread picked up a couple hundred retweets in an hour and the support inbox was already on fire. Honestly, we hadn’t shipped a security bug in the usual sense. We’d shipped a cache key change that dropped the locale segment from the composition, and the Cloudflare Worker at the edge happily stored one user’s response and served it to thousands of others. No XSS, no SQL injection, no CSRF token miss. Just a key.

That’s the thing about Rails security at scale. The framework gives you most of OWASP for free. The bugs that survive code review are the ones you bolt on top.

Rails defaults you should not turn off

CSRF protection, SQL parameter binding, escape-by-default in ERB, strong parameters, and secure_headers (or the equivalent rolled into ActionDispatch::ContentSecurityPolicy). These are not exotic. They are turned on in a fresh rails new. The way you lose them is feature by feature, six months apart, when someone decides the new endpoint “doesn’t need” something.

Don’t.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  before_action :set_csp_nonce

  private

  def set_csp_nonce
    request.content_security_policy_nonce_generator = ->(_) { SecureRandom.base64(16) }
  end
end

protect_from_forgery with: :exception is the one I want pinned. The default in older apps is :null_session, which silently strips the session on a CSRF miss. Silent is bad. We want the exception. We want PagerDuty to know.

The CSP nonce is the other one I’d argue for. A nonce-based policy with no unsafe-inline is the difference between an XSS being a footgun and an XSS being a press release.

Strong parameters are not optional

I’ve seen mass assignment bugs in modern Rails apps. They get shipped because someone needed to update a record fast and reached for params.to_unsafe_h. Once it’s in the codebase, it propagates. The reviewer two months later assumes the pattern is intentional.

class UsersController < ApplicationController
  def update
    @user = current_user
    if @user.update(user_params)
      render json: @user
    else
      render json: { errors: @user.errors }, status: :unprocessable_entity
    end
  end

  private

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

What’s missing from permit: role, subscription_tier, email_verified_at. If those fields are on the model, leaving them out of permit is the whole point. A user POSTing {user: {role: "admin"}} gets ignored. Quietly. As it should.

The pattern I push for in reviews: every controller’s permit list is grep-able. If you can’t grep permit( and see exactly what comes in from the wire, you don’t know what your attack surface is.

SQL injection still happens

ActiveRecord makes parameter binding the path of least resistance. User.where(email: params[:email]) is safe. The bugs come from string interpolation that someone wrote on a Friday.

# Don't.
User.where("email = '#{params[:email]}'")

# Don't.
User.order(params[:sort])

# Yes.
User.where(email: params[:email])

# Yes, when the column is dynamic.
allowed_sorts = %w[created_at email name]
sort_column = allowed_sorts.include?(params[:sort]) ? params[:sort] : "created_at"
User.order(sort_column)

The order case is the one that catches teams. where is obviously taint-aware. order, group, select, and having accept arbitrary SQL strings. Rails will not save you. Allowlist the column names, every time.

Sessions, tokens, and the time-to-revoke

Devise’s defaults serialize the user from the session by calling User.find(id) per authenticated request. That’s fine, but it means revoking a session means changing something the session payload depends on. Out of the box, you can’t.

I add a token_version column on users and a Warden strategy that compares it.

class TokenVersionStrategy < Warden::Strategies::Base
  def valid?
    request.session[:user_id].present? &&
      request.session[:token_version].present?
  end

  def authenticate!
    user = User.find_by(id: request.session[:user_id])
    return fail!("invalid session") unless user
    return fail!("revoked session") unless user.token_version == request.session[:token_version]

    success!(user)
  end
end

Now password reset, “log out everywhere”, and “this account was compromised” all reduce to one query: user.increment!(:token_version). Every existing session is dead the next request. No cache to flush, no JWT blocklist to maintain.

Webhooks are a security boundary

The creator-economy platform I worked at. Native billing inside the branded-mobile-app pipeline. Apple’s SubscriptionRenewal server-to-server notification retried a couple of times because our endpoint returned a slow 200 OK. We had no idempotency check on the handler. Every retry inserted a new creator_subscriptions row. A few thousand customers across dozens of branded apps ended up with multiple active subscriptions and double charges on their cards. Apple had already moved the money.

First wrong fix was a frontend patch: only show the latest row per customer. Cosmetic. Apple did not refund anything because the row was hidden.

The real fix was twofold. A unique constraint at the database level on (apple_original_transaction_id, notification_uuid). And the handler rewritten as a Sidekiq job so the endpoint returns within five seconds, Apple’s retries become enqueues, and the worker idempotently inserts. The structural lesson: any server-to-server webhook can and will be received twice. Signature verification is necessary but not sufficient. You need the idempotency key on the database side, not in your Ruby code.

class AppleNotificationsController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    payload = AppleSignatureVerifier.verify!(request.raw_post, request.headers)

    ProcessAppleNotification.perform_async(
      payload[:original_transaction_id],
      payload[:notification_uuid],
      payload
    )

    head :ok
  rescue AppleSignatureVerifier::InvalidSignature
    head :unauthorized
  end
end

class ProcessAppleNotification
  include Sidekiq::Job

  def perform(transaction_id, notification_uuid, payload)
    CreatorSubscription.upsert(
      {
        apple_original_transaction_id: transaction_id,
        notification_uuid: notification_uuid,
        state: payload.fetch("state"),
        renewed_at: Time.current
      },
      unique_by: %i[apple_original_transaction_id notification_uuid]
    )
  end
end

Three things matter here. skip_before_action :verify_authenticity_token because the request comes from Apple, not a browser session. The signature verifier replaces the CSRF guarantee with a cryptographic one. The Postgres-level unique index makes “received this exact notification twice” a no-op, no matter what the Ruby code does.

Takeaways

  • Default to exceptions on CSRF miss, not silent session strips.
  • Grep every controller for permit(. If you can’t, you don’t know your attack surface.
  • Allowlist column names anywhere ActiveRecord accepts a raw SQL fragment: order, group, select, having.
  • Token versioning beats every revocation scheme that doesn’t.
  • Webhook idempotency lives in a database unique index, not in Ruby.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.