Is your Action Cable connection secure when using Turbo?

posted by Ayush Newatia
10 February, 2025



When Turbo 7 and its Rails integration turbo-rails dropped in late 2020, it became easier than ever to use Action Cable. With just a few lines of code, you could broadcast updates to the client over WebSockets and build all kinds of real-time interactivity with ease.

This ease does come with a trade-off. There’s a security pitfall that’s all too easy to fall into. By default, WebSocket connections aren’t protected by any authentication or authorisation checks. There’s also no mention in the Turbo documentation that I’m aware of explaining how the developer can add their own checks.

Before discussing the issue itself, let’s back up a bit and look at how the WebSocket connection is made.

Creating the WebSocket connection for streaming Turbo Streams

In the view, we’d establish a connection and subscribe to a channel using an Active Model/Active Record object, or a string:

<%= turbo_stream_from @conversation %>
<%= turbo_stream_from "my_channel" %>

This will render as:

<turbo-cable-stream-source
  channel="Turbo::StreamsChannel"
  signed-stream-name="...">
</turbo-cable-stream-source>

We could then broadcast to this channel from the server:

Turbo::StreamsChannel.broadcast_action_to(
  @conversation,
  action: :append,
  target: dom_id(@conversation, :messages),
  partial: "messages/message"
)

The signed stream name is generated from the @conversation object or the string.

A closer look at stream names

Any type of object can used to generate the stream name as long as it responds to to_gid_param or to_param. Hence, it’s usually an Active Record object, string, or symbol.

to_gid_param generates the Global ID for a model and then Base64 encodes it. For strings, to_param returns the object itself. For symbols, it returns the object’s string representation.

The stream name for a Conversation object would look something like:

Z2lkOi8vcGlhenphL0NvbnZlcnNhdGlvbi8x

Base64 decoding the above value will reveal the object’s Global ID:

>> stream = "Z2lkOi8vcGlhenphL0NvbnZlcnNhdGlvbi8x"
>> Base64.urlsafe_decode64(stream)

# => "gid://piazza/Conversation/1"

The stream name is run through a MessageVerifier to obtain the signed stream name which is rendered in the HTML attribute. This ensures the stream name can’t be tampered with on the client.

Using the signed stream name and channel name, the custom <turbo-cable-stream-source> element establishes a WebSocket connection using Action Cable and subscribes to the desired channel.

The security vulnerability

The signed stream names themselves cannot be spoofed easily as they are securely signed with your app’s secret_key_base.

The security issue is when a malicious user gets their hands on a signed stream name for a resource they shouldn’t have access to. This could happen in a variety of ways, but the most likely scenario is that they had access to a resource, but that access was revoked. Then, in the absence of any additional checks, they use the signed stream name to continue receiving messages broadcast to that channel.

Let’s look at some code to understand this further. This is the default ApplicationCable::Connection class which is the entry point for all WebSocket connections.

module ApplicationCable
  class Connection < ActionCable::Connection::Base
  end
end

There’s no logic in there to do any checks on the incoming connections. It will automatically accept all WebSocket connections. The next step is that the connection will try to subscribe to a channel. The default Turbo::StreamsChannel in the turbo-rails gem looks like:

class Turbo::StreamsChannel < ActionCable::Channel::Base
  extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
  include Turbo::Streams::StreamName::ClassMethods

  def subscribed
    if stream_name = verified_stream_name_from_params
      stream_from stream_name
    else
      reject
    end
  end
end

This does a check to verify the stream name hasn’t been tampered with, but then accepts the subscription without any additional authorisation checks.

Depending on the messages your app broadcasts, this might not be a big problem. I’ll also say that the scenario of a malicious user getting the markup they need is pretty unlikely. But in the event that they do, there’s absolutely no mitigation in place to stop them. It’s a vulnerability that makes me nervous.

I don’t think we should accept any connections without the appropriate authentication and authorisation checks. This applies to WebSockets just as much as it applies to HTTP.

Fixing the vulnerability

We can address this issue by doing an authentication check before the connection is ever established, and then doing an authorisation check when subscribing to a channel.

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :user

    def connect
      user_session = authenticate_session
      reject_unauthorized_connection unless user_session.present?

      self.user = user_session.user
    end

    private
      def authenticate_session
        session = cookies.encrypted[:_piazza_session]
        # Authentication logic here
      end
end

Depending on how your app’s authentication is setup, the above code will look slightly different. The general idea is to use a cookie to authenticate the user just as you would an HTTP request. The Rails session variable isn’t available in this context so we need to read the session cookie manually.

The parameters passed to identified_by are analogous to ActiveSupport::CurrentAttributes, but in the context of an Action Cable connection. We can set global attributes for the connection in those params.

Next, we create a custom channel and add our authorization logic:

class ConversationsChannel < Turbo::StreamsChannel

  def subscribed
    if authorized?
      stream_from stream_name
    else
      reject
    end
  end

  private

    def stream_name
      @stream_name ||= verified_stream_name_from_params
    end

    def authorized?
      # authorize self.user
    end
end

And finally, we need to specify this channel in our markup:

<%= turbo_stream_from @conversation, channel: ConversationsChannel %>

That’s it! Now, we’re doing an authentication and authorisation check before accepting all WebSocket connections.

Conclusion

Rails and Turbo both needed to implement the WebSocket mechanism in a generic way. There’s just no way to anticipate the intricacies of an app’s authentication and authorisation logic to provide any default setup.

However, I do wish this gotcha was made more obvious. It can be a rather sizeable security hole in the right context. Every app I’ve encountered in my freelance work has been missing these checks.

As unlikely as such an attack might be, the fix is easy enough that I think it’s absolutely worth doing. I believe that application security is something everyone should take very seriously.


For more useful tips like this, and to level up your Rails skills, buy The Rails and Hotwire Codex.