Track every browser session for a Devise-authenticated user, display them in “your active sessions” UI, and let users revoke sessions remotely. When a session is revoked, the next request from that browser forces a sign-out.

This approach hooks into Warden (the authentication layer underneath Devise) so it works with all sign-in methods: standard email/password, magic links, OmniAuth, sign_in(user) — everything.

Architecture overview #

Sign in → Warden fires :set_user → SessionManager.on_sign_in → Session.track!
Request → Warden fires :fetch    → SessionManager.on_fetch   → touch_last_active! / force sign-out if revoked
Sign out → Warden fires logout   → SessionManager.on_logout  → Session#revoke!

Every browser gets a UUID stored in the encrypted session cookie. That UUID maps to a Session database record. On each request, Warden’s :fetch event checks the record — if it’s been revoked (from another device), the user is force-signed-out.

1. Migration #

# db/migrate/20260301000000_create_sessions.rb
class CreateSessions < ActiveRecord::Migration[7.1]
  def change
    create_table :sessions do |t|
      t.references :user, null: false, foreign_key: true
      t.string :session_id, null: false
      t.string :ip_address
      t.string :user_agent, limit: 1024
      t.string :browser_name
      t.string :os_name
      t.string :device_type
      t.datetime :last_active_at, null: false
      t.datetime :revoked_at

      t.timestamps
    end

    add_index :sessions, :session_id, unique: true
    add_index :sessions, [:user_id, :revoked_at]
  end
end

Key details:

  • session_id has a unique index — this is critical for the race condition handling in track!.
  • revoked_at is a soft-delete timestamp. We don’t hard-delete sessions because we need them for the “active sessions” UI and for detecting revoked cookies.
  • user_agent is capped at 1024 characters because user agents can be absurdly long.

Run the migration:

bin/rails db:migrate

2. Session model #

Add the browser gem for user-agent parsing:

# Gemfile
gem "browser", "~> 6.0"
bundle install
# app/models/session.rb
class Session < ApplicationRecord
  belongs_to :user

  scope :active, -> { where(revoked_at: nil) }

  before_create :parse_user_agent

  # Find-or-create a session record for a given session_id.
  # Handles race conditions from concurrent requests.
  def self.track!(user:, session_id:, request:)
    find_or_create_by!(session_id: session_id) do |session|
      session.user = user
      session.ip_address = request.remote_ip
      session.user_agent = request.user_agent&.first(1024)
      session.last_active_at = Time.current
    end
  rescue ActiveRecord::RecordNotUnique
    retries ||= 0
    retry if (retries += 1) < 3
    raise
  end

  # Soft-revoke the session. Does NOT delete the record.
  def revoke!
    update!(revoked_at: Time.current)
  end

  # Update last_active_at, but only if 5+ minutes have passed.
  # Avoids a DB write on every single request.
  def touch_last_active!
    return if last_active_at > 5.minutes.ago

    update_column(:last_active_at, Time.current)
  end

  private

  def parse_user_agent
    return if user_agent.blank?

    client = Browser.new(user_agent)
    self.browser_name = client.name
    self.os_name = client.platform.name
    self.device_type = if client.device.mobile?
      "Mobile"
    elsif client.device.tablet?
      "Tablet"
    else
      "Desktop"
    end
  end
end

Why find_or_create_by! + rescue RecordNotUnique? Because find_or_create_by! is not atomic. Two concurrent requests can both fail the SELECT, then both attempt INSERT. The unique index on session_id causes one to fail. We rescue and retry — the second attempt will find the existing record.

3. User model association #

# app/models/user.rb
class User < ApplicationRecord
  devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable

  has_many :sessions, dependent: :destroy
end

4. SessionManager #

This module contains the three lifecycle methods called from Warden hooks. Keeping them in a separate module makes them independently testable.

