๐Ÿ“ฑ Device Sessions๏ƒ

Device Sessions let you track every device and browser from which a user has logged in. Users can see their active sessions and sign out from any device remotely โ€” the same โ€œManage active sessionsโ€ feature you see in apps like GitHub, Google, or Slack.

๐Ÿ“‹ Table of Contents๏ƒ


How It Works๏ƒ

When a user logs in, a record is created in the auth_device_sessions table containing:

Field

Description

user_id

The user who logged in

uuid

A unique identifier for this session (UUID v7)

ip_address

The IP address at login time

user_agent

The browser/device string

last_active

Timestamp of the most recent activity

logged_out_at

When the session was terminated (null = still active)

trusted_until

Datetime until which this device skips 2FA (null = not trusted)

created_at

When the session was created

When the user logs out, the session record is updated with a logged_out_at timestamp.


Configuration๏ƒ

Enable device session tracking in app/Config/Auth.php:

public array $sessionConfig = [
    'field'                => 'user',
    'allowRemembering'     => true,
    'rememberCookieName'   => 'remember',
    'rememberLength'       => 30 * DAY,

    // Enable device session tracking
    'trackDeviceSessions'  => true,
];

With trackDeviceSessions set to true, every successful login call to Session::startLogin() automatically creates a device session record.


Database Migration๏ƒ

Device sessions require the auth_device_sessions table, which is created by the migration at:

src/Database/Migrations/2026-02-26-000002_create_device_sessions.php

Run it with:

php spark migrate --all

The table also includes a uuid column for safe external exposure (never expose the integer id directly in APIs or URLs).


Viewing Active Sessions๏ƒ

The HasDeviceSessions trait is mixed into the User entity and provides all session management methods.

Get All Sessions for a User๏ƒ

$user     = auth()->user();
$sessions = $user->getDeviceSessions();

foreach ($sessions as $session) {
    echo $session->ip_address;      // "203.0.113.42"
    echo $session->user_agent;      // "Mozilla/5.0 (Macintosh; ...)"
    echo $session->created_at;      // "2026-02-28 10:30:00"
    echo $session->last_active;     // "2026-02-28 12:15:00"
    echo $session->uuid;            // "0195d8b2-..." (safe to expose)
}

Get Only Active (Not Logged-Out) Sessions๏ƒ

$activeSessions = $user->getActiveDeviceSessions();

Identify the Current Session๏ƒ

Each device session is linked to the current PHP session ID. You can highlight the โ€œcurrent deviceโ€ in the UI:

$currentSessionId = session()->get('device_session_id');

foreach ($user->getActiveDeviceSessions() as $session) {
    $isCurrent = ($session->id === $currentSessionId);
}

Terminating Sessions๏ƒ

Terminate a Specific Session๏ƒ

// Using the session's UUID (safe for URLs/forms)
$user->terminateDeviceSessionByUuid($uuid);

// Using the internal integer ID (internal use only)
$user->terminateDeviceSession($id);

Terminate All Other Sessions (Keep Current)๏ƒ

Useful for โ€œSign out everywhere elseโ€ functionality:

$currentSessionId = session()->get('device_session_id');
$user->terminateOtherDeviceSessions($currentSessionId);

Terminate All Sessions (Including Current)๏ƒ

$user->terminateAllDeviceSessions();
// Then redirect to login

Concurrent Session Limit๏ƒ

Cap how many simultaneous active sessions a single user can hold. When a new login pushes the count above the limit, the oldest sessions are terminated automatically.

Enable๏ƒ

// app/Config/Auth.php

// 0 = unlimited (default).
// 5 = at most 5 concurrent sessions; oldest are terminated on each new login.
public int $maxConcurrentSessions = 5;

Requires sessionConfig.trackDeviceSessions = true.

Behaviour๏ƒ

DeviceSessionRecorder::recordSession() calls DeviceSessionModel::enforceConcurrentSessionLimit() before creating the new row:

  1. Counts active sessions for the user (logged_out_at IS NULL).

  2. If the count + 1 (the new session about to be created) would exceed the limit, the oldest active rows are terminated via terminateSession() โ€” by last_active ascending โ€” until exactly limit - 1 remain.

  3. The new session is then inserted normally.

Use cases๏ƒ

Scenario

Suggested limit

SaaS with per-seat licensing

1 (single device)

Consumer app

5โ€“10

