The rebuilding turbo-rails tutorial is out!
View new course →
← Back to the list of chapters

Turbo Streams and security

Published on
In this chapter, we will learn how to use Turbo Streams securely and avoid sending broadcastings to the wrong users.

Understanding Turbo Streams and security

Before using Turbo Streams in production, we must understand how security works. It would be terrible to broadcast HTML containing sensitive data to a user that should not receive it.

Let's imagine that our quote editor was a more complex application where quotes belong to companies and companies have many users. In that case, we should not broadcast quotes to the Quotes#index page of a user that does not belong to our company. That would be a significant security flaw.

We will add users with the Devise gem and companies to our application to simulate this real-world scenario and understand Turbo Streams security. We will then experiment in the browser to show some of the security issues that might arise with Turbo Streams if we are not careful enough.

What we will build

Let's sketch what our application will look like at the end of this chapter. We will add a home page with links to the login page when the user is not signed in:

Sign inQuote EditorSign inA blazing fast quote editor built with Turbo
Sketch of the home page when the user is not signed in

Our users will be able to sign in by entering their email and password:

Emailaccountant@kpmg.comPasswordpasswordSign inBack to home page
Sketch of the login page

Our users will be redirected to the home page when they sign in. In the navbar, we will display the name of the company and user's name based on the email address. They will be able to navigate to our quote editor by clicking on the "View quotes" button:

Quote EditorView quotesA blazing fast quote editor built with HotwireSign outKPMGAccountant
Sketch of the home page when the user is signed in

When clicking on the "View quotes" button, users will navigate to the Quotes#index page that will now have a navbar:

QuotesNew quoteSecond quotedeleteeditThird quotedeleteeditFirst quotedeleteeditSign outKPMGAccountant
Sketch of the Quotes#index page with the navbar

With all of these new additions, our application will start looking like a real one, and we will be able to do some experiments in the browser where we will find a security issue.

The Quote, Company, and User models will be related to one another by the following associations:

  • A user belongs to a company
  • A quote belongs to a company
  • A company has many users
  • A company has many quotes

The database schema we will implement is illustrated in the following sketch:

companiesnameusersemailpasswordquotesidcompany_ididcompany_ididname
Sketch of the desired database schema

We will seed the data we need with the rails db:seed command to simulate our real-world scenario. In the fixtures, we will need two companies and three users:

  • The first company (KPMG) will have two users: an accountant and a manager.
  • The second company (PwC) will have only one user: an eavesdropper who should never access to KPMG's quotes.

Let's create the Company and the User models, and add the required associations to match our database schema.

Adding companies to our Rails application

Let's create the Company model:

rails generate model Company name

Let's edit the migration because we want our companies always to have a name. It is good to enforce this at the database level by adding null: false as a database constraint. It will prevent any companies from having an empty name if, for some reason, validations are skipped:

# db/migrate/XXXXXXXXXXXXXX_create_companies.rb

class CreateCompanies < ActiveRecord::Migration[7.0]
  def change
    create_table :companies do |t|
      t.string :name, null: false

      t.timestamps
    end
  end
end

We can now run the migration:

rails db:migrate

Let's not forget to add the presence validation for the name in the Company model:

# app/models/company.rb

class Company < ApplicationRecord
  validates :name, presence: true
end

We will only need the two companies we talked about in introduction: KPMG and PwC. Let's add them to our companies.yml fixture file:

# test/fixtures/companies.yml

kpmg:
  name: KPMG

pwc:
  name: PwC

We only need those two companies to make our real-world example work. To talk about Turbo Streams and security, we don't need to create the full CRUD in the CompaniesController and the associated views. Let's continue by adding users to our application.

Adding users to our application with Devise

We will use the very popular and widely used Devise gem to add users to our application and authenticate them.

Let's add it into our Gemfile:

# Gemfile

gem "devise", "~> 4.8.1"

Let's now install the gem:

bundle install
bin/rails generate devise:install

We can now generate the User model with the Devise generators:

bin/rails generate devise User
bin/rails db:migrate