# app/lib/session_manager.rb
module SessionManager
  # Called on sign-in (Warden :set_user or :authentication events).
  # Generates a session_id UUID and creates the Session record.
  def self.on_sign_in(user:, warden:, scope:)
    session_id = warden.session(scope)["session_id"] ||= SecureRandom.uuid
    Session.track!(
      user: user,
      session_id: session_id,
      request: ActionDispatch::Request.new(warden.env)
    )
  rescue ActiveRecord::ActiveRecordError => e
    Rails.logger.error("[SessionManager] on_sign_in failed: #{e.message}")
  end

  # Called on every authenticated request (Warden :fetch event).
  # Checks for revocation, updates last_active_at, or lazy-creates a missing record.
  def self.on_fetch(user:, warden:, scope:)
    session_id = warden.session(scope)["session_id"]
    return unless session_id

    session_record = Session.find_by(session_id: session_id)

    if session_record&.revoked_at?
      force_sign_out(warden: warden, scope: scope)
    elsif session_record
      session_record.touch_last_active!
    else
      # Lazy-create: session_id exists in cookie but DB record is missing
      Session.track!(
        user: user,
        session_id: session_id,
        request: ActionDispatch::Request.new(warden.env)
      )
    end
  rescue ActiveRecord::ActiveRecordError => e
    Rails.logger.error("[SessionManager] on_fetch failed: #{e.message}")
  end

  # Called on sign-out (Warden before_logout hook).
  # Soft-revokes the session record.
  def self.on_logout(warden:, scope:)
    # Skip if this logout was triggered by our own force_sign_out
    # (the session is already revoked in that case)
    return if warden.env["app.revoking_session"]

    # IMPORTANT: Access the raw Rack session directly. Do NOT call
    # warden.session(scope) here — see "The on_logout footgun" below.
    scoped_session = warden.raw_session["warden.user.#{scope}.session"]
    session_id = scoped_session&.dig("session_id")
    return unless session_id

    session_record = Session.find_by(session_id: session_id)
    session_record&.revoke! unless session_record&.revoked_at?
  rescue ActiveRecord::ActiveRecordError => e
    Rails.logger.error("[SessionManager] on_logout failed: #{e.message}")
  end

  # Force sign-out for a revoked session. Sets a flag to prevent
  # the before_logout hook from double-revoking.
  def self.force_sign_out(warden:, scope:)
    warden.env["app.revoking_session"] = true
    proxy = Devise::Hooks::Proxy.new(warden)
    proxy.sign_out(scope)
    throw :warden, scope: scope, message: :revoked_session
  end
  private_class_method :force_sign_out
end

Important: ActionDispatch::Request vs Rack::Request #

Warden hooks receive a Warden::Proxy (warden). warden.request returns a Rack::Request, which does NOT have the remote_ip method. remote_ip is an ActionDispatch::Request method. You must wrap the env:

# CORRECT — ActionDispatch::Request has #remote_ip
request = ActionDispatch::Request.new(warden.env)
request.remote_ip # => "192.168.1.1"

# WRONG — Rack::Request does NOT have #remote_ip
warden.request.remote_ip # => NoMethodError

The on_logout footgun: never call warden.session(scope) in before_logout #

This is the single most important gotcha. In on_logout, you must read the session_id from warden.raw_session, not warden.session(scope). Here’s why:

  1. warden.session(scope) internally calls authenticated?(scope)user(scope).
  2. During before_logout, Warden has already cleared @users[scope] from memory.
  3. user(scope) sees @users[scope] is nil, re-fetches the user from the session serializer, and re-populates @users[scope].
  4. When sign_in is called later in the same request (e.g. a magic-link flow does sign_out(current_user) then sign_in(new_user)), Warden sees the user already in @users[scope] and skips session storage.
  5. The next request finds no user in the session cookie → authentication fails.

This bug is particularly insidious because it only manifests when sign-out is immediately followed by sign-in in the same request — a pattern common in magic-link and token-based authentication flows.

# CORRECT — reads raw Rack session, no Warden side effects
scoped_session = warden.raw_session["warden.user.#{scope}.session"]
session_id = scoped_session&.dig("session_id")

# WRONG — re-populates @users[scope], breaks subsequent sign_in
session_id = warden.session(scope)&.dig("session_id")

The app.revoking_session flag #

When a revoked session is detected on :fetch, we call proxy.sign_out(scope) which triggers the before_logout hook. Without the flag, on_logout would try to revoke! a session that’s already revoked. The flag skips that:

:fetch detects revoked session
  → sets warden.env["app.revoking_session"] = true
  → calls proxy.sign_out(:user)
    → before_logout hook fires
      → on_logout checks flag → skips revoke (already done)
  → throw :warden redirects to sign-in

The on_logout method also has an unless session_record&.revoked_at? guard as a second line of defense — if the flag mechanism fails for any reason, it still won’t double-revoke.

5. Warden hooks initializer #

# config/initializers/warden_hooks.rb
Warden::Manager.after_set_user do |user, warden, opts|
  scope = opts[:scope]

  # Sign-in events: create a session record
  if scope == :user && [:set_user, :authentication].include?(opts[:event])
    SessionManager.on_sign_in(user: user, warden: warden, scope: scope)
  end

  # Fetch events (every authenticated request): check revocation, update activity
  if scope == :user && opts[:event] == :fetch
    SessionManager.on_fetch(user: user, warden: warden, scope: scope)
  end
end

Warden::Manager.before_logout do |_user, warden, opts|
  if opts[:scope] == :user
    SessionManager.on_logout(warden: warden, scope: opts[:scope])
  end
end

Warden events reference #