API portal / dev tools

0 (unlimited)

Edge cases๏ƒ

  • The session a user is currently using can be among the terminated ones โ€” they will be redirected to login on their next request from that device.

  • The PHP session cookie itself remains valid until the next request (the auth filter then sees logged_out_at != null and rejects).

  • Forcing the user to log out from the current device is intentional when the limit is set to 1: it implements โ€œsingle deviceโ€ licensing.


Trusted Devices (2FA bypass)๏ƒ

The trusted_until column on auth_device_sessions powers the โ€œTrust this deviceโ€ feature in 2FA. After successful TOTP verification, the user can opt to skip 2FA on the same device for a configurable period.

See TOTP โ€” Trust This Device for the full user flow, security properties, and revocation paths.

Helper methods on the model๏ƒ

/** @var \Daycry\Auth\Models\DeviceSessionModel $model */
$model = model(\Daycry\Auth\Models\DeviceSessionModel::class);

// Trust the session identified by $uuid for $lifetime seconds.
$model->markTrusted($uuid, 30 * DAY);

// Returns the DeviceSession if trusted_until > now, else null.
$session = $model->findTrustedByUuid($uuid);

// Clears the trust flag.
$model->revokeTrust($uuid);

Login Activity Feed๏ƒ

A user-facing endpoint that shows the userโ€™s recent login attempts (success + failure) โ€” distinct from device sessions, this lists every entry from auth_logins so the user can spot suspicious activity targeting their account.

// Wire the route once
$routes->group('account/security', ['filter' => 'session', 'namespace' => 'Daycry\Auth\Controllers'], static function ($routes) {
    $routes->get('activity', 'UserSecurityController::loginActivity', ['as' => 'security-activity']);
});

The default view (Views/security/login_activity.php) is a Bootstrap 5 table showing timestamp, success/failure, identity type, IP, and User-Agent. Override the view via setting('Auth.views')['security_login_activity'].

The ?limit=NN query parameter (default 25, capped at 100) controls how many recent entries are shown.


UserSecurityController Integration๏ƒ

Daycry Auth includes a UserSecurityController with ready-made actions for session management. Register the routes:

// app/Config/Routes.php
$routes->group('security', ['filter' => 'session', 'namespace' => 'Daycry\Auth\Controllers'], static function ($routes) {
    // Device sessions
    $routes->get('sessions',                     'UserSecurityController::deviceSessionsView', ['as' => 'security-sessions']);
    $routes->delete('sessions/(:segment)',       'UserSecurityController::terminateDeviceSession/$1');
    $routes->delete('sessions/other/all',        'UserSecurityController::terminateOtherDeviceSessions');
});

Building a Sessions Management Page๏ƒ

Here is a complete, working example of a device sessions page.

Controller๏ƒ

<?php

namespace App\Controllers;

use App\Controllers\BaseController;

class SecurityController extends BaseController
{
    public function sessionsView()
    {
        $user             = auth()->user();
        $sessions         = $user->getActiveDeviceSessions();
        $currentSessionId = session()->get('device_session_id');

        return view('security/sessions', [
            'sessions'         => $sessions,
            'currentSessionId' => $currentSessionId,
        ]);
    }

    public function terminateSession(string $uuid)
    {
        $user = auth()->user();
        $user->terminateDeviceSessionByUuid($uuid);

        return redirect()->back()->with('message', 'Session terminated successfully.');
    }

    public function terminateOtherSessions()
    {
        $user             = auth()->user();
        $currentSessionId = session()->get('device_session_id');

        $user->terminateOtherDeviceSessions($currentSessionId);

        return redirect()->back()->with('message', 'All other sessions have been terminated.');
    }
}

View๏ƒ