Now that we have our User model in place, we need a view to sign in to match the sketches we described in the introduction. We won't allow users to register because we would have to code the logic to attach new users to their companies. We don't need all this work to talk about Turbo Streams and security, so let's keep things simple.

We just need users in our seeds and a way to log in. Therefore, we will disable all the Devise features for our User model except two of them:

  • The feature to sign in users (:database_authenticatable)
  • The feature to validate the email and password using Devise built-in validations (:validatable)

Here is how our User model should look like:

# app/models/user.rb

class User < ApplicationRecord
  devise :database_authenticatable, :validatable
end

Last but not least, let's create the fixtures we talked about in the introduction:

# test/fixtures/users.yml

accountant:
  email: accountant@kpmg.com
  encrypted_password: <%= Devise::Encryptor.digest(User, 'password') %>

manager:
  email: manager@kpmg.com
  encrypted_password: <%= Devise::Encryptor.digest(User, 'password') %>

eavesdropper:
  email: eavesdropper@pwc.com
  encrypted_password: <%= Devise::Encryptor.digest(User, 'password') %>

Devise stores the encrypted_password field in the users database table for security reasons. If we want our fixtures to have the string "password" as a password, we need to use the same method the Devise gem would use to encrypt the password in our fixtures. This is why we use the Devise::Encryptor.digest method here.

With our User model complete, we have to add the associations between users, companies, and quotes. That's what we will do in the next section.


Note: When logging in with users, you might encounter a redirection bug when submitting an invalid form. This is because the Devise gem does not support Turbo yet (version 4.8.1). The easiest way to prevent this bug is to disable Turbo on Devise forms by setting the data-turbo attribute to false on Devise forms, as we learned in the Turbo Drive chapter.

We won't do it in our Tutorial, but if we pushed our app to production, we would have to do it before real users try our app.


Users, companies and, quotes associations

We don't have any associations between our User, Company, and Quote models. Let's generate two migrations to be able to add those associations.

bin/rails generate migration add_company_reference_to_quotes company:references
bin/rails generate migration add_company_reference_to_users  company:references

The first migration will add the company_id foreign key to quotes, and the second will add the company_id foreign key to users. Thanks to the Rails generators, those two migrations are ready for a migration:

bin/rails db:migrate

Note: The migrations will fail if we have some users or quotes in our database because the two migration files specify null: false as a constraint for the company_id foreign key on users and quotes. The quotes or users in our database currently have a blank company_id which clashes with the new constraint.

If our project were a real application already in production, we would have to first populate the company_id field for all users and quotes before adding the null: false constraint. As our application is not yet in production, we can simply drop the database, re-create it and rerun our migration:

bin/rails db:drop db:create db:migrate

Our migration now runs successfully!


Now that our migrations pass and our database schema is complete, let's add the associations on the User, Company, and Quote models:

# app/models/user.rb

class User < ApplicationRecord
  devise :database_authenticatable, :validatable

  belongs_to :company
end
# app/models/company.rb

class Company < ApplicationRecord
  has_many :users, dependent: :destroy
  has_many :quotes, dependent: :destroy

  validates :name, presence: true
end
# app/models/quote.rb

class Quote < ApplicationRecord
  belongs_to :company

  # All the previous code
end

Let's update our fixtures accordingly. For our users' fixtures, we mentioned in the introduction that our accountant and our manager should belong to KPMG. On the other hand, our eavesdropper should belong to PwC. This is very easy to do with fixtures:

# test/fixtures/users.yml

accountant:
  company: kpmg
  email: accountant@kpmg.com
  encrypted_password: <%= Devise::Encryptor.digest(User, 'password') %>

manager:
  company: kpmg
  email: manager@kpmg.com
  encrypted_password: <%= Devise::Encryptor.digest(User, 'password') %>

eavesdropper:
  company: pwc
  email: eavesdropper@pwc.com
  encrypted_password: <%= Devise::Encryptor.digest(User, 'password') %>

Let's also update our quotes fixtures as they belong to a company. All the quotes we used until now will belong to KPMG, and PwC won't have any quotes by default:

# test/fixtures/quotes.yml

first:
  company: kpmg
  name: First quote

second:
  company: kpmg
  name: Second quote

third:
  company: kpmg
  name: Third quote

