How before_action Authorization Bypasses Happen in Rails

How before_action Authorization Bypasses Happen in Rails

In Rails, most apps enforce access control with controller callbacks, and before_action authorization is the pattern where a filter like before_action :require_login or before_action :authorize runs before every action to reject callers who should not be there. It works well until one action quietly runs without the check. This post walks through the four ways that happens, with real controller code, and why a scanner that greps for the check will miss the gap.

How before_action authorization is meant to work

A Rails controller can register a method that runs before its actions. A typical setup puts the check in a base controller so every child inherits it:

class ApplicationController < ActionController::Base
  before_action :require_login

  private

  def require_login
    redirect_to login_path unless current_user
  end
end

Every controller that inherits from ApplicationController now runs require_login before each action. The check exists in one place and covers everything below it. That is the strength of the pattern, and also where the trouble starts. The check is defined once and applied by a chain of rules, so the real question is never “does the check exist” but “does the check run on this exact action”. Those are different questions, and the gap between them is invisible if you only look for the method.

Bypass one: a skip that removes too much

Rails lets a controller opt out of an inherited filter with skip_before_action. That is a legitimate feature. A public pages controller genuinely should not force a login. The bug is when the skip is broader than intended.

class ReportsController < ApplicationController
  skip_before_action :require_login

  def public_summary
    # meant to be public, fine
  end

  def export
    # sensitive, was NEVER meant to be public
    send_data current_account.full_export
  end
end

The developer wanted public_summary open to anyone. They wrote skip_before_action :require_login with no scope, so it stripped the login check from every action in the controller, including export. Now an anonymous request to /reports/export runs with no session. The check still exists in ApplicationController. A text search for require_login finds it and reports the app as protected. The one place it does not run is the one place that matters.

Bypass two: only and except that fall out of date

Callbacks can be scoped to a list of actions with only: or except:. This is where drift creeps in. A filter written months ago names the actions that existed then, and a new action added later is simply not on the list.

class InvoicesController < ApplicationController
  before_action :require_admin, only: [:edit, :update, :destroy]

  def edit;    end
  def update;  end
  def destroy; end

  # added in a later PR
  def approve
    Invoice.find(params[:id]).approve!
  end
end

The admin check guards edit, update, and destroy. Someone later added approve, which changes real state, but never added :approve to the only: list. So approve runs the inherited require_login but never require_admin. Any logged in user, not just an admin, can approve an invoice. The same trap works in reverse with except:. A new action that should have been excluded is not, or one that should have been covered slips through because the list was written as a blocklist and a case was forgotten.

The check is not missing from the codebase. It is missing from one action’s effective callback chain, and that chain is assembled from inherited filters, skips, and only or except scopes that no single line of code shows you.

Bypass three: a child controller that resets the chain

Inheritance is the third source. A child controller can override the parent method or reset the whole filter chain, and the override wins. Consider an API base class that swaps session login for token auth:

class Api::BaseController < ApplicationController
  skip_before_action :require_login
  before_action :require_token

  def require_token
    head :unauthorized unless valid_token?(request.headers["X-Api-Key"])
  end
end

class Api::WebhooksController < Api::BaseController
  skip_before_action :require_token, only: [:receive]

  def receive
    Order.create!(webhook_params)
  end
end

The API base is fine on its own. The webhooks controller then skips require_token for receive, maybe because a third party signs its payloads a different way and the team meant to verify the signature instead. If that signature check was never added, receive now runs with no auth at all. The parent chain was reset for this one action on purpose, and the replacement never arrived. Reading Api::BaseController alone tells you the API is protected. It is the child that opened the hole.

Bypass four: callback ordering

Callbacks run in the order they are declared. If the filter that loads the current user runs after the filter that checks permissions, the permission check reads a user that is not set yet.

class DashboardController < ApplicationController
  before_action :authorize_manager
  before_action :set_current_membership

  def authorize_manager
    head :forbidden unless @membership&.manager?
  end

  def set_current_membership
    @membership = current_user.memberships.find_by(org_id: params[:org_id])
  end
end

Here authorize_manager runs first, when @membership is still nil. The safe navigation @membership&.manager? returns nil, so the guard does not halt, and the action proceeds. Swapping the two lines fixes it, but nothing about either method looks wrong in isolation. The bug lives entirely in the order. Both filters are present, both are correct, and the app is still open.

Why before_action authorization gaps hide from text scanners

Every example above shares one trait. The check is written somewhere in the code. A tool that pattern matches on require_login, require_admin, or authorize finds the string and moves on. The problem is never the string. It is the effective callback chain for one specific action, and Rails builds that chain from four things at once:

  • Inherited filters from every parent controller up to ActionController::Base, and any filters mixed in through modules.
  • Skips that remove an inherited filter, scoped or unscoped.
  • only and except scopes that decide whether a filter applies to this action at all.
  • Declaration order, which fixes what runs before what.

To see the gap you have to model that chain per action, the way Rails itself resolves it, then attach the result only to the actions that are actually routed and reachable from outside. That is a structural read of the code, not a search over its text. UnboundCompute parses these controller callbacks structurally, following inherited and mixed in chains, before, around, and after ordering, action scopes, and skips, then reasons about which action really runs a given check. The same idea drives the open code property graph that parses controller callback chains, lachesis.

This is a close cousin of broken function level authorization and belongs under the same access control umbrella, since the root cause is an action that runs without the check its neighbors get. If you want the wider picture of the class, see what is an access control vulnerability. Finding a per action gap means understanding how the app is meant to enforce access and then checking each reachable action against that intent, which is exactly the kind of assumption an autonomous researcher is built to test; you can read more on our about page.

Frequently asked questions

What causes a before_action authorization bypass in Rails?

It happens when an action ends up with no auth check even though the check exists in the code. The four common causes are a skip_before_action that removes too much, an only: or except: list that a new action was left off, a child controller that resets or overrides the parent chain, and callback order that runs the permission check before the user is loaded.

How does skip_before_action remove authorization by accident?

When you call skip_before_action :require_login with no only: or except: scope, it drops that filter from every action in the controller, not just the one you meant to open. A public action gets its skip, and a sensitive action in the same controller silently loses the login check too. The safe form names the exact actions, such as skip_before_action :require_login, only: [:public_summary].

Why do text scanners miss these bypasses?

A scanner greps for the check by name, finds require_login or require_admin in the source, and marks the app as protected. The bug is never the missing string. It is the effective callback chain for one action, which Rails builds from inherited filters, skips, only and except scopes, and declaration order. You have to model that chain per action to see which action actually runs the check.

How do I prevent before_action authorization gaps?

Scope every skip to named actions, keep only: and except: lists in sync when you add an action, declare the filter that sets the current user before any filter that checks permissions, and verify child controllers do not reset the parent chain without replacing it. Then test each reachable action as a low privilege user and confirm you get a 403 where you expect one.


Put an autonomous researcher on your own systems

UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.