ruby erb 88 lines · 4 tabs

Per-account stream scoping to prevent “cross-tenant” updates

Shared by codesnips Jan 2026
4 tabs
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
4 files · ruby, erb Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Per-account stream scoping to prevent “cross-tenant” updates — share card
Link copied