class ApplicationController < ActionController::Base
before_action :authenticate_user!
rescue_from ActionController::InvalidAuthenticityToken do
handle_unauthenticated(reason: :csrf)
end
private
def authenticate_user!
return if current_user.present?
handle_unauthenticated(reason: :session)
end
def handle_unauthenticated(reason:)
store_return_location
flash.now[:alert] = unauthenticated_message(reason)
respond_to do |format|
format.html { redirect_to new_session_path, alert: flash.now[:alert] }
format.turbo_stream do
@redirect_url = new_session_path
render "shared/auth_failure", status: :unauthorized
end
format.any { head :unauthorized }
end
end
def store_return_location
return unless request.get? && !request.xhr?
session[:return_to] = request.fullpath
end
def unauthenticated_message(reason)
reason == :csrf ? "Your session was interrupted. Please sign in again." : "Please sign in to continue."
end
end
<%= turbo_stream.replace "flash" do %>
<div id="flash" class="flash flash--alert" role="alert">
<%= flash.now[:alert] %>
</div>
<% end %>
<turbo-stream action="redirect" url="<%= @redirect_url %>"></turbo-stream>
import { Turbo } from "@hotwired/turbo-rails"
Turbo.StreamActions.redirect = function () {
const url = this.getAttribute("url")
if (!url) return
const delay = parseInt(this.getAttribute("delay") || "0", 10)
const navigate = () => {
Turbo.cache.clear()
Turbo.visit(url, { action: "replace" })
}
if (delay > 0) {
window.setTimeout(navigate, delay)
} else {
navigate()
}
}
When a Rails app uses Hotwire's Turbo, form submissions and link clicks are intercepted and sent as fetch requests that expect a Turbo Stream or full-page response. This creates a subtle problem: when a session expires mid-session, a normal redirect_to login_path returns a 302 that Turbo silently follows, and the resulting login page HTML gets swapped into a tiny stream target — producing a broken partial render instead of a clean redirect. The files here show a coherent way to make authentication failures behave correctly inside Turbo requests.
In ApplicationController the entry point is authenticate_user!, called via a before_action. Rather than always redirecting, it inspects request.format and branches through handle_unauthenticated. For HTML it redirects as usual, but for a TURBO_STREAM request it renders a Turbo Stream that replaces a global flash region and, crucially, emits a custom redirect stream action. The status: :unauthorized code and Turbo-Frame awareness keep Turbo from treating the body as a normal navigation.
The auth_failure.turbo_stream.erb template is the payload. It uses turbo_stream.replace to update the flash and a custom <turbo-stream action="redirect"> element carrying the target URL in a data attribute. Turbo does not know this action natively, so the browser side must teach it.
That is the role of redirect_controller.js, a Stimulus-style registration of a custom StreamActions.redirect. When Turbo processes the stream it invokes this function, which reads getAttribute("url") and performs Turbo.visit(...) with action: "replace", giving a real navigation to the login page rather than a fragment swap.
The trade-off is a small amount of client code in exchange for correct behavior across every Turbo interaction, including frames and streams. A common pitfall is forgetting the :unauthorized status — without it some flows still try to merge the body. Another is CSRF: expired sessions often also invalidate the token, so rescue_from ActionController::InvalidAuthenticityToken is routed through the same handler for consistency. This pattern is worth reaching for whenever an app mixes long-lived pages with Turbo-driven updates and needs session expiry to feel seamless rather than broken.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.