Our fixtures are now ready. Let's seed the database by running rails db:seed in the console. Everything is set up!

Adding a home page to our application

Now that we have users in the application, we must help them easily sign in. As described in the sketches from the beginning of the chapter:

  1. Users must be authenticated to access the quote editor, and they should only see quotes that belong to their company.
  2. Users must be able to navigate to the sign-in form from the home page even when they are not authenticated.

To solve the first bullet point above, we need to ensure our users are authenticated everywhere on the whole application. Let's enforce that in the ApplicationController thanks to the Devise method authenticate_user!:

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  before_action :authenticate_user!
end

To solve the second bullet point, we need unauthenticated users to be able to access the login form; otherwise, they couldn't sign in. Let's add an exception to our callback:

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  before_action :authenticate_user!, unless: :devise_controller?
end

We also need a home page from which our users can navigate to the sign-in form. Let's create a PagesController with a home action as a root path. This controller is public, so we will skip the need to be authenticated:

# app/controllers/pages_controller.rb

class PagesController < ApplicationController
  skip_before_action :authenticate_user!

  def home
  end
end

Let's now make the home action the root path of our application:

# config/routes.rb

Rails.application.routes.draw do
  root to: "pages#home"

  # All the other routes
end

Finally, let's add the corresponding view and make it look like our sketches:

<%# app/views/pages/home.html.erb %>

<main class="container">
  <h1>Quote editor</h1>
  <p>A blazing fast quote editor built with Hotwire</p>

  <% if user_signed_in? %>
    <%= link_to "View quotes", quotes_path, class: "btn btn--dark" %>
  <% else %>
    <%= link_to "Sign in", new_user_session_path, class: "btn btn--dark" %>
  <% end %>
</main>

We won't style our landing page more than that, but we will spend more time on the navbar as it will be visible everywhere in our application.

Let's first add the markup for our navbar. We will first use placeholders for the company's name and the current user's name, but we will change that soon. For now, let's focus on the HTML:

<%# app/views/layouts/_navbar.html.erb %>

<header class="navbar">
  <% if user_signed_in? %>
    <div class="navbar__brand">
      Company name
    </div>
    <div class="navbar__name">
      Current user name
    </div>
    <%= button_to "Sign out",
                  destroy_user_session_path,
                  method: :delete,
                  class: "btn btn--dark" %>
  <% else %>
    <%= link_to "Sign in",
                new_user_session_path,
                class: "btn btn--dark navbar__right" %>
  <% end %>
</header>

To render our navbar on every page of our application, we can render it directly from the application's layout:

<%# app/views/layouts/application.html.erb %>

<!DOCTYPE html>
<html>
  <!-- All the <head> content -->

  <body>
    <%= render "layouts/navbar" %>
    <%= yield %>
  </body>
</html>

Now that our navbar is displayed on the whole application, we will write a little bit of CSS to style it:

// app/assets/stylesheets/components/_navbar.scss

.navbar {
  display: flex;
  align-items: center;
  box-shadow: var(--shadow-large);
  padding: var(--space-xs) var(--space-m);
  margin-bottom: var(--space-xxl);
  background-color: (var(--color-white));

  &__brand {
    font-weight: bold;
    font-size: var(--font-size-xl);
    color: var(--color-text-header);
  }

  &__name {
    font-weight: bold;
    margin-left: auto;
    margin-right: var(--space-s);
    color: var(--color-text-header);
  }

  &__right {
    margin-left: auto;
  }
}

Let's not forget to import this CSS file into our manifest file:

// app/assets/stylesheets/application.sass.scss

// All the previous imports
@import "components/navbar";

Everything should start looking exactly like the sketches in the first section of this chapter, except we won't style the sign-in form. Let's test everything is wired up correctly in the browser. Before we test, let's make sure our seeds are ready with the bin/rails db:seed command.

Let's sign in with our accountant user. To do this, let's navigate to the home page and click on the "Sign in" button. Let's enter the email (accountant@kpmg.com) and the password (password) for the accountant fixture n the sign in page. Our navbar looks nice, but we still need to change the company's name and current user's name dynamically.