Event When Our hook
:set_user Devise.sign_in(user) or warden.set_user(user) Creates Session
:authentication warden.authenticate! (e.g. magic link strategies) Creates Session
:fetch Every request where Warden loads user from session cookie touch_last_active!, lazy-create, or force sign-out
before_logout warden.logout(:user) on any sign-out Soft-revokes Session

Graceful degradation for existing sessions #

Users who were signed in before this feature is deployed have no session_id in their session cookie. The :fetch hook guards on return unless session_id — when it’s nil, all tracking is skipped. These users browse normally. They’ll get a Session record on their next sign-in.

6. Revoked session Devise message #

Add a flash message for the revoked session redirect:

# config/locales/en/devise.en.yml
en:
  devise:
    failure:
      revoked_session: "Your session has been revoked. Please sign in again."

7. Sessions controller (optional — for “manage sessions” UI) #

# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  before_action :authenticate_user!

  def index
    @sessions = current_user.sessions.active.order(last_active_at: :desc)
    @current_session_id = request.env["warden"].session(:user)["session_id"]
  end

  def destroy
    session_record = current_user.sessions.find(params[:id])
    session_record.revoke!
    redirect_to sessions_path, notice: "Session revoked."
  end
end
<%# app/views/sessions/index.html.erb %>
<h1>Your Active Sessions</h1>

<% @sessions.each do |session| %>
  <div style="border: 1px solid #ddd; padding: 1rem; margin-bottom: 1rem;">
    <p>
      <strong><%= session.browser_name %></strong> on <%= session.os_name %>
      (<%= session.device_type %>)
      <% if session.session_id == @current_session_id %>
        <span style="color: green;">(This device)</span>
      <% end %>
    </p>
    <p>IP: <%= session.ip_address %></p>
    <p>Last active: <%= time_ago_in_words(session.last_active_at) %> ago</p>

    <% unless session.session_id == @current_session_id %>
      <%= button_to "Revoke", session_path(session), method: :delete %>
    <% end %>
  </div>
<% end %>
# config/routes.rb
resources :sessions, only: [:index, :destroy]

When a user clicks “Revoke” on a session, that session’s revoked_at is set. The next time the revoked browser makes a request, the :fetch hook detects it and force-signs it out.

8. Tests #

Factory #

# test/factories/sessions.rb
FactoryBot.define do
  factory :session do
    association :user
    session_id { SecureRandom.uuid }
    ip_address { "127.0.0.1" }
    user_agent { "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" }
    browser_name { "Chrome" }
    os_name { "macOS" }
    device_type { "Desktop" }
    last_active_at { Time.current }

    trait :mobile do
      user_agent { "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" }
      browser_name { "Safari" }
      os_name { "iOS" }
      device_type { "Mobile" }
    end

    trait :revoked do
      revoked_at { Time.current }
    end
  end
end

Model tests #

# test/models/session_test.rb
require "test_helper"

