ruby javascript 103 lines · 4 tabs

Handle 401 responses in Turbo by forcing a full redirect

Shared by codesnips Jan 2026
4 tabs
class TurboFailureApp < Devise::FailureApp
  def respond
    if turbo_request?
      redirect_for_turbo
    else
      super
    end
  end

  def redirect_for_turbo
    store_location!
    flash[:alert] = i18n_message
    self.status = 303
    self.response_body = ""
    self.headers["Location"] = scope_url
  end

  private

  def turbo_request?
    request.headers["Turbo-Frame"].present? ||
      request.headers["Accept"].to_s.include?("text/vnd.turbo-stream.html")
  end

  def scope_url
    new_user_session_path
  end
end
4 files · ruby, javascript Explain with highlit

When a session expires mid-session, a Turbo Drive fetch request that receives a 302 to the login page silently follows the redirect and swaps the login form into a <turbo-frame> or the <body>, producing a broken half-rendered page instead of a real navigation. The reason is that Turbo treats XHR-style responses differently from top-level browser navigation: it will not perform a full document replacement for a redirect the way the browser would for a plain link click. The fix is to detect an unauthenticated Turbo request on the server, respond with a status Turbo cannot silently absorb, and tell the browser to do a hard window.location change instead.

In TurboFailureApp, the Devise failure app is overridden so that when the incoming request is a Turbo Drive request it emits a redirect_to wrapped for Turbo. The key detail is request_format and the check on the Turbo-Frame / text/vnd.turbo-stream.html accept header: normal HTML navigations keep Devise's default 302 behaviour, while Turbo requests are steered toward a response Turbo will honour as a genuine visit.

TurboAuthMiddleware is the belt-and-suspenders layer for any 401 that escapes the Devise path — API-ish endpoints, custom head :unauthorized, or Pundit failures. It inspects the response after the app runs, and when it sees a 401 on a request whose Accept header contains text/vnd.turbo-stream.html, it rewrites the response into a 200 carrying a small turbo-stream that drives a redirect. Returning 200 matters: a 401 body is not rendered as a stream by Turbo, so the middleware normalises the status while preserving intent. The Turbo-Location style header and the stream action both point at new_user_session_path.

redirect_controller.js is the Stimulus companion that executes the actual hard navigation. When the injected element connects, it reads its data-redirect-url-value and calls Turbo.visit(url, { action: 'replace' }), falling back to window.location.assign so the entire document reloads and the CSRF token, cookies, and layout are all rebuilt cleanly.

The trade-off is a little duplicated redirect logic across the failure app and the middleware, but each covers a different escape hatch. A common pitfall is forgetting that Turbo caches pages: forcing action: 'replace' avoids leaving the expired page in the back-forward cache. This pattern is worth reaching for whenever authentication or authorization can fail asynchronously inside a Turbo-driven app.


Related snips

Share this code

Here's the card — post it anywhere.

Handle 401 responses in Turbo by forcing a full redirect — share card
Link copied