<!-- app/Views/security/sessions.php -->
<div class="container mt-4">
    <div class="d-flex justify-content-between align-items-center mb-3">
        <h2>Active Sessions</h2>
        <?= form_open('security/sessions/other/all', ['method' => 'delete']) ?>
            <button type="submit" class="btn btn-warning btn-sm"
                    onclick="return confirm('Sign out all other sessions?')">
                Sign Out Everywhere Else
            </button>
        <?= form_close() ?>
    </div>

    <?php if (session('message')): ?>
        <div class="alert alert-success"><?= session('message') ?></div>
    <?php endif ?>

    <div class="list-group">
        <?php foreach ($sessions as $session): ?>
            <div class="list-group-item d-flex justify-content-between align-items-center">
                <div>
                    <!-- Parse user agent for a friendly name -->
                    <strong>
                        <?php
                            // Simple device type detection
                            $ua = $session->user_agent ?? '';
                            if (str_contains($ua, 'Mobile')) echo '๐Ÿ“ฑ Mobile';
                            elseif (str_contains($ua, 'Tablet')) echo '๐Ÿ“Ÿ Tablet';
                            else echo '๐Ÿ’ป Desktop';
                        ?>
                    </strong>

                    <?php if ($session->id === $currentSessionId): ?>
                        <span class="badge bg-success ms-2">Current Session</span>
                    <?php endif ?>

                    <div class="text-muted small mt-1">
                        IP: <?= esc($session->ip_address) ?>
                        &nbsp;ยท&nbsp;
                        Last active: <?= esc($session->last_active) ?>
                        &nbsp;ยท&nbsp;
                        Signed in: <?= esc($session->created_at) ?>
                    </div>
                </div>

                <?php if ($session->id !== $currentSessionId): ?>
                    <?= form_open('security/sessions/' . esc($session->uuid), ['method' => 'delete']) ?>
                        <button type="submit" class="btn btn-outline-danger btn-sm">
                            Sign Out
                        </button>
                    <?= form_close() ?>
                <?php endif ?>
            </div>
        <?php endforeach ?>
    </div>
</div>

New Device Login Notification๏ƒ

You can notify users when a login occurs from an unrecognized device by listening to the login event:

<?php

// app/Config/Events.php
use CodeIgniter\Events\Events;

Events::on('login', static function ($user) {
    // Compare current IP/UA against known sessions
    $knownIps = array_column($user->getActiveDeviceSessions(), 'ip_address');
    $currentIp = service('request')->getIPAddress();

    if (! in_array($currentIp, $knownIps, true)) {
        // Send a notification email
        $email = service('email');
        $email->setTo($user->email)
              ->setSubject('New sign-in to your account')
              ->setMessage(
                  "A new sign-in to your account was detected from IP: {$currentIp}.\n" .
                  "If this wasn't you, please change your password immediately."
              )
              ->send();
    }
});

Admin CLI๏ƒ

Administrators can terminate every active session for a user from the CLI โ€” useful for support cases (โ€œthey think someone has access to their account, kick everyone offโ€):

php spark auth:sessions terminate -e alice@example.com
php spark auth:sessions terminate -i 42

This sets logged_out_at on every active row in auth_device_sessions for the user. Their next request from any browser/device falls back to login.

The PHP session ID lives in the cookie until the userโ€™s next request. The auth filter then sees there is no matching active row and rejects.

See CLI Commands โ€” auth:sessions for the full reference.


Testing Device Sessions๏ƒ

<?php

namespace Tests\Authentication;

use Tests\Support\DatabaseTestCase;

class DeviceSessionTest extends DatabaseTestCase
{
    public function testLoginCreatesDeviceSession(): void
    {
        // Ensure tracking is enabled
        $this->injectMockAttributes(['sessionConfig' => ['trackDeviceSessions' => true]]);

        $user = $this->createUser('user@example.com', 'secret');

        auth('session')->attempt(['email' => 'user@example.com', 'password' => 'secret']);

        $sessions = $user->getActiveDeviceSessions();
        $this->assertCount(1, $sessions);
    }

    public function testTerminateSessionRemovesRecord(): void
    {
        $this->injectMockAttributes(['sessionConfig' => ['trackDeviceSessions' => true]]);

        $user = $this->createUser('user@example.com', 'secret');
        auth('session')->attempt(['email' => 'user@example.com', 'password' => 'secret']);

        $sessions = $user->getActiveDeviceSessions();
        $this->assertCount(1, $sessions);

        $user->terminateDeviceSessionByUuid($sessions[0]->uuid);

        $this->assertCount(0, $user->getActiveDeviceSessions());
    }
}

Security Tips๏ƒ

Best Practices

  • Always use the uuid column when referencing sessions in URLs or API responses โ€” never expose the integer id.

  • Show the user a โ€œwhen and from whereโ€ summary so they can spot unfamiliar sessions.

  • Consider sending an email notification on new device logins for high-security applications.

  • Pair device sessions with per-user account lockout for defense in depth.


๐Ÿ”— See also: