Managing server-side sessions in Rails

Engineering
October 27, 2021
Chance Feick
Senior Software Engineer
Managing server-side sessions in Rails
Welcome to The Observatory, the community newsletter from Orbit.

I'm Rosie, and I'll be your guide for this mission. Each week I'll go down rabbit holes so you don't have to. I'm here to share tactics, trends and valuable resources I've observed in the world of community building.
‚Äć

ūüíę ¬†Subscribe to The Observatory

The act of creating web application sessions occurs so frequently that it's easy to take for granted how seamlessly it actually works. A user provides their credentials, the web app authenticates the user based on their credentials, then the user carries on with their workflow.

Except, someday, a user may need to manage all those sessions. It could be an unrecognized device, location from the other side of the globe, or even just forgetting to sign out of a public computer at the library.ūüėÖ When this inevitably occurs, a user will need to revoke sessions quickly.

Managing sessions may not seem like the most exciting feature on your product roadmap, but it's a problem that your engineering organization will ultimately need to solve. Users will be grateful for the added protection, and you'll be glad you prioritized it early.

Today, we'll cover how we built server-side session management in Rails to provide users with the ability to view and log out of any or all currently active sessions and devices.

We will cover the following topics in this article:

  • Migrating to Server-Side Sessions
  • Adding Safeguards and Protections
  • Distinguishing Web and API Sessions
  • Detecting Devices and IP Addresses
  • Listing and Removing Sessions

Let's begin!

Session Types

A session allows web apps to persist data about the current user between requests. The mechanism to accomplish this is a cookie, which allows the server to create small blocks of contextual data placed on the user's browser. The contents of a session cookie can be anything from `current_user_id` to `flash` messages, as long as it's under the 4096 bytes cookie size limit.

Client-side

By default, Rails uses a cookie-based session store. Here's an example of the cookie contents for a client-side session:

{% c-block language="ruby" %}

{
  "session_id"=>"e96b2391200a073f73f7d9aedd91d1da", 
  "warden.user.user.key"=>[[1], "$2a$11$iPs8f8LJC2U4Z0Q6B31QJu"],
   "_csrf_token"=>"Zj7AY98lavPEpQ7uNW6i+/SKuE7eDmK3Tj16xrvcUWk=",
}

{% c-block-end %}

Note that everything is stored on the client, including the current user ID. Rails uses encryption to securely prevent tampering with the session contents, however, users cannot revoke sessions because the contents are stored on the browser.

Server-side

When referring to client-side vs server-side, a cookie is still the mechanism for persisting data. The difference is simply the contents of the cookie. Here's an example of the cookie contents for a server-side session:

{% c-block language="ruby" %}

"e96b2391200a073f73f7d9aedd91d1da"

{% c-block-end %}

Note that only the session ID is stored on the client. The remaining session data is stored in the server-side session store, which the server can retrieve by session ID.

Session Storage

Once we decided to transition to server-side sessions, we needed to choose where to store sessions. We had a few options for session stores:

  • Relational database
  • In-memory cache

We opted for storing sessions in the DB using an `ActiveRecord` backed data model:

{% c-block language="ruby" %}

# config/initializers/session_store.rb

Rails.application.config.session_store :active_record_store,
   key: ORBIT_SESSION_KEY
ActionDispatch::Session::ActiveRecordStore.session_class = ServerSideSession

{% c-block-end %}

We ultimately choose Postgres for our needs based on availability, anticipated capacity, and operational experience. One of our mottos is "Move fast and fix things", and we believe we could easily swap the session store to Redis if needed.

Storage Protection

A trade-off with storing sessions in the DB is the possibility of performance issues impacting the rest of your app. As a result, we still need to ensure that we do not write too many sessions to the DB. We largely solved this first challenge with approaches: limiting and trimming.

Limiting active sessions

We added a limit of up to 100 active sessions for a user:

{% c-block language="ruby" %}

# app/models/server_side_session.rb

class ServerSideSession < ActiveRecord::SessionStore::Session

  ACTIVE_SESSION_LIMIT = 100

 scope :by_oldest, -> { order(updated_at: :asc) }
  after_commit :limit_active_sessions

  private

  def limit_active_sessions
   if ServerSideSession.where(user_id: user_id).count > ACTIVE_SESSION_LIMIT
      ServerSideSession
         .where(user_id: user_id)
         .by_oldest
         .limit(1)
         .delete_all
    end
  end
end

{% c-block-end %}

When exceeded, the oldest session for the current user will be deleted first.

Trimming old sessions

Additionally, we trim sessions older than 30 days:

{% c-block language="ruby" %}

# app/workers/trim_server_side_sessions_worker.rb

class TrimServerSideSessionsWorker

  include ApplicationWorker

  urgency :low

  def perform
    cutoff_period = (ENV['SESSION_DAYS_TRIM_THRESHOLD']).to_i.days.ago
   ServerSideSession
        .where('updated_at < ?', cutoff_period)
        .delete_all
  end
end

{% c-block-end %}

We use Sidekiq for our background jobs and scheduled the worker to run once a day. We feel these measures put appropriate protection in place and we’ll continue to monitor the size of the `sessions` table.

Web and API Sessions

After we were able to store sessions in Postgres, we ran into our second challenge. Rails API mode excludes session management middleware since REST APIs are typically considered stateless.

Our web application and API, however, are currently powered by a monolith. As a result, we needed to avoid creating server-side sessions for stateless requests.

Rails lazy loads the session object, e.g. `session.loaded?`. As long as we did not read or write the session object in the API path, then no record would be created. The issue was that we could not guarantee a future developer wouldn't come along and access the session object, thus creating server-side sessions on every API request.

The workaround was to set the `session_options` for the `Rack::Request`:

{% c-block language="ruby" %}

# app/controllers/application_controler.rb

class ApplicationController < ActionController::Base

 before_action :skip_session, if: :api_request?

 protected

  def skip_session
   request.session_options[:drop] = true
  end
end

{% c-block-end %}

We receive significant API traffic for inbound webhooks from sources like GitHub and Discourse. Now, we were able to safely avoid creating a session record for every API request.

Authenticated Sessions

With only web app sessions in the store, we needed to provide a way to list sessions for authenticated users.

We use Devise for our user authentication solution and it just works. Under the hood, Devise uses Warden. When the current user is signed in, Devise will add the following to the session:

{% c-block language="ruby" %}

{
  "warden.user.user.key"=>[[1], "$2a$11$iPs8f8LJC2U4Z0Q6B31QJu"],
}

{% c-block-end %}

The contents of the array are a little difficult to parse, but it's just the user ID (e.g. `[1]`) and the encrypted password (e.g. `$2a$11$iPs8f8LJC2U4Z0Q6B31QJu`). We're primarily interested in the extracting the user ID here, so we can easily index and query by association:

{% c-block language="ruby" %}

# app/models/server_side_session.rb

class ServerSideSession < ActiveRecord::SessionStore::Session
  belongs_to :user, optional: true
  
 before_update :set_user_id

  private

 def set_user_id
    self.user_id = data['warden.user.user.key']&.first&.first
  end
end

{% c-block-end %}

Now we can use ActiveRecord features like `current_user.server_side_sessions` to list sessions for Devise users.

Detecting Devices

Finally, we needed to provide users with a way to easily distinguish between devices and locations. We wanted to capture only the minimum data needed to accomplish this and ultimately landed on: IP address and user agent.

IP Address

An IP address is a unique address that identifies a device on the internet. Rails provides an easy way to access the `RemoteIP`:

{% c-block language="ruby" %}

# app/controllers/application_controler.rb

class ApplicationController < ActionController::Base

  after_action :record_session_device, unless: :api_request?

  protected

  def record_session_device
    session[:user_agent] = request.user_agent unless session[:user_agent]
    session[:remote_ip] = request.remote_ip unless session[:remote_ip]
  end
end

{% c-block-end %}

While not perfect, you can utilize an IP address for approximately identifying geolocation.

User Agent

A user agent is a characteristic string to identify the application, operating system, and more. We used the `DeviceDetector` gem to parse the UA and a decorator to display useful information to the user:

{% c-block language="ruby" %}

class ServerSideSessionDecorator < Draper::Decorator
  include Draper::LazyHelpers

  delegate_all

  def device_icon
   case device.device_type
    when 'smartphone'
      'mobile'
    when 'ipad'
      'tablet'
    else
      'desktop'
    end
  end
end

{% c-block-end %}

Now we could display user-friendly device icons using Font Awesome.

Listing and Revoking Sessions

Finally, it was time to build the session management UI. At a minimum, you'll want to provide users with the following:

  • Listing active sessions
  • Revoking a session

Functionality maps nicely to `#index` and `#destroy` controller actions, respectively. Here's a screenshot of Account Settings in Orbit:


And that's how we built V1 of session management in Rails! We learned a few things along the way and hope you enjoyed hearing our approach.

Recommended Reading

ūüíę ¬†Orbit is Hiring Engineers in US/EMEA

Orbit helps grow and measure thousands of communities like Kubernetes and CircleCI. We're a remote-first company with a product-driven, empathetic engineering team that enjoys the occasional space pun! Check out our careers page for open opportunities.
‚Äć

Related Articles