class Comment < ApplicationRecord
belongs_to :account
belongs_to :author, class_name: "User"
validates :body, presence: true
# The account object is baked into the signed stream name, so a
# broadcast for one tenant can never be delivered to another.
broadcasts_to ->(comment) { [comment.account, :comments] },
inserts_by: :prepend,
target: "comments"
scope :for_account, ->(account) { where(account: account) }
def to_partial_path
"comments/comment"
end
end
class CommentsController < ApplicationController
before_action :authenticate_user!
before_action :set_account
def index
@comments = @account.comments.order(created_at: :desc).limit(50)
end
def create
@comment = @account.comments.new(comment_params.merge(author: current_user))
if @comment.save
# No manual broadcast: the model's broadcasts_to fires, scoped
# to @account, so only this tenant's subscribers see it.
redirect_to comments_path, notice: "Comment posted"
else
@comments = @account.comments.order(created_at: :desc).limit(50)
render :index, status: :unprocessable_entity
end
end
private
def set_account
# Derived from the authenticated session, never from params.
@account = Current.account
head :forbidden if @account.nil?
end
def comment_params
params.require(:comment).permit(:body)
end
end
<%# Subscribe to the SAME [account, :comments] tuple the model broadcasts to. %>
<%= turbo_stream_from @account, :comments %>
<h1>Comments for <%= @account.name %></h1>
<%= form_with model: [@account, Comment.new], url: comments_path do |f| %>
<%= f.text_area :body, rows: 3 %>
<%= f.submit "Post" %>
<% end %>
<div id="comments">
<%= render @comments %>
</div>
<%# The signed channel name embeds the account GlobalID, so a client
cannot re-sign it to eavesdrop on a different tenant. %>
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user, :current_account
def connect
self.current_user = find_verified_user
self.current_account = current_user.account
reject_unauthorized_connection if current_account.nil?
end
private
def find_verified_user
if (verified = env["warden"]&.user)
verified
else
reject_unauthorized_connection
end
end
end
end
This snippet shows how to scope Hotwire Turbo Streams to a single tenant so a broadcast for one account never reaches subscribers of another. The core risk with turbo_stream_from is that the stream name is signed but otherwise guessable in structure: if every account broadcasts to a channel named only after the record class and id, a malicious user who knows or forges the right identifiers could subscribe to another tenant's updates. The fix is to make the account part of the stream identity everywhere a stream is produced or consumed.
In Comment model, the record belongs_to :account and broadcasts are built with broadcasts_to using a lambda that returns an array [comment.account, :comments]. Turbo serializes that array into the signed stream name, so the account GlobalID becomes part of the channel. Because after_create_commit and friends are wired through broadcasts_to, every insert, update, and destroy is automatically namespaced to the owning account with no per-callback wiring.
In CommentsController, set_account derives the tenant from Current.account rather than from params, which prevents a request from targeting another account's stream. The create action relies on the model callbacks to broadcast, and the association is built through @account.comments so the foreign key can never drift to a foreign tenant.
In comments/index.html.erb, turbo_stream_from account, :comments renders the exact same tuple the model broadcasts to. This symmetry is essential: subscription and broadcast must serialize to the identical signed name or the update silently never arrives. Passing account (not its id) lets Turbo sign the GlobalID so the channel token cannot be tampered with client-side.
The ApplicationCable::Connection tab closes the loop by authenticating the socket and rejecting connections without a verified account, so even a correctly-named subscription is refused for an unauthenticated or mismatched user. The trade-off is that every stream must consistently include the tenant object; forgetting it in one template reintroduces the leak. This pattern is the standard approach for SaaS apps where a single deployment serves many isolated customers over the same WebSocket.
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.