Background

It’s good security practice to expire sessions if a user’s been inactive for a while, for obvious reasons. It’s so important that Rails’ most common authentication library, Devise, includes an entire timeoutable module to do just that.

However, since it’s handled server-side, one issue that arises is that a user’s session can expire, but the page isn’t forced to re-render until the user attempts to navigate away to a new page. Therefore, the user can still see any sensitive information present on the page, and might even think their session is still valid, but will be met with an immediate logout the next time they try to do something. In the worst case, because their session is already expired, any data they may have tried to update will just get dropped.

That’s exactly what was happening to some of our internal users at work – a short session TTL coupled with very long tasks that don’t require page navigation was causing sessions to expire before our users could get their work done, wasting time and causing endless frustration.

The solution was to implement a session heartbeat, where if the session was about to expire, the user might be prompted to stay logged in. You have no doubt seen one of these prompts elsewhere:

Session Expiration Example

Jason Hee wrote a great article about Devise and using timeoutable to implement a session heartbeat, although it’s not as feature complete as what I’ll be describing today. I encourage you to read it as a starting point and then work towards building any additional requirements you may have on top.


Rails Controllers and Routes

First things first, we’ll need to build some ways for the front end to interact with the session directly. A couple things we’ll want to do:

  1. Poll for a session TTL
  2. Reset the session TTL
  3. Handle automatic logout

Looks like three routes to me, so let’s configure that in config/routes.rb:

  resources :session_heartbeat, :only => [:index, :create] do
    get :timeout, :on => :collection
  end

These will resolve to:

GET  /session_heartbeat
POST /session_heartbeat
GET  /session_heartbeat/timeout

Next, let’s take a look at the SessionHeartbeatController I implemented with these three routes:

class SessionHeartbeatController < ApplicationController
  prepend_before_action :skip_session_refresh, only: :index

  skip_before_action :authenticate_user!, only: :index

  def index
    render json: { session_ttl: session_ttl }
  end

  def create
    head :ok
  end

  def timeout
    if current_user.present?
      reset_session
      sign_out(current_user)
    end

    flash[:alert] = I18n.t("devise.failure.timeout")
    redirect_to new_user_session_path
  end

  protected

  def session_ttl
    return 0 unless current_user.present?

    current_user.timeout_in - (Time.now.utc - last_request_time).to_i
  end

  def last_request_time
    warden.session["last_request_at"] || 0
  end
end

Our index action (GET /session_heartbeat) is simple, just render session_ttl as a JSON object. Note the second line of the controller as it’s very important:

prepend_before_action :skip_session_refresh, only: :index

With this line, before anything else happens in the controller, we want to make sure that we set some additional flags to ensure that the session TTL isn’t accidentally updated just by us polling it. We define that method in the ApplicationController in our application because there are some other actions that we might want to make sure don’t update the session, but you could in theory put in the SessionHeartbeatController if you didn’t have that requirement:

  def skip_session_refresh
    # This key determines whether the time of last request should be recorded
    request.env["devise.skip_trackable"] = true
  end

Our create action (POST /session_heartbeat) is also simple, we render a successful response to whatever’s requesting that route, but the caveat here is that under the hood, Devise will reset the session TTL for us.

And finally, timeout is our custom action (GET /session_heartbeat/timeout) that will automatically reset everything and log us out.

There may be some pieces in there that look unfamiliar if you haven’t interacted with Warden directly, which is what Devise is based around. We need the last_request_time to calculate the session_ttl. The current_user.timeout_in value is actually the configured value for the user. You can grab the global Devise timeout value or, like in our case, the one belonging to the specific user you’re wanting to check session status. It’s possible to have different classes of users with different session timeout configurations, so this is safer if you choose to go that route.


Implementing a Frontend Controller to Handle Heartbeat

NOTE: I’m not going to go incredibly in-depth here as everyone has their particular flavor of Javascript they use within their application, so it’s pointless to get into the nitty-gritty on this section. I will say that the application I implemented this with used Rails 5.1 with Webpacker and StimulusJS, so while I’ll try to keep this as generic as possible, it’ll likely be constrained to that stack.


Polling

Here’s where things start to get complicated. The backend only needs a few little pieces to function correctly, but the frontend has a lot more to consider. For example, if our session expires in 30 minutes, is it really that useful to poll it every 5 seconds? We know it’ll never expire faster than 30 minutes, so we can stop polling until much closer to our expiry. Secondly, what if your users make use of multiple open windows? If we have the notice that the session is about to expire up on one window, but we take an action on another window that resets the session, we should get rid of the notice and do nothing (until the next time the session expires!).

Let’s take a look at how that was implemented:

export class SessionHeartbeatController extends Controller  {

  // A bunch of setup code specific to our application, this causes poll() to be called at least once.

  /**
   * Polls the current user's session time-to-live and prompts the user to interact if below TTL_THRESHOLD.
   */
  async poll() {
    const response = await fetch(
      '/session_heartbeat',
      {
        method: 'GET',
        headers: {
          'Accept': 'application/json',
          'Content-Type': 'application/json',
          'X-CSRF-Token': getCsrfToken(),
        },
      },
    );

    const sessionTtlDetails = await response.json();

    // Log out unless session is unexpired.
    if (sessionTtlDetails.session_ttl <= 0) {
      this.countdownComplete();
    }

    if (!this.countdownActive) {
      // If there's no active countdown, but the session TTL is BELOW threshold, open the modal and start a countdown.
      if (sessionTtlDetails.session_ttl < TTL_THRESHOLD_IN_SECONDS) {
        this.modal.open();
        this.countdownActive = true;
        this.initializeCountdown(sessionTtlDetails.session_ttl);
      }
    } else {
      // If there's an active countdown, but the session TTL is ABOVE threshold, close the modal and remove the countdown.
      if (sessionTtlDetails.session_ttl > TTL_THRESHOLD_IN_SECONDS) {
        this.modal.close();
        this.countdownActive = false;
        this.destroyCountdown();
      }
    }

    // If the session TTL is very far away, schedule the next poll time
    if (sessionTtlDetails.session_ttl > POLL_SCHEDULE_THRESHOLD_IN_SECONDS) {
      clearTimeout(this.timeout);

      const nextTtlPollTime = (sessionTtlDetails.session_ttl - POLL_SCHEDULE_THRESHOLD_IN_SECONDS) * 1000;

      this.timeout = setTimeout(() => this.poll(), nextTtlPollTime);
    } else {
      // Otherwise, start polling every POLL_INTERVAL
      this.timeout = setTimeout(() => this.poll(), POLL_INTERVAL);
    } 
  }
}

Here’s the general flow of things:

  1. We initialize the controller and poll() at least once.
  2. Based on what the backend sends us, we do the following:
    • If the session has expired, clean everything up and logout
    • If conditions are met for closing or opening the modal, we do so
    • If we should continue polling, do so, else schedule it for a more appropriate time

By continuing to poll once the modal is open, we can see if the session time-to-live goes above the threshold, and if it does, we can get rid of the modal and stop alerting the user. On the other end, our POLL_SCHEDULE_THRESHOLD_IN_SECONDS can tell us whether we’re too far away to continue polling.

Let’s look at those constants:

/**
 * The interval time used when polling for new statuses.
 */
const POLL_INTERVAL = 5000;

/**
 * The session time-to-live value below which the user will be prompted to confirm activity.
 */
const TTL_THRESHOLD_IN_SECONDS = 90;

/**
 * The session TTL above which the controller should schedule the next poll rather than loop to avoid extra requests.
 */
const POLL_SCHEDULE_THRESHOLD_IN_SECONDS = TTL_THRESHOLD_IN_SECONDS + (POLL_INTERVAL / 1000) + 1;

Notice that the POLL_SCHEDULE_THRESHOLD_IN_SECONDS is simply the TTL_THRESHOLD_IN_SECONDS + POLL_INTERVAL plus an offset. We use an offset so we’re less likely to hit the last possible valid value, like 91 in our case, as that would mean we give less notice to the user to take action.


Handling Input

There’s only one last piece we need to handle, which is resetting the session TTL. Compared to the above, it’s really easy:

  async handleAccept() {
    const response = await fetch(
      '/session_timeouts',
      {
        method: 'POST',
        headers: {
          'Accept': 'application/json',
          'Content-Type': 'application/json',
          'X-CSRF-Token': getCsrfToken(),
        },
      },
    );

    if (response.status == 200) {
      this.modal.close();
      this.countdownActive = false;
      this.destroyCountdown();
      clearTimeout(this.timeout);

      // Start the entire cycle over again.
      this.poll();
    }
  }

It’s pretty simple to close any open modal, clean up countdowns and timeouts, and then restart the cycle all over again! The final call to this.poll() allows us to continue polling for as long as needed and enables multiple interactions with the notification without needing to navigate to a new page.

Wrap Up

And that’s really all there is to it! The last steps would be to hook up your controller to the modal of your choosing and let it fly!

That’s all for now. Thanks for reading!