class SessionTest < ActiveSupport::TestCase
  setup do
    @user = create(:user)
  end

  test ".track! creates a new session record" do
    request = OpenStruct.new(remote_ip: "192.168.1.1", user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
    session_id = SecureRandom.uuid

    assert_difference "Session.count", 1 do
      session = Session.track!(user: @user, session_id: session_id, request: request)
      assert_equal @user, session.user
      assert_equal session_id, session.session_id
      assert_equal "192.168.1.1", session.ip_address
      assert_not_nil session.last_active_at
    end
  end

  test ".track! does not duplicate sessions with the same session_id" do
    request = OpenStruct.new(remote_ip: "192.168.1.1", user_agent: "Mozilla/5.0")
    session_id = SecureRandom.uuid

    Session.track!(user: @user, session_id: session_id, request: request)

    assert_no_difference "Session.count" do
      Session.track!(user: @user, session_id: session_id, request: request)
    end
  end

  test ".track! parses user agent on create" do
    request = OpenStruct.new(
      remote_ip: "10.0.0.1",
      user_agent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
    )

    session = Session.track!(user: @user, session_id: SecureRandom.uuid, request: request)

    assert_equal "Safari", session.browser_name
    assert_includes session.os_name, "iOS"
    assert_equal "Mobile", session.device_type
  end

  test "#revoke! sets revoked_at timestamp" do
    session = create(:session, user: @user)
    assert_nil session.revoked_at

    session.revoke!

    assert_not_nil session.reload.revoked_at
  end

  test "#touch_last_active! updates when stale (>5 minutes)" do
    session = create(:session, user: @user, last_active_at: 10.minutes.ago)
    original_time = session.last_active_at

    session.touch_last_active!

    assert session.reload.last_active_at > original_time
  end

  test "#touch_last_active! does NOT update when recent (<5 minutes)" do
    session = create(:session, user: @user, last_active_at: 2.minutes.ago)
    original_time = session.last_active_at

    session.touch_last_active!

    assert_equal original_time, session.reload.last_active_at
  end

  test ".active scope returns only non-revoked sessions" do
    active = create(:session, user: @user)
    _revoked = create(:session, :revoked, user: @user)

    results = @user.sessions.active
    assert_includes results, active
    assert_equal 1, results.count
  end
end

Integration tests for Warden hooks #

These tests verify the full sign-in/sign-out lifecycle end-to-end:

# test/integration/warden_hooks_test.rb
require "test_helper"

class WardenHooksTest < ActionDispatch::IntegrationTest
  include Devise::Test::IntegrationHelpers

  setup do
    @user = create(:user)
  end

  test "sign-in creates a Session record" do
    assert_difference "Session.count", 1 do
      sign_in @user
      get root_path
    end

    session_record = @user.sessions.last
    assert_not_nil session_record
    assert_nil session_record.revoked_at
  end

  test "multiple requests do not create duplicate sessions" do
    sign_in @user
    get root_path

    assert_no_difference "Session.count" do
      get root_path
    end
  end

  test "sign-out soft-revokes the session (sets revoked_at, does NOT delete)" do
    sign_in @user
    get root_path
    session_record = @user.sessions.last

    assert_nil session_record.revoked_at

    delete destroy_user_session_path

    assert_not_nil session_record.reload.revoked_at
    # Record still exists (soft delete)
    assert_equal 1, Session.count
  end

  test "revoked session forces sign-out on next request" do
    sign_in @user
    get root_path

    # Simulate revoking from another device
    @user.sessions.last.revoke!

    # Next request detects revocation and force-signs-out
    get root_path

    # Subsequent request redirects to sign-in
    get root_path
    assert_redirected_to new_user_session_path
  end

  test "touch_last_active! updates on fetch when stale" do
    sign_in @user
    get root_path

    session_record = @user.sessions.last
    session_record.update_column(:last_active_at, 10.minutes.ago)
    old_time = session_record.last_active_at

    get root_path

    assert session_record.reload.last_active_at > old_time
  end

  test "lazy-creates a Session when session_id exists but DB record is missing" do
    sign_in @user
    get root_path

    # Delete the record but keep the session_id in the cookie
    @user.sessions.delete_all

    assert_difference "Session.count", 1 do
      get root_path
    end
  end
end

9. Gotchas and decisions #

Why soft-delete instead of hard-delete? #

Two reasons:

  1. Session history UI — users can see past sessions even after sign-out.
  2. Revocation detection — if a revoked cookie is still active in some browser, the :fetch hook needs the record to exist (with revoked_at set) to detect and force sign-out. If we hard-deleted, the hook would see “no record” and lazy-create a new one, re-authenticating the revoked session.

Why update_column in touch_last_active!? #

update_column skips validations and callbacks. For a simple timestamp update on every request, this avoids unnecessary overhead.

Why find_or_create_by! instead of create! with rescue RecordNotFound? #

find_or_create_by! handles the common case (record already exists) with a fast SELECT. The RecordNotUnique rescue only fires during the race condition window — not on every request.

Why rescue ActiveRecordError in SessionManager? #

Session tracking is a non-critical feature. If the database is temporarily unavailable or a constraint fails, the user should still be able to sign in and browse. The rescue prevents session tracking failures from breaking authentication. In production, you’d send these to your error tracker (Sentry, Honeybadger, etc.) instead of just logging.

Why scope on :user in the hooks? #

If your app uses multiple Devise scopes (e.g., :user and :admin), you must scope the hooks to the correct model. Replace :user with your scope. If you want session tracking for multiple scopes, duplicate the hooks for each.

The bypass_sign_in edge case #

Some apps sign in users programmatically with bypass_sign_in or warden.set_user(user, run_callbacks: false). These skip Warden hooks entirely. If you have such a path, you must call Session.track! manually:

def sign_in_programmatically(user)
  bypass_sign_in(user, scope: :user)
  warden.set_user(user, scope: :user, run_callbacks: false)

  # Hooks are skipped, so manually track the session:
  session_id = warden.session(:user)["session_id"] ||= SecureRandom.uuid
  Session.track!(user: user, session_id: session_id, request: request)
end

Summary #

The full implementation is 3 files of application code (model, manager, initializer) plus a migration. Warden’s hook system makes this work transparently with every Devise sign-in strategy. The key design choices:

  • UUID per browser stored in the encrypted session cookie
  • Soft-delete via revoked_at for revocation detection
  • 5-minute write throttle on last_active_at to avoid DB write amplification
  • Race condition handling via unique index + retry
  • Graceful degradation for sessions that predate the feature
  • raw_session in on_logout — never call warden.session(scope) inside before_logout hooks; it re-populates Warden’s internal user cache and breaks sign-out/sign-in flows