Let's start with the easiest one: the name of the current_user. We will use it to see which user fixture is logged in to make it easier for us in development. Let's add a #name method to our User model to guess the user's name from the email address:

# app/models/user.rb

class User < ApplicationRecord
  devise :database_authenticatable, :validatable

  belongs_to :company

  def name
    email.split("@").first.capitalize
  end
end

We can also add a test to our method to ensure it behaves as we expect:

# test/models/user_test.rb

require "test_helper"

class UserTest < ActiveSupport::TestCase
  test "name" do
    assert_equal "Accountant", users(:accountant).name
  end
end

Now that we are sure our method displays the correct result, we can update the HTML in our navbar:

<%# app/views/layouts/_navbar.html.erb %>

<header class="navbar">
  <% if user_signed_in? %>
    <div class="navbar__brand">
      Company name
    </div>
    <div class="navbar__name">
      <%= current_user.name %>
    </div>
    <%= button_to "Sign out",
                  destroy_user_session_path,
                  method: :delete,
                  class: "btn btn--dark" %>
  <% else %>
    <%= link_to "Sign in",
                new_user_session_path,
                class: "btn btn--dark navbar__right" %>
  <% end %>
</header>

We also want to add the company name to the navbar. Like we have a current_user, it would be nice to have a current_company as our users always belong to a company.

Let's add this logic into the ApplicationController as we will use the current_company method later in our controllers and views. To use the current_company method in our views, we need to turn it into a helper. We can do that thanks to the helper_method method:

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  before_action :authenticate_user!, unless: :devise_controller?

  private

  def current_company
    @current_company ||= current_user.company if user_signed_in?
  end
  helper_method :current_company
end

Now that we have our current_company helper, we can use it in our views and especially in our navbar:

<%# app/views/layouts/_navbar.html.erb %>

<header class="navbar">
  <% if user_signed_in? %>
    <div class="navbar__brand">
      <%= current_company.name %>
    </div>
    <div class="navbar__name">
      <%= current_user.name %>
    </div>
    <%= button_to "Sign out",
                  destroy_user_session_path,
                  method: :delete,
                  class: "btn btn--dark" %>
  <% else %>
    <%= link_to "Sign in",
                new_user_session_path,
                class: "btn btn--dark navbar__right" %>
  <% end %>
</header>

That was a lot of setup code, but we are almost there. We only need to fix our tests because we required users to be signed in on the quote editor and added some associations. After that, we will be ready to discuss Turbo Streams and security by doing experimenting with the browser.

Fixing our tests

Our system tests are currently broken! Let's run them with the bin/rails test:system command.

The first error we might notice is that our users now need to sign in before manipulating quotes. To sign in users in system tests, we will rely on helpers from the Warden gem to avoid coding them ourselves. Devise is built on top of Warden and the Warden::Test::Helpers module contains helpers that will help us login users in our tests thanks to the login_as method.

To use them, let's first include Warden::Test::Helpers in our ApplicationSystemTestCase class.

# test/application_system_test_case.rb

require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  include Warden::Test::Helpers

  driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
end

We can now use the helpers from the Warden::Test::Helpers module in our system tests as all of our system tests classes inherit from ApplicationSystemTestCase. What we need to do, is to log in our accountant user before each test run in the quote system test. To do this, we will use the login_as helper from the Warden::Test::Helpers in the setup block:

# test/system/quotes_test.rb

require "application_system_test_case"

class QuotesTest < ApplicationSystemTestCase
  setup do
    login_as users(:accountant)
    @quote = Quote.ordered.first
  end

  # All the previous code
end

Let's rerun the system tests with the bin/rails test:system command. Some tests are still failing because we require quotes to be associated with a company. Let's update the QuotesController to use the association with the Company model.

The QuotesController#index method should only show the quotes that belong to the current user's company. Let's use the associations for this:

# app/controllers/quotes_controller.rb

def index
  @quotes = current_company.quotes.ordered
end

Also, when creating the quote, we need to make sure the quote is associated with the current user's company:

# app/controllers/quotes_controller.rb

def create
  # Only this first line changes to make sure the association is created
  @quote = current_company.quotes.build(quote_params)

  if @quote.save
    respond_to do |format|
      format.html { redirect_to quotes_path, notice: "Quote was successfully created." }
      format.turbo_stream
    end
  else
    render :new
  end
end

For all the other actions, we must ensure the quote we manipulate is scoped to the current_company to prevent users from manipulating quotes that don't belong to their company, thus avoiding security issues:

class QuotesController < ApplicationController
  before_action :set_quote, only: [:show, :edit, :update, :destroy]
  # All the previous code

  private

  def set_quote
    # We must use current_company.quotes here instead of Quote
    # for security reasons
    @quote = current_company.quotes.find(params[:id])
  end

  # All the previous code
end

Let's run our system tests one more time with the bin/rails test:system command. All should be green. Let's run all of our tests with the bin/rails test:all command. They all pass! We are now ready to discuss Turbo Streams and security.

Security and Turbo Streams

This was a very lengthy setup, but we are ready to discuss how security works with Turbo Streams.

Let's start by showing a big problem we have in our application. To do this, let's open two browser windows side by side to be able to see real-time updates:

  • One browser window in default mode
  • One browser window in private navigation

In the default mode window, let's sign in our accountant fixture defined in the users.yml fixtures file (the email is accountant@kpmg.com, and the password is password)!

In the private navigation window, let's sign in with our eavesdropper fixture (the email is eavesdropper@pwc.com and the password also is password).

Now let's make both these users navigate to the Quotes#index page. With the accountant's account, let's create a quote named "Secret quote". The "Secret quote" appears in the private window where the eavesdropper is logged in.

There is a critical security issue here: our quote created by the accountant was broadcasted to the eavesdropper user. Those two users belong to different companies and should never gain access to the other company's quotes.

If we refresh the private navigation page with our eavesdropper account, the "Secret quote" disappears. This is because the HTML containing the "Secret quote" was broadcasted to the eavesdropper user even if we correctly scoped the list of quotes to the current_company in the QuotesController#index action:

# app/controllers/quotes_controller.rb

def index
  @quotes = current_company.quotes.ordered
end

This is a Turbo Streams issue. Before using Turbo Streams in production, we first need to understand how security works at a high level. Let's analyze why we have this security breach and how to fix it.

Turbo Stream Security in depth

It is essential to understand how security works before using turbo-rails in production. Security is a complex topic, but the solution is straightforward here.

Let's start our journey by noticing something. Let's open our dev tools and inspect the DOM with our accountant's session and our eavesdropper's session (the default navigation window and the private one). In the Quotes#index page on both browser windows, we should find a <turbo-cable-stream-source> tag. It was generated by the turbo_stream_from helper we called in the Quotes#index page:

<%# app/views/quotes/index.html.erb %>

<%# This line generate the <turbo-cable-stream-source> tag %>
<%= turbo_stream_from "quotes" %>

<%# All previous content %>

Let's copy the turbo-cable-stream-source from both the accountant's session and the eavesdropper's session.

<!-- Accountant's session -->
<turbo-cable-stream-source
  channel="Turbo::StreamsChannel"
  signed-stream-name="InF1b3RlcyI=--eba9a5055d229db025dd2ed20d069d87c36a2e4191d8fc04971a93c851bb19fc"
>
</turbo-cable-stream-source>

<!-- Eavesdropper's session -->
<turbo-cable-stream-source
  channel="Turbo::StreamsChannel"
  signed-stream-name="InF1b3RlcyI=--eba9a5055d229db025dd2ed20d069d87c36a2e4191d8fc04971a93c851bb19fc"
>
</turbo-cable-stream-source>

Notice how both signed stream names are the same? We begin to understand why both users receive the broadcastings for quotes creation, updates, and deletion.


Note: The attribute name for the stream is signed-stream-name instead of simply stream-name. The turbo_stream_from helper automatically signs the stream name to prevent users from tampering with its value and gaining access to private broadcastings. The turbo-rails gem takes care of this security concern for us!

In the sketches, I use a simplified version stream-name for our mental model. In reality, the stream-name is signed automatically, and we don't have to think about it.


