LaunchKit

A disabled feature should answer 404

September 18, 2026

A feature flag turned off has to mean something specific at the HTTP layer, and the choice is made once for the whole application or it is made inconsistently forever. There are four candidates: 404, 403, a redirect somewhere friendly, or a page that renders with the feature's parts missing.

What each answer tells the visitor

403 says the route exists and you may not have it. A redirect to the dashboard says the same thing more politely. Rendering an empty page says the feature exists and is broken. 404 says nothing at all, which is the only answer that does not hand out information.

The information matters more than it first seems, because the person typing URLs at a product is usually not its customer. A 403 on /api/v1/users confirms there is a JSON API to come back for. A redirect on /referral confirms a referral program exists and is currently shut. Neither is a vulnerability, and both are the sort of detail that turns a bored scan into an interested one.

The argument on the other side is real: a signed-in customer who bookmarked a page and now gets 404 has no idea what happened, and a support ticket is the result. That is the cost of this choice, and it is paid by the small number of people who used the feature before it was switched off. A product whose flags are flipped weekly should probably answer differently to a signed-in user than to a stranger. This one answers 404 to both, and the simplicity is the point.

Ten lines of concern

module FeatureGated
  extend ActiveSupport::Concern

  class_methods do
    def gated_by(feature, **options)
      prepend_before_action(**options) do
        head :not_found if Feature.disabled?(feature)
      end
    end
  end
end

Included in ApplicationController, so every controller in the application can declare its feature on one line:

class ReferralsController < ApplicationController
  gated_by :referrals
end

class_methods do is what ActiveSupport::Concern gives you instead of a nested module ClassMethods and an extend in included do. The **options splat is not decoration either: it forwards straight into the callback, so gated_by :support, only: :new works because only: is a callback option and nothing here needs to know that.

head :not_found rather than raise ActiveRecord::RecordNotFound is a deliberate difference. The exception would route through config.exceptions_app and render the application's 404 page, which is friendlier and also renders a layout, a navigation bar and whatever else the page carries. The bare head sends the status and an empty body, which is what a route that does not exist would do.

The word that does the work

prepend_before_action, not before_action. One word, and it decides the answer for every visitor who is not signed in.

Rails before_action order is not configuration, it is registration order: callbacks run in the sequence they were declared, and inheritance counts as declaration. A controller like App::SupportTicketsController inherits an authentication callback from ApplicationController, registered when that class was defined, which means it is already in the chain before gated_by runs. A plain before_action appends, so authentication goes first and the gate never gets a turn for an anonymous request. prepend_before_action puts the gate at the front.

The difference is visible in one request. With the feature on, an anonymous GET on /app/support_tickets/new answers 302 to /session/new, which is correct: the page exists and you need an account. With the feature off, the same request answers 404.

Swap the one word and run it again, and the disabled case answers 302 to /session/new as well. The feature is off, and the application still offers the visitor a sign-in page for it. Everything else about the code is identical, and nothing fails: the flag works, the gate runs, the module is unreachable. It just announces itself on the way.

Two lanes for the same anonymous GET on slash app slash support tickets slash new with the support feature off. The top lane, labelled prepend underscore before underscore action, runs from a green box reading feature gate, subtitled Feature.disabled? open paren colon support close paren, to a white box reading 404, subtitled empty body, noted as: the route may as well not exist. The bottom lane, labelled before underscore action, runs from a white box reading require underscore authentication, subtitled inherited, already first, to a yellow box reading 302, subtitled to slash session slash new, noted as: the visitor now knows the route is there. A greyed dashed box sits after it reading feature gate, never reached. Caption: same flag, same gate, the difference is which end of the chain it went on.

The gate is not the whole feature

Nine controllers in this codebase declare gated_by, covering referrals, sign-up, the support inbox on both the customer and admin sides, the blog, three AI controllers and the JSON API base controller. Gating the base controller is worth noticing: every versioned API controller inherits from it, so one declaration closes the entire namespace rather than one endpoint at a time.

None of that hides a link. The sidebar wraps its entries in feature?(:key), the pricing table checks the signup flag before rendering a free plan, and a mailer that belongs to a feature has to check for itself. A flag is enforced in as many places as the feature has surfaces, and the controller gate is only the one that cannot be worked around by typing.

The reverse mistake is more common and worse. Hiding the link alone leaves the feature fully functional for anyone with a bookmark, a browser history entry or a link in an old email, which is precisely the population most likely to use it.

Testing the closed door

The closed case is the one nobody looks at, because while it works there is nothing to see. Six request specs here assert it through a shared example, and testing feature flags in Rails goes through what that example pins down and the one line inside it that turns out to pin nothing.

Where the flag's answer comes from in the first place, including why an unknown key is false, is the registry and its overrides.

What this page does not cover

Feature gating at the routing layer, with a constraint that removes the route from the table entirely rather than answering 404 from inside the controller. That version has one clear advantage, rails routes tells the truth about what the application serves, and one clear cost: the route set is loaded once at boot, so flipping the flag needs a restart and the whole no-deploy property is gone.

Nor does it cover what to do with work already in flight. A background job enqueued while the feature was on will run after it goes off, and nothing in the gate touches that. The job has to decide for itself whether a flag flipped since it was queued means abandon or proceed, and the answer is different for a welcome email than for a refund.

More on Feature flags in Rails

← All Feature flags in Rails articles