AnyCable v1.2:
JWT identification and "hot streams"

September 29, 2021

Update (2021-12-21): Both features, JWT auth and Hotwire/CableReady speedy streams, were moved into the OSS version of AnyCable. Enjoy!


We've just launched the final AnyCable PRO release, bringing advanced features and a 40% lower memory footprint to AnyCable—the product for building real-time features for Ruby on Rails apps. The two brand-new PRO features we're presenting in the post, JWT identification and signed streams support, arrived from the Early Access program. We asked its adopters to share how they use cables and which features they would like us to provide out of the box. They might seem unrelated to each other at first glance, but when used together, they have the potential to bring about a huge performance boost.

Dealing with authentication

Amongst all the requested features, one of the leading requests was a "token-based authentication". AnyCable relies on the Action Cable API (although it can work outside of Rails), and most Action Cable applications utilize cookies as an authentication mechanism (either directly or via sessions). The main benefit of cookie-based authentication is simplicity—it just works. However, there are some drawbacks:

Using tokens could certainly help us solve these problems—but what's the catch? Well, we'd have to build everything ourselves, both the server, and the client side (and good luck actually finding a library or gem which makes this process easier instead of harder).

While I was thinking about how to incorporate tokens into AnyCable, I realized that we could also leverage this feature to improve performance. Tokens could also be made to carry identification information, and therefore could be used instead of calling an RPC server (Authenticate method).

Let's recall how Action Cable (and AnyCable) work in the context of authentication and identification.

Whenever a new connection opens, the ApplicationCable::Connection#connect method is called. This is the place where you can reject the connection (#reject_unathorized_connection) and where you configure the so-called connection identifiers (.identifed_by). The identifiers represent the client state which could be used by channels (e.g., current_user or current_tenant). AnyCable obtains the identifiers during the Authenticate call and passes along all subsequent requests (in a serialized form).

To sum everything up, all we need from the #connect method is to accept or reject the connection and populate the identifiers. And we can encapsulate all this logic within a single JWT token!

This is how it might look written in pure Ruby. First, let's create a helper to build a connection URL with a token:

class ApplicationHelper
  def action_cable_with_jwt_meta_tag(**identifiers)
    # Token TTL (5 minutes)
    exp = Time.now.to_i + 60*5
    # JWT payload.
    # The #serialize_identifiers is responsible for serializing objects into strings
    # (AnyCable uses GlobalID for that)
    payload = {ext: serialize_identifiers(identifiers), exp: exp}

    token = JWT.encode payload, JWT_ENCRYPTION_KEY, "HS256"

    base_url = ActionCable.server.config.url || ActionCable.server.config.mount_path
    tag "meta", name: "action-cable-url", content: "#{base_url}?token=#{token}"
  end
end

Now we can add it to our layout:

<%= action_cable_with_jwt_meta_tag(current_user: current_user) %> would render:
# => <meta name="action-cable-url" content="ws://demo.anycable.io/cable?token=eyJhbGciOiJIUzI1NiJ9....EWCEzziOx3sKyMoNzBt20a3QvhEdxJXCXaZsA-f-UzU" />

Finally, we need to verify the token and extract the identifiers in the #connect method:

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

    def connect
      token = request.params[:token]

      decoded_token = JWT.decode token, JWT_ENCRYPTION_KEY, true, { algorithm: "HS256" }
      # The #deserialize_identifiers method is a counterpart to #serialize_identifiers
      # and is responsible for converting strings back to objects
      identifiers = deserialize_identifiers(JSON.parse(decoded_token["ext"]))

      # Populate identifiers
      identifiers.each do |k, v|
        self.public_send("#{k}=", v)
      end
    rescue JWT::DecodeError
      reject_unauthorized_connection
    end
  end
end

This doesn't look like such a simple solution, after all 😕 If you continue staring at this piece of code for a bit longer, you might notice that this implementation contains nothing specific to a particular application: we retrieve a token, verify it, and extract the identifiers. If this is the case, why can't we just re-implement this in Go? Well, we can!

This is how AnyCable Go PRO JWT identification works:

And we don't have to perform any RPC calls! Let's see how it affects the connection time:

It's more than 2x faster now! And even more importantly, we've reduced the stress on the RPC server. This could be especially useful during connection avalanches.

JWT identification allows you to standardize the authentication flow for WebSockets, protects from cross-site WebSocket hijacking and results in a performance boost!

Luckily, you don't need to write any of the code above yourself, even the token generation part is done for you via a little companion gem called anycable-rails-jwt.

The only question left: how can we gracefully handle token expiration? AnyCable Go uses a specific disconnect message to distinguish expiration from unauthorized access (sends a {"type":"disconnect","reason":"token_expired"} message). You can use it to refresh the token. Again, we're glad to provide an out-of-the-box solution for this in our brand new anycable-client-refresh-tokens!

"Hot-wiring" Action Cable subscriptions

We've collected more than a hundred responses from Action Cable and AnyCable users around the world, and we found that a good portion of them build applications on top of Hotwire or Stimulus Reflex (+ CableReady). This is pretty cool: frontendless Rails frontend is gaining more and more traction!

What can AnyCable do for these users? Let's take a look at how Turbo Streams work with Rails.

Let's start with the turbo-rails gem, which integrates Action Cable with Hotwire. You just need to drop a helper into your template to start consuming streams:

<%= turbo_stream_from some_model %>

The helper adds a <turbo-cable-stream-source> element, which initiates an Action Cable subscription. The corresponding server-side code is as follows:

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

  def subscribed
    if verified_stream_name = self.class.verified_stream_name(params[:signed_stream_name])
      stream_from verified_stream_name
    else
      reject
    end
  end
end

The #verified_stream_name method uses an ActiveSupport::MessageVerifier to decode and verify the stream name. The subscription is rejected if the signed stream name is invalid.

Structurally speaking, the code above is the same as the example we wrote for JWT identification. What does this mean? It means that, once again, we can "move" the implementation to an AnyCable Go server and create subscriptions without touching RPC.

This is exactly what we did as a part of the signed streams feature. Further, we did it not only for Turbo Streams, but for CableReady as well (since its #stream_from implementation is nearly the same).

By combining JWT identification with signed streams, it's possible to completely avoid running an RPC server, in case you only need to use Hotwire or CableReady functionality.

Below is a visualization of RPC server metrics during Hotwire benchmarks with four different configurations (denoted by blue markers), from left to right: baseline (AnyCable PRO without any features enabled), with JWT identification, with signed streams, and with both features enabled at the same time.

It shouldn't come as any surprise that the last one shows zeros for RPC metrics—it wasn't impacted at all. We can also note that CPU usage with AnyCable Go is the lowest in the fourth scenario—dealing with tokens and signatures is "cheaper" than performing gRPC calls.

We've added these features in response to your needs. We believe that, thanks to user feedback, we're able to build a much better product together. Who knows? Maybe your request will be the next feature we add! Ready to get started? Give AnyCable a try today!