Note: Your signed-stream-name won't be the same as mine because it won't be signed with the same private key. Rails generate a different private key for every new Ruby on Rails application. What is important here is that the signed-stream-name attributes are the same for both the accountant and the eavesdropper.


Both users have subscribed to the Turbo::StreamsChannel thanks to the channel attribute. All communications between the publisher (the server) and the subscriber (the client) from Turbo Streams will go through the Turbo::StreamsChannel.

However, both users have the same signed-stream-name. In ActionCable, the role of a stream is to route broadcastings to subscribers. Therefore, if we have the same signed-stream-name on both the accountant's and eavesdropper's Quotes#index page, they will both receive the same broadcastings. This is why when a quote is created, the corresponding HTML is broadcasted to both the accountant and the eavesdropper!

Let's sketch what happens. When the accountant creates the "Secret quote", it is broadcasted to the stream names "quotes". Both the accountant and the eavesdropper are receiving the broadcastings as they are streaming from the "quotes" stream.

QuotesNew quoteSecond quotedeleteeditFirst quotedeleteeditSign outKPMGAccountant<turbo-cable-stream-source stream-name="quotes"><turbo-frame id="quotes">Secret quotedeleteedit<turbo-frame id="quote_3"><turbo-stream action="prepend" target="quotes">stream name "quotes"
The accountant is streaming from the quotes stream

QuotesNew quoteSecond quotedeleteeditFirst quotedeleteeditSign outPwCEavesdropper<turbo-cable-stream-source stream-name="quotes"><turbo-frame id="quotes">Secret quotedeleteedit<turbo-frame id="quote_3"><turbo-stream action="prepend" target="quotes">stream name "quotes"
The eavesdropper is also streaming from the quotes stream

To solve our security issue, those signed-stream-name attributes must be different.

Now that we understand the problem better, we will see how to solve it in the next section.

Fixing our Turbo Streams security issue

In the previous chapter, we instructed the quote model to broadcast creations, updates, and deletions to users:

# app/models/quote.rb

class Quote < ApplicationRecord
  # All the previous code

  broadcasts_to ->(quote) { "quotes" }, inserts_by: :prepend
end

The part that interests us here is the lambda passed as the first argument to the broadcasts_to method:

->(quote) { "quotes" }

What this does under the hood is that the Turbo::StreamsChannel will broadcast quotes creations, updates, and deletions through the "quotes" stream as this lambda will always return the string "quotes". The broadcasting is received in the Quotes#index page because users are subscribed to stream thanks to the following line:

<%# app/views/quotes/index.html.erb %>

<%# This line generate the <turbo-cable-stream-source> tag %>
<%= turbo_stream_from "quotes" %>

<%# All previous content %>

We want to have the same signed-stream-name for the accountant and the manager and a different one for the eavesdropper. To do this, we have to change the stream name where the quotes' HTML will be broadcasted. Doing this with turbo-rails is very simple:

class Quote < ApplicationRecord
  # All the previous code

  broadcasts_to ->(quote) { [quote.company, "quotes"] }, inserts_by: :prepend
end

Under the hood, the signed stream name is generated from the array returned by the lambda that is the first argument of the broadcasts_to method. The rules for secure broadcastings are the following:

  1. Users who share broadcastings should have the lambda return an array with the same values.
  2. Users who shouldn't share broadcastings should have the lambda return an array with different values.

In our example, the quote's company is the same for the accountant and the manager fixture users. For both of them, the lambda returns an array with the same values, so they can share quotes creations, updates, and deletions broadcastings.

The quote's company is different for the eavesdropper, so he will not be able to receive the broadcastings.

To make our feature work again, we need to update the turbo_stream_from in the Quotes#index page as we just changed the stream name in our Quotes#index view to match the values inside the lambda:

<%# app/views/quotes/index.html.erb %>

<%= turbo_stream_from current_company, "quotes" %>

Let's experiment again in the browser. Let's open the Quotes#index page in a default browser window with our accountant's session and the Quotes#index page in a private navigation window with our eavesdropper's session. Let's create a new quote with the name of "Secret quote" with our accountant's session. This time, it was not prepended to the eavesdropper's list of quotes!

