π sessions - GitHub-style device management & login tracking for Rails
[!TIP] π Ship your next Rails app 10x faster! I've built RailsFast, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go check it out!
sessions is the missing session layer for Rails: a "Your devices" page like GitHub's (every active session, "log out of that device", "sign out everywhere else") plus an audit trail of every login attempt β successful and failed β with parsed device names, IP geolocation, and the auth method that started each session.
It decorates the session storage your app already has instead of replacing it. On Rails 8+ omakase auth (rails generate authentication) it enriches the sessions table the generator already created β Rails captures ip_address and user_agent on every session and then never looks at them again; this gem is the product on top of that data. On Devise, it turns the proven one-session-per-user revocation trick into true per-device remote logout via Warden hooks. Either way: one bundle add, one generator, one has_sessions.
And it's built for how people actually sign in now: password, OAuth (Google, Apple, GitHubβ¦ any OmniAuth provider β including failed OAuth attempts), Google One Tap, passkeys, magic links β plus first-class Hotwire Native awareness, so a session shows up as "MyApp 2.4.1 on Pixel 8 (Android 16)", not as a WebView mystery string.
π¨βπ» Example
current_user.sessions.active # every live device, most recent first
session = current_user.sessions.first
session.device_name # => "Chrome 137 on macOS"
# => "MyApp 2.4.1 on iPhone15,2 (iOS 19.5)"
session.location # => "Madrid, Spain" (via the trackdown gem)
session.country_flag # => "πͺπΈ"
session.last_seen_at # => 3 minutes ago (throttled touch)
session.current? # => true for the request's own session
session.hotwire_native? # session.native_ios? / session.native_android? / session.web?
session.auth_method # => "oauth" Β· session.auth_provider # => "google"
session.revoke! # remote logout β that device is signed out on its next request
current_user.revoke_other_sessions! # GitHub's "sign out everywhere else"
current_user.revoke_all_sessions! # the account-takeover hammer
current_user.session_history.recent # the trail, identity-matched failures included
current_user.session_history.failed_logins.last_24_hours
# Admin / fraud triage β scopes are the product:
Sessions::Event.failed_logins.last_24_hours.group(:ip_address).count
Sessions::Event.for_identity("victim@example.com") # ATO investigation
Sessions::Event.by_country("RU").logins
And the drop-in devices page:
Your devices
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
π₯ Chrome 137 on macOS β This device
πͺπΈ Madrid, Spain Β· Active now Β· Signed in May 2 via Google
π± MyApp 2.4.1 on iPhone15,2 (iOS 19.5) [Log out]
πͺπΈ Madrid, Spain Β· Active 3 minutes ago Β· Signed in Apr 28
[ Sign out of all other sessions ]
Login history
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Signed in Β· Chrome on macOS Β· Madrid, Spain Β· today 09:12
β Failed sign-in attempt (wrong credentials) Β· yesterday 23:48
β Session revoked (you signed out everywhere) Β· Apr 30
Quickstart
# Gemfile
gem "sessions"
bundle install
rails generate sessions:install # detects Rails 8 auth vs Devise, writes the right migrations
rails db:migrate
# app/models/user.rb
class User < ApplicationRecord
has_sessions
end
# config/routes.rb β Rails 8 auth apps:
mount Sessions::Engine => "/settings/sessions"
# Devise apps β wrap the mount in your auth:
authenticate :user do
mount Sessions::Engine => "/settings/sessions"
end
That's it. Every sign-in from now on lands on the devices page and in the trail β on Rails 8 auth there is literally nothing else to wire (the gem decorates the generated Session model automatically; your app code stays untouched).
What sessions does (and doesn't) do
Does:
- Live device registry β one row per signed-in device on the (Rails-8-shaped)
sessionstable, enriched with parsed device intelligence, geolocation, auth method, and a throttledlast_seen_at. - Remote revocation that actually works β destroy the row, and that device is logged out on its very next request, on both auth stacks. Revoking a Devise session also rotates remember-me credentials so a stolen long-lived cookie can't quietly revive it.
- Append-only login trail β logins, failed logins (with the typed identity, even for accounts that don't exist), logouts, revocations, expirations. Each trail row links to the live session it created: a suspicious login is one lookup away from the kill switch.
- Every 2026 login method β password and OAuth classify automatically (OmniAuth failures get captured too, via a composed
on_failure); One Tap / passkeys / magic links / SSO take oneSessions.tagline. - Hotwire Native device intelligence β platform, OS version, and (on Android) device model work with zero setup; add the UA prefix convention for app versions and iOS hardware models.
- Security hygiene as defaults β revoke-on-password-change (OWASP ASVS 3.3.3), per-user session caps with oldest-eviction, opt-in idle/absolute timeouts with NIST presets, bounded trail retention with a generated sweep job.
Doesn't:
- Authentication itself β passwords, 2FA, lockout, sign-up. That's Rails auth / Devise / rodauth;
sessionsobserves whichever you chose and never replaces it. - Rate limiting β Rails 8's
rate_limitalready guards the generated login (and this gem records when it trips); use rack-attack for more. - Send emails β the
on_new_devicehook hands you the moment; your mailer (goodmail, noticed) sends the "Was this you?" email. - API/token auth tracking β that's
api_keys' lane. Token-authenticated requests (Wardenstore: false) are deliberately never tracked as sessions. - Browser fingerprinting β no canvas/WebGL/font probing, no probabilistic identifiers derived from UA/IP/client hints, ever (that's consent-gated under ePrivacy and would poison the drop-in pitch). Device identity is server-observed UA + IP plus one honest first-party cookie: the signed, random
sessions_device_idbrowser-continuity cookie β minted only at login, never on anonymous visitors β that powers device dedup and the "Last used" badge. It's documented in full under Security & privacy posture.
π₯ The "Your devices" page
Three ways to ship it, pick your layer:
- Mount the engine (the quickstart) β a complete page: device list with "This device" badge, per-row Log out, "Sign out of all other sessions", login history. Semantic
sessions-*classes with minimal styles; looks decent unstyled inside any Tailwind app. All copy through i18n (English + Spanish shipped). - Render the partials inside your own settings page β no mount needed for display:
<%= render "sessions/devices", user: current_user %>
<%= render "sessions/history", user: current_user, limit: 10 %>
(Revoke buttons render when the engine is mounted; without it you get the read-only registry.)
- Eject and restyle β
rails generate sessions:viewscopies every template intoapp/views/sessions/, where your copies shadow the gem's automatically (the Devise move).
The engine inherits from your ApplicationController (configurable via config.parent_controller), so your layout, auth and locale apply automatically. The current session is resolved on both stacks; destructive actions can be gated behind your own sudo/password-confirm flow with config.require_reauthentication. One heads-up: if your app layout leans heavily on host route helpers, isolated-engine rendering means those resolve through main_app.* β rendering the partials in your own page (layer 2) sidesteps the whole topic.
A hard rule the page enforces: you can never touch a session you don't own (foreign ids 404 β existence never leaks), and the current session is never revocable from the page (that's what sign-out is for).
π΅οΈ The trail: every login attempt, kept honest
Sessions::Event is an append-only table written through an error-isolated pipeline:
Sessions::Event.logins / .failed_logins / .logouts / .revocations / .expirations
Sessions::Event.recent.last_days(90).for_ip("203.0.113.7")
event.session # the live row it created β nil once revoked (that's the point)
event.new_device? # flagged when the login matched no prior device
Failed attempts record the identity as typed (normalized) even when no such account exists β brute-force and credential-stuffing triage needs exactly that β but they never link to an account (no enumeration oracle), never store the password, and store the auth stack's failure reason verbatim (Devise paranoid mode stays :invalid).
Tee every event into your own audit system with one line β event.summary is the audit-shaped projection (device, identity, reasons, ip, country; compacted, no raw blobs):
config.events = ->(event) do
AuditLog.log(event_type: "session.#{event.name}", user: event.user,
request: event.request, data: event.summary)
end
And for custom UIs, events and sessions share the display vocabulary so you never re-derive it: event.label / event.reason / event.reason_label (localized), event.device_name, event.source_line (the location-first one-liner β "πͺπΈ Madrid, Spain Β· IP 83.45.112.7 Β· Firefox 139 on Windows" β ready for security emails and notification bodies; pass ip: false for compact rows), session.active_now?, plus sessions_device_icon_name(session) / sessions_event_icon_name(event) view helpers (Heroicons-vocabulary names for whatever icon system you use).
π± Hotwire Native
Detection works out of the box (Hotwire Native UA marker β same contract as turbo-rails' hotwire_native_app?): platform, real OS version, and on Android the real device model (WebViews are exempt from Chrome's UA reduction). To also get app version and iOS hardware model, set the documented prefix convention in your shells:
// iOS β AppDelegate, before creating the Navigator
var u = utsname(); uname(&u)
let model = withUnsafeBytes(of: &u.machine) { String(decoding: $0.prefix(while: { $0 != 0 }), as: UTF8.self) }
Hotwire.config.applicationUserAgentPrefix =
"MyApp/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0") (\(model); iOS \(UIDevice.current.systemVersion));"
// Android β Application.onCreate, before any HotwireActivity
Hotwire.config.applicationUserAgentPrefix =
"MyApp/${BuildConfig.VERSION_NAME} (${Build.MODEL}; Android ${Build.VERSION.RELEASE}; build ${BuildConfig.VERSION_CODE});"
Sessions now read "MyApp/2.4.1 (iPhone15,2; iOS 19.5; build 241)". Validated X-Client-Platform/-Version/-Build/-OS headers are honored too, and legacy prefixes (like "MyApp Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)") parse once you declare them: config.native_app_names = ["MyApp"].
One identity rule the gem follows: a native device is one cookie jar, not one user agent β WebView navigations and native HTTP calls share the session, so they're one device row, not two.
π· Auth methods: how each session started
Password and OAuth logins classify automatically (so do devise-passwordless magic links and remember-me re-auths). Flows that can't self-identify take one line before signing the user in:
# Google One Tap endpoint:
Sessions.tag(request, method: :google_one_tap, detail: { select_by: params[:select_by] })
# Passkey verification:
Sessions.tag(request, method: :passkey, detail: { user_verified: credential.user_verified? })
β¦and custom failure paths (a native-app sign-in branch that renders 422s, a passkey SignCountVerificationError β a possible cloning signal) get the manual seam:
Sessions.record_failed_attempt(request, scope: :user, identity: params[:email],
reason: :invalid_password)
Custom Warden strategies map with config.strategy_methods = { "OtpAuthenticatable" => :otp }. Everything else is unknown β the gem never guesses.
Two-factor flows (TOTP apps, security keys, Touch ID)
Every mainstream Ruby 2FA setup creates the session at full authentication β we verified each one against its source β so the registry and trail stay correct without configuration. What varies is the labeling, and where a recipe is needed it's one line:
- devise-two-factor (GitLab/Mastodon-style TOTP + backup codes): fully automatic. It's single-phase β its strategy subclasses Devise's
DatabaseAuthenticatableand consumesparams[scope][:otp_attempt]in the same request as the password (itsstrategies/two_factor_authenticatable.rb), so Warden signs in exactly once. The gem classifiespasswordand stampsauth_detail: { second_factor: "totp" }(or"backup_code"forTwoFactorBackupablewins) when a second factor was actually used. - devise-otp: two-phase. Its replaced
database_authenticatablestrategyredirect!s OTP-enabled users to a challenge instead ofsuccess!(no session yet β nothing recorded, correctly), and the challenge'sOtpCredentialsController#updatethen calls plainsign_inwith no Warden strategy β the row records, but classifiesunknown(the gem never guesses). One initializer block labels it:
Rails.application.config.to_prepare do
DeviseOtp::Devise::OtpCredentialsController.before_action only: :update do
Sessions.tag(request, method: :password, detail: { second_factor: "totp" })
end
end
- authentication-zero
--two-factor(the generator Rails 8's authentication was modeled on): itssessionstable is Rails-8-shaped, so the install generator adopts it and the model concern tracks everyuser.sessions.create!β which its challenge controllers call only after the second factor verifies (password-phase requests stash a signedchallenge_tokenand create nothing). Tag eachcreateso rows don't classifyunknown:
Sessions.tag(request, method: :password) # SessionsController (password-only branch)
Sessions.skip!(request) # β¦and its challenge-redirect branch: the password
# was RIGHT β without this, the no-session outcome
# would read as a failed login
Sessions.tag(request, method: :password, detail: { second_factor: "totp" }) # Challenge::TotpsController
Sessions.tag(request, method: :password, detail: { second_factor: "webauthn" }) # Challenge::SecurityKeysController
Sessions.tag(request, method: :password, detail: { second_factor: "recovery_code" }) # Challenge::RecoveryCodesController
- webauthn-rails second-factor mode (YubiKeys, Touch ID, Windows Hello β all WebAuthn authenticators): the session starts in
SecondFactorAuthenticationsController#createafter verification β tag it there:
Sessions.tag(request, method: :password, detail: { second_factor: "webauthn" })
start_new_session_for user
- devise-passkeys / warden-webauthn (passkey-first, passwordless): fully automatic. That's not a second factor, it's the method β their
PasskeyAuthenticatable/Warden::WebAuthn::Strategystrategies classify aspasskeyby name. devise-passkeys' sudo confirm (reauthenticate, asign_inwithevent: :passkey_reauthentication) is recognized as a reauthentication of the live session β never a duplicate device row. - rotp / active_model_otp (the DIY primitives β pure TOTP math / model mixin, no strategies, no controllers): your controllers own the flow, so label it at the seam that fits. Verifying before creating the session:
Sessions.tag(request, method: :password, detail: { second_factor: "totp" }). A post-login step-up gate (session already live, OTP unlocks sensitive areas):Sessions.current(request)&.second_factor!("totp"). - Email/SMS login codes:
Sessions.tag(request, method: :otp).
Either way, session.second_factor? / session.second_factor (also on events) answer "was this login 2FA-protected?" β useful for step-up gates and admin triage. Failed second-factor attempts surface through the same seams as everything else: devise-two-factor failures land in the trail automatically (Warden failure, message verbatim); WebAuthn rescues should call Sessions.record_failed_attempt(request, reason: e.class.name, method: :password, detail: { second_factor: "webauthn" }) β a SignCountVerificationError there is a possible credential-cloning signal worth alerting on.
The "Last used" badge (no JavaScript required)
The conversion classic β a little "Last used" pill next to the sign-in button this browser used last time. Most implementations reach for localStorage and a sprinkle of JS; sessions answers it server-side with one lookup, because the signed browser-continuity cookie (the same one that deduplicates devices) survives logout by design:
<% last_login = Sessions.last_login(request) %>
<%= button_to "Sign in with Google", ... %>
<% if last_login&.auth_method == "oauth" && last_login.auth_provider == "google" %>
<span class="badge">Last used</span>
<% end %>
<%= button_to "Sign in with passkey", ... %>
<% if last_login&.auth_method == "passkey" %>
<span class="badge">Last used</span>
<% end %>
last_login returns the most recent login event from this browser (or nil for browsers that never signed in, cleared cookies, or tampered values β the cookie is signed), so you also get auth_method_label for copy and occurred_at for "last used 2 days ago". It's device-scoped, not account-scoped β it reflects whoever last signed in from this browser, which is exactly what a signed-out login page can honestly know β and it's read-only: it never mints the cookie.
[!NOTE] If you fragment- or page-cache your login page, render the badge outside the cached fragment β it's per-browser by nature.
Repeated failed attempts ("someone is trying to get in")
Per-attempt alerts are notification fatigue and an abuse vector (an attacker hammering the form would flood the victim's inbox), so the gem ships threshold-crossing detection instead β the hook fires exactly once when an identity crosses the line inside the window:
config.repeated_failed_logins = { threshold: 5, within: 15.minutes }
config.on_repeated_failed_logins = ->(identity:, count:, event:) do
user = User.find_by(email: identity) or next # identity is AS TYPED β may match no account
SecurityMailer.with(user: user, event: event).repeated_failed_logins.deliver_later
end
The event is the attempt that tripped the threshold β IP, location and device included. This complements (not replaces) Devise's :lockable and Rails 8's rate_limit: they stop the attacker; this tells the user.
π Geolocation (via trackdown, soft dependency)
If the trackdown gem is in your bundle, sessions and events get country/city automatically: behind Cloudflare the answer is read synchronously from request headers (free); with a MaxMind database, lookups run asynchronously in Sessions::GeolocateJob so logins never wait on geo. No trackdown β locations stay blank and the UI omits them cleanly.
[!IMPORTANT] Locations are approximate by nature (they come from the IP) and the page labels them as such. Coordinates are only stored on trail events, precision-reduced (~1km) by default.
Behind Cloudflare? request.remote_ip returns a Cloudflare edge IP unless CF ranges are trusted. Best: add cloudflare-rails and everything just works. Alternatively set config.ip_resolver = ->(request) { request.headers["CF-Connecting-IP"] || request.remote_ip } β but only if your origin is unreachable except through Cloudflare.
π The "Was this you?" moment
When a login matches no device the user has signed in from before (coarse, server-observed matching β never fingerprinting), the gem hands you the moment; you send the email:
config.on_new_device = ->(user:, session:, event:) do
SecurityMailer.with(event: event).new_device.deliver_later
end
Pass the event to your mailer, not the session: the event is a persisted, GlobalID-able record that survives revocation (the session row may already be destroyed by the time an async job runs), and it carries everything the email needs β event.user, event.device_name, event.location, event.country_flag, event.source_line, event.occurred_at:
class SecurityMailer < ApplicationMailer
def new_device
@event = params.fetch(:event)
mail to: @event.user.email, subject: "New sign-in from #{@event.device_name}. Was this you?"
end
end
A user's very first login doesn't fire it (nobody wants a security alert on signup), and like every hook in this gem it's error-isolated: a broken mailer can never break a login.
In-app notifications too? Don't fan out from the hook β that's your notification system's job. With noticed, one notifier owns every channel (feed row, push, email) and the hook stays one line:
config.on_new_device = ->(user:, session:, event:) do
NewDeviceNotifier.with(record: event, event: event).deliver(user)
end
class NewDeviceNotifier < Noticed::Event
deliver_by :email do |config|
config.mailer = "SecurityMailer"
config.method = :new_device
config.if = -> { recipient.email.present? }
end
notification_methods do
# NOT `def event` β Noticed::Notification delegates #record to its own
# `event` association (the Noticed::Event row); shadowing it recurses.
def session_event = record
def title = "New sign-in"
def body = "New sign-in from #{session_event&.source_line(ip: false)}. Was this you?"
def url = "/settings/sessions"
end
end
The trail event is the record: β persisted and GlobalID-safe, so delivery jobs render fine even after the session row is revoked, and the feed preloads it without N+1s. (X and Google run exactly this pattern: a new-device login lands in the notifications tab of every other device you're still signed in on.)
π Admin: triage surfaces in one command
Scopes are the admin product β Sessions::Event.failed_logins.last_24_hours.group(:ip_address).count works in any console or admin framework. If your app uses madmin:
rails generate sessions:madmin
β¦generates the two resources (the live registry with a per-row Revoke session action, and the login trail with its triage scopes as filters) plus their controllers, with madmin's two namespacing footguns pre-solved. The generated files use only stock madmin APIs and are yours to restyle. For a per-user security panel (devices + trail on the user's show page), load user.sessions.by_recency and user.session_events.recent in a member action β including the user's failed attempts by matching Sessions::Event.where(identity: Sessions::Event.normalize_identity(user.email)) (failures never link to accounts; matching the signed-in user's own identity is the safe way to show them).
π§Ή Retention & the sweep
The install generator drops a SessionsSweepJob into app/jobs/ β schedule it daily:
# config/recurring.yml (Solid Queue)
production:
sessions_sweep:
class: SessionsSweepJob
schedule: every day at 4am
It purges trail rows past config.events_retention (12 months by default β CNIL's recommendation for security logs), evicts per-user overflow beyond config.max_sessions_per_user (100, GitLab's number), and β only if you opted into config.idle_timeout / config.max_session_lifetime (or config.timeout_preset = :nist_aal2) β expires stale sessions. Worth knowing: the Rails 8 auth cookie lives 20 years, so this sweep is the only real session expiry most omakase apps will ever have.
π Security & privacy posture
- Tracking never breaks login. Every adapter path, parser, geo lookup and hook is error-isolated; the test suite includes a chaos test that detonates every pipeline stage at once and asserts sign-in still works.
- Tracking outages fail OPEN. A revoked session is a row that's gone; an errored lookup (sessions table unreachable, a migration mid-deploy, a timeout) is an outage β the request proceeds untracked instead of logging anyone out. Kicks are scope-precise, too: revoking a user session never touches an admin scope riding the same rack session, or your cart/locale data.
- The trail rejects rewrites. Normal Active Record mutations on events are blocked β
update/destroyraiseActiveRecord::ReadOnlyRecord. The callback-bypassing APIs (update_columns,delete_all) remain available, because the gem's own sanctioned paths use them: async geo backfill,Sessions.forget's GDPR scrub, the retention sweep. Append-only at the model-contract level β not a database constraint, and a host determined to rewrite history still can. - No usable credential is ever persisted. Devise-mode session tokens are random 32-byte values stored as SHA-256 digests; the raw token lives only in the user's own session. Rails-8-mode rows store nothing secret (the signed cookie is the credential). Nothing secret is ever logged.
- Revocation is server-side and immediate (checked on the very next request, both stacks) β OWASP ASVS 7.4.1; "view and terminate any or all currently active sessions" is literally ASVS 3.3.4 / 7.5.2, the requirement this gem exists to satisfy.
- IPs and UAs are personal data (GDPR Recital 30), processed under the network-security legitimate interest (Recital 49 / Art. 6(1)(f)). The gem ships bounded retention, optional IP truncation before persistence (
config.ip_mode = :truncatedβ zeroes the last IPv4 octet / 80 IPv6 bits, the Google Analytics precedent), data minimization (no bodies, no referrers), andSessions.forget(user)for erasure requests. - The browser-continuity cookie, stated plainly.
sessions_device_idis a signed random UUID β no fingerprint material in it β set only when someone signs in (anonymous visitors never get one), with a 5-year lifetime that survives logout by design: that's what lets a re-login replace its old device row instead of stacking duplicates, and what powers the signed-out "Last used" badge. It identifies the browser installation, not the person β two accounts sharing a browser share it (each keeps their own rows). The id is stored on session rows and login events, so it lives under the same retention sweep as everything else;Sessions.forget(user)removes the user's rows and trail, and clearing browser cookies orphans the id entirely (an id with no rows resolves to nothing). It's a security-purpose first-party cookie (session integrity/dedup), which most ePrivacy readings treat as strictly-necessary rather than consent-gated β but it exists, it persists, and your privacy policy should mention it. - Want encryption at rest?
Session.encrypts :ip_address, deterministic: true(deterministic keeps equality queries working β the documented Rails tradeoff) and non-deterministic foruser_agent.
Configuration reference
Everything lives in one annotated initializer (config/initializers/sessions.rb, written by the install generator). The defaults work untouched:
Sessions.configure do |config|
# β Behavior β
config.touch_every = 5.minutes # last_seen_at throttle (nil = never touch)
config.max_sessions_per_user = 100 # oldest-eviction; nil = unlimited
config.idle_timeout = nil # opt-in expiryβ¦
config.max_session_lifetime = nil # β¦or config.timeout_preset = :nist_aal2
config.revoke_on_password_change = true # ASVS 3.3.3
config.revoke_remember_me = true # Devise: revoke also rotates remember-me
config.track_failed_logins = true
# β Device intelligence β
config.ua_parser = :browser # :device_detector | ->(ua, headers) { {...} }
config.request_client_hints = false # Accept-CH for real platform versions / Android models
config.native_app_names = [] # legacy native UA prefixes to recognize
# β IP & geo β
config.ip_resolver = ->(request) { request.remote_ip }
config.ip_mode = :full # | :truncated (anonymize before persistence)
config.geolocate = :auto # trackdown when present | :off
config.geo_precision = 2 # lat/lng decimals on events (~1km)
# β Retention β
config.events_retention = 12.months # trail purge horizon (nil = keep forever)
# β Hooks (kwargs, no-op defaults, error-isolated) β
config.on_new_device = ->(user:, session:, event:) {}
config.on_session_revoked = ->(session:, by:, reason:) {}
config.events = ->(event) {} # catch-all tee β AuditLog / analytics
# β Integration β
config.parent_controller = "::ApplicationController"
config.current_user_method = :current_user # chain: this β current_user β Current.session&.user
config.authenticate_method = :authenticate_user!
config.layout = nil # nil inherits the parent controller's layout
config.require_reauthentication = nil # ->(controller) { ... } sudo gate
config.session_class = "Session"
config.strategy_methods = {} # { "OtpAuthenticatable" => :otp }
end
π§± Why the models?
Two primitives, linked β rows are active sessions; events are history:
sessions(the registry β your table, Rails-8-shaped on both stacks): one row = one signed-in device. Destroyed on logout/revocation/expiry, which is what makes revocation instant β both adapters resolve the row on every request, so a missing row is a remote logout. No soft-delete state machine.sessions_events(the trail β gem-owned, append-only): what happened and from where, surviving the rows it describes. Itssession_idis a plain column with no foreign key on purpose: history must outlive the registry.
On Rails 8 auth, the gem adopts the generated table and model: one migration adds columns (the add_devise_to_users precedent), and the 2-line Session model is decorated via a concern at boot β your generated code stays byte-identical. On Devise, the install generator creates the same Rails-8-shaped table and a 3-line shell model β so if you ever migrate Devise β Rails auth, your sessions table is already exactly where Rails expects it.
Why this gem exists
Rails 8's authentication generator creates a database-backed sessions table with ip_address and user_agent on every row β and then ships zero UI, no session listing, no per-device revocation, no failed-login log. The Rails security guide literally recommends a Session.sweep based on updated_at that the generated code can never satisfy, because nothing ever touches a session row after creation. Devise stores even less: two sign-in slots on the users table, overwritten on every login, with cookie sessions that are unenumerable and unrevocable server-side β people have been asking for a decade.
Meanwhile Laravel ships "Browser Sessions" in its starter kit, Phoenix's mix phx.gen.auth tracks every session token in a table, OWASP ASVS makes "view and terminate your sessions" a Level-2 requirement, and GitLab, Mastodon and Discourse have each independently hand-rolled (and maintain, forever) this exact feature set. Every serious Rails app eventually rebuilds the same thing: a sessions page, a login trail, a revocation mechanism, a device parser. sessions is that rebuild, done once, done right β on top of the auth you already own, never instead of it.
Database support
PostgreSQL (including PostGIS), MySQL, and SQLite. The migrations adapt automatically: they honor your app's configured primary key type (uuid or bigint β same detection rails g model uses) and pick jsonb on Postgres / json elsewhere, resolved at migration run time so one migration file survives a dev-SQLite/prod-Postgres split. Works on Rails 7.1+ and shines on the Rails 8 omakase.
Testing
The gem is tested with Minitest against a real dummy host app whose auth files are vendored verbatim from rails generate authentication β so the adapter's duck-detection and prepends run against the actual generated shapes, and upstream template drift breaks CI here instead of login there. The Warden adapter runs against a real Warden::Manager rack stack (the exact hook ABI Devise rides), and a chaos test detonates every hook and pipeline stage at once to prove sign-in survives.
bundle exec rake test # full suite
bundle exec appraisal install # then test across Rails versions:
bundle exec appraisal rails-7.1 rake test
bundle exec appraisal rails-8.1 rake test
Testing your own app with the engine mounted β one Rails gotcha worth knowing: in an integration test, after a request to any engine route, the test session keeps that request's url_options (including the engine's mount point as script_name), so host route helpers called next generate prefixed paths (settings_path β /settings/sessions/settings). Use literal paths (get "/settings") after driving engine routes β real requests are unaffected (script_name resolves per request).
Development
After checking out the repo, run bundle install, then bundle exec rake test. The dummy app lives in test/dummy and mounts the engine at /settings/sessions exactly like a real host.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/sessions. Our code of conduct is: just be nice and make your mom proud of what you do and post online.
License
The gem is available as open source under the terms of the MIT License.