LaunchKit

The onboarding gate, and the two ways it traps people

September 19, 2026

A product that collects information after signup has to decide what happens when somebody skips it. Rendering a banner is the soft version and people ignore banners. The hard version sends them back to the flow from wherever they went, and that is four lines with two sharp edges.

The rule

def require_onboarding
  return unless authenticated?
  return if Current.user.onboarded?

  redirect_to onboarding_path
end

Registered as a before_action on ApplicationController, so every controller inherits it and no controller has to remember it.

The first guard matters as much as the rule. An anonymous visitor has no user and therefore no onboarding state, so checking it would raise on nil. Returning early also keeps the two concerns ordered correctly: authentication decides whether there is a user, and only then does onboarding ask anything about them.

onboarded? reads the onboarded_at timestamp rather than inspecting the step column. A user mid-flow has a step and no timestamp; a finished user has a timestamp and a cleared step. Asking the timestamp means the gate never has to know how many steps there are or what the last one is called.

The first trap: the flow redirects to itself

OnboardingController renders the flow. It also inherits require_onboarding, and the user walking the flow is by definition not onboarded. So the gate catches the request, redirects it to onboarding_path, and the new request is caught by the same gate.

The fix is one line on that controller:

class OnboardingController < ApplicationController
  allow_unonboarded_access # this *is* the onboarding flow
end

Take it out and run one request to see what it was holding back. GET /onboarding for a signed-in, un-onboarded user answers 302 with Location: /onboarding. Put the line back and the same request answers 200.

A browser follows that redirect about twenty times before giving up and showing ERR_TOO_MANY_REDIRECTS, on the page the user is required to complete before they can do anything else. There is nothing in the logs that says "loop": just a long run of 302s, each one individually correct.

Two lanes for the same request, a signed-in but un-onboarded user asking for slash onboarding. The top lane, labelled allow underscore unonboarded underscore access, runs from a green box reading gate skipped, subtitled require underscore onboarding never runs, to a white box reading 200, subtitled the step renders, noted as: the user can actually fill it in. The bottom lane, labelled without it, runs from a yellow box reading require underscore onboarding, subtitled not onboarded so redirect, to a white box reading 302, subtitled to slash onboarding, noted as: caught by the same gate, again. An arrow leaves that box, loops around the bottom of the diagram and points back up into the require underscore onboarding box. Caption: ERR underscore TOO underscore MANY underscore REDIRECTS, on the page they must finish before doing anything else.

The second trap: they cannot leave

class SessionsController < ApplicationController
  allow_unonboarded_access only: :destroy # let a half-onboarded user still sign out
end

The only: :destroy is the part worth reading twice. Sign-in stays gated, because a user who signs in without having finished should be sent to the flow. Sign-out does not, because otherwise the gate catches the sign-out request too.

Measured the same way: drop only: :destroy, sign in as a user mid-flow, and DELETE /session answers 302 to /onboarding. With the opt-out it answers 303 to /session/new and the session is gone.

Consider what the version without it means for somebody who opened the flow, decided they did not want to give you a company name, and reached for sign out. They cannot finish, and they cannot leave. Their only exits are clearing cookies or closing the tab and hoping the session expires. That is a trap, built out of one correct rule applied one controller too widely.

The list of exceptions is the design

Fourteen controllers declare allow_unonboarded_access, and the list is worth reading as a statement about what a half-finished account may still do:

onboarding because it is the flow. sessions, only on destroy, so they can leave. legal and pricing and blog and landings because marketing pages have no business being gated. checkout and its successes and claims controllers, because somebody who is paying should not be interrupted to name their company. admin, because the founder console is not a customer surface. sitemaps and robots, because crawlers have no session at all. busy, the shared rate-limit page, because a throttled request must not turn into a redirect.

Each one is a decision. A gate whose exception list you cannot explain is a gate that will trap somebody you did not think about.

Why redirect instead of render

The alternative keeps the user where they are and renders the flow inline, in a modal or a banner. It is friendlier and it spreads the rule across the app: every layout, every page, every controller that might render something now has to know whether this user finished onboarding.

Redirecting keeps the rule in one place and the answer binary. A request either reaches its controller or it does not, decided once, in a method any developer can read in four lines. The cost is that the user loses the page they asked for, which for a flow you expect people to complete once, in the first minute, is the right trade.

What the gate does not decide

Which step they land on. The gate only sends them to onboarding_path; the flow resolves the stored step key and falls back to the first step when it matches nothing, which is what stops a deleted step from turning this redirect into a 500.

Nor does it decide what any step asks for. That belongs to each step's form object, and the gate would work identically if every step collected nothing at all.

What this page does not cover

Partial onboarding, where a user gets into the product with some features locked until they finish. That is a per-feature rule rather than a global gate, and it replaces one before_action with a question asked in many places, which is the cost of the friendlier version.

Nor does it cover expiring the requirement. A user who signed up two years ago, before the flow existed, has no onboarded_at and will be gated forever by this code. Backfilling that column for existing accounts is a migration somebody has to remember, and nothing here reminds them.

More on Onboarding in Rails

← All Onboarding in Rails articles