By inspecting the DOM, we can see that the signed-stream-name values are no longer the same on the accountant's and the eavesdropper's Quote#index page.

<!-- Accountant's session -->
<turbo-cable-stream-source
  channel="Turbo::StreamsChannel"
  signed-stream-name="IloybGtPaTh2YUc5MGQybHlaUzFqYjNWeWMyVXZRMjl0Y0dGdWVTOHhNRFU1TmpNNE5qTXc6cXVvdGVzIg==--9a371841e42663b82349431a512bdc7e22bb3b5407a57cc11d97e3d9af71247c"
>
</turbo-cable-stream-source>

<!-- Eavesdropper's session -->
<turbo-cable-stream-source
  channel="Turbo::StreamsChannel"
  signed-stream-name="IloybGtPaTh2YUc5MGQybHlaUzFqYjNWeWMyVXZRMjl0Y0dGdWVTODBNalV5TVRFeE56RTpxdW90ZXMi--7295a05abddcccdb1e26398b55722c8de8d34f7eeb6c43c0d1a28e56a2b15feb"
>
</turbo-cable-stream-source>

Let's now do the test with the accountant and the manager accounts. Let's log the eavesdropper out in the private window and log the manager in. With both users on the Quotes#index page, let's create a "Shared quote" quote with the accountant's account. The created quote should be prepended in real-time to the list of quotes in the manager's private window. Everything now works as expected!

By inspecting the DOM, we can see that the signed-stream-name values are the same on the accountant's and the manager's Quote#index page.

<!-- Accountant's session -->
<turbo-cable-stream-source
  channel="Turbo::StreamsChannel"
  signed-stream-name="IloybGtPaTh2YUc5MGQybHlaUzFqYjNWeWMyVXZRMjl0Y0dGdWVTOHhNRFU1TmpNNE5qTXc6cXVvdGVzIg==--9a371841e42663b82349431a512bdc7e22bb3b5407a57cc11d97e3d9af71247c"
>
</turbo-cable-stream-source>

<!-- Manager's session -->
<turbo-cable-stream-source
  channel="Turbo::StreamsChannel"
  signed-stream-name="IloybGtPaTh2YUc5MGQybHlaUzFqYjNWeWMyVXZRMjl0Y0dGdWVTOHhNRFU1TmpNNE5qTXc6cXVvdGVzIg==--9a371841e42663b82349431a512bdc7e22bb3b5407a57cc11d97e3d9af71247c"
>
</turbo-cable-stream-source>

Let's sketch what happens here. The accountant and the manager both receive the broadcasting as they share the same stream name:

QuotesNew quoteSecond quotedeleteeditFirst quotedeleteeditSign outKPMGAccountant<turbo-cable-stream-source stream-name="kpmg quotes"><turbo-frame id="quotes">Secret quotedeleteedit<turbo-frame id="quote_3"><turbo-stream action="prepend" target="quotes">stream name "kpmg quotes"
The accountant receives the broadcasting

QuotesNew quoteSecond quotedeleteeditFirst quotedeleteeditSign outKPMGManager<turbo-cable-stream-source stream-name="kpmg quotes"><turbo-frame id="quotes">Secret quotedeleteedit<turbo-frame id="quote_3"><turbo-stream action="prepend" target="quotes">stream name "kpmg quotes"
The manager receives the broadcasting

The eavesdropper does not receive the broadcasting as the <turbo-cable-stream-source> element does not have the same stream-name attribute:

QuotesNew quoteSecond quotedeleteeditFirst quotedeleteeditSign outPwCEavesdropper<turbo-cable-stream-source stream-name="pwc quotes"><turbo-frame id="quotes">
The eavesdropper does not receive the broadcasting

When using Turbo Streams in production, we must ensure that the broadcasts_to method in the model and the turbo_stream_from method in the view are appropriately configured to avoid security issues.

Wrap up

Turbo Streams are a fantastic tool, but we need to be careful who we broadcast information to if we don't want to create significant security issues.

In this chapter, we saw that this should be configured in the model callbacks that trigger the broadcastings. The lambda in the first argument of the broadcasts_to method should return the same value for users who share broadcastings and a different one for users who should not share the broadcastings.

← previous next →