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.
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:
classOnboardingController<ApplicationControllerallow_unonboarded_access# this *is* the onboarding flowend
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.
The second trap: they cannot leave
classSessionsController<ApplicationControllerallow_unonboarded_accessonly: :destroy# let a half-onboarded user still sign outend
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.
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
Registered as a
before_actiononApplicationController, 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 theonboarded_attimestamp 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
OnboardingControllerrenders the flow. It also inheritsrequire_onboarding, and the user walking the flow is by definition not onboarded. So the gate catches the request, redirects it toonboarding_path, and the new request is caught by the same gate.The fix is one line on that controller:
Take it out and run one request to see what it was holding back.
GET /onboardingfor a signed-in, un-onboarded user answers302withLocation: /onboarding. Put the line back and the same request answers200.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.
The second trap: they cannot leave
The
only: :destroyis 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, andDELETE /sessionanswers302to/onboarding. With the opt-out it answers303to/session/newand 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:onboardingbecause it is the flow.sessions, only on destroy, so they can leave.legalandpricingandblogandlandingsbecause marketing pages have no business being gated.checkoutand itssuccessesandclaimscontrollers, because somebody who is paying should not be interrupted to name their company.admin, because the founder console is not a customer surface.sitemapsandrobots, 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_actionwith 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_atand 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.