<%# private stream: turbo signs the serialized record name %>
<%= turbo_stream_from current_user %>
<section class="notifications">
<h1>Notifications</h1>
<%= turbo_frame_tag "notifications" do %>
<ul id="notifications">
<% current_user.notifications.unread.recent.each do |notification| %>
<%= render notification %>
<% end %>
</ul>
<% end %>
<% if current_user.notifications.unread.none? %>
<p class="empty">You're all caught up.</p>
<% end %>
</section>
class Notification < ApplicationRecord
belongs_to :user
scope :unread, -> { where(read_at: nil) }
scope :recent, -> { order(created_at: :desc).limit(20) }
# Each user gets a private stream keyed on their record identity.
broadcasts_to :user, inserts_by: :prepend, target: "notifications"
def self.notify!(user:, title:, body:, url: nil)
notification = create!(user: user, title: title, body: body, url: url)
# deliver off the request thread so fan-out doesn't block
broadcast_prepend_later_to(
user,
target: "notifications",
partial: "notifications/notification",
locals: { notification: notification }
)
notification
end
def read!
update!(read_at: Time.current)
end
end
class TurboStreamsChannel < Turbo::StreamsChannel
def subscribed
stream_name = verified_stream_name_from_params
if stream_name.present? && owns_stream?(stream_name)
stream_from stream_name
else
reject
end
end
private
def verified_stream_name_from_params
signed = params[:signed_stream_name]
return if signed.blank?
Turbo::StreamsChannel.verified_stream_name(signed)
end
# The signed name must resolve to the connection's authenticated user.
def owns_stream?(stream_name)
record = GlobalID::Locator.locate_signed(stream_name, for: "turbo")
record.is_a?(User) && record.id == current_user.id
rescue ActiveSupport::MessageVerifier::InvalidSignature
false
end
end
This snippet shows how to keep Turbo Stream broadcasts private to a single user by combining a signed stream name with a server-side authorization check. By default turbo_stream_from accepts any string as a channel identifier, and because that identifier is serialized and signed into the page, a naive turbo_stream_from current_user still works — but only if the subscription is bound to the exact same signed token that the broadcast targets. The pattern here treats the stream name as a capability: the signed token embedded in the DOM is what proves the browser is allowed to listen.
In notifications.html.erb, the view calls turbo_stream_from with the current_user record itself rather than a raw id. Turbo signs the serialized name via Turbo::StreamsChannel, so the resulting data-signed-stream-name attribute is tamper-proof: a malicious client cannot swap in another user's id and receive their notifications. The rest of the template renders an notifications target that later appends will flow into.
Notification model produces the broadcasts. broadcasts_to scopes each stream to the owning user, meaning Notification.create! for a given user pushes a turbo_stream append only to that user's private channel. broadcast_prepend_later_to is used in notify! to push work onto a background job so the request isn't blocked on ActionCable delivery, which matters when a single action fans out many notifications.
TurboStreamsChannel is where authorization actually lives. Overriding subscribed and verifying the incoming signed_stream_name against current_user via Turbo::StreamsChannel.verified_stream_name closes the gap where a user with a valid session could hand-craft a subscription to someone else's stream. If verification fails or the record is not owned by current_user, the channel calls reject, which tears down the WebSocket subscription.
The trade-off is that signing binds a stream to a specific record identity, so streams cannot be shared ad hoc; that rigidity is exactly what makes them safe. A common pitfall is trusting the signature alone — signing proves the name was not altered, but the subscribed guard is still needed to enforce that the connection's authenticated identity matches the stream's owner. Reach for this when broadcasts carry per-user data and a shared or guessable channel name would leak information.
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.