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:
Let's begin!
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.
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.
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.
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.
Finally, it was time to build the session management UI. At a minimum, you'll want to provide users with the following:
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.