Cyber Security

Secure PHP Sessions for Small Applications — Protect the ID from Login to Logout

Secure PHP Sessions for Small Applications — Protect the ID from Login to Logout

A login form can verify the right password and still leave the account resting on a weak session. The difficult part begins after authentication: how does the application recognize the next request, how long should that recognition remain valid, and what happens when the user logs out?

For a small native-PHP application, the answer is often a server-side session whose identifier travels in a cookie. That is a reasonable design, but the identifier temporarily becomes almost as useful as the credential that created it. OWASP therefore describes an authenticated session ID as temporarily equivalent to the authentication method behind it. If someone can steal, predict, or fix that identifier, the password check may no longer protect the requests that follow.

This is not a recipe for “perfectly secure sessions.” No single configuration provides that. It is a practical way to review the whole lifecycle: creating an ID, carrying it in a cookie, rotating it when privileges change, expiring it on the server, and removing it during logout.

Begin with the session lifecycle, not a list of flags

A useful mental model is a coat-check ticket. The browser holds the ticket; the application keeps the coat and the associated information. The ticket should reveal nothing valuable by itself, but anyone holding a valid ticket may be treated as its owner. This is why an opaque session ID still needs careful protection.

The lifecycle has at least five transitions:

  1. An anonymous request receives or resumes a session.
  2. A successful login changes that session from anonymous to authenticated.
  3. Ordinary requests continue using the authenticated session.
  4. Inactivity or an absolute deadline makes the session invalid.
  5. Logout clears the application state, browser cookie, and server-side record.

A secure cookie on step one does not repair a missing ID rotation on step two. A good idle timeout does not repair a logout handler that leaves the browser cookie behind. Reviewing transitions exposes gaps that a checklist of isolated settings can hide.

Harden the session before starting it

PHP allows session configuration in php.ini, pool configuration, or application code. When an application uses session_set_cookie_params(), the PHP manual requires it to run before session_start() and notes that its effect lasts only for the current script. A central bootstrap included by every authenticated route is safer than repeating slightly different settings across controllers.

<?php
declare(strict_types=1);

ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.use_trans_sid', '0');

session_set_cookie_params([
    'lifetime' => 0,
    'path' => '/',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);

session_start();

This example assumes the site is served only over HTTPS. On a plain-HTTP development origin, a Secure cookie will not behave like a production cookie, so testing should use local HTTPS rather than weakening production settings.

What each choice actually does

session.use_strict_mode tells PHP not to adopt a session ID that its session handler did not initialize. PHP calls strict mode a required session-hardening setting, while also describing it as a mitigation rather than a complete answer to fixation. session.use_only_cookies prevents PHP from accepting the identifier through alternate mechanisms such as query or form parameters. That also keeps IDs out of URLs, browser history, copied links, and common access logs.

The cookie attributes reduce different risks:

  • Secure limits transmission to secure connections.
  • HttpOnly prevents browser scripts from reading the cookie through APIs such as document.cookie.
  • SameSite=Lax restricts some cross-site cookie sending while preserving common top-level navigation flows.

These controls are narrower than their names may suggest. HttpOnly does not cure cross-site scripting: injected script may still perform actions from the victim's page even if it cannot read the ID. SameSite is defense in depth, not a replacement for CSRF tokens. Secure requires HTTPS for the whole session, not only the login response.

The example deliberately omits Domain. Under the cookie rules defined by RFC 6265, omitting it keeps the cookie scoped to the host that set it. Adding a parent domain makes the cookie available to subdomains and increases the number of applications that can affect it. Broader scope should be an explicit requirement, not a convenience default.

A lifetime of zero creates a non-persistent cookie, but it should not be interpreted as “the user is certainly logged out when the last tab closes.” MDN notes that browsers define when a session ends, and session restoration can preserve such cookies. Server-side expiry remains necessary.

Rotate the ID when trust changes

The session ID used by an anonymous visitor should not simply become the authenticated ID. After credentials are verified, rotate it before treating subsequent requests as authenticated:

if ($credentialsAreValid) {
    if (!session_regenerate_id(false)) {
        throw new RuntimeException('Could not rotate the session ID');
    }

    $_SESSION['user_id'] = $userId;
    $_SESSION['authenticated_at'] = time();
    $_SESSION['last_activity_at'] = time();
}

Rotation matters after login and other privilege changes, such as entering an administrator mode. It breaks the simple fixation sequence in which an attacker supplies a known anonymous ID, waits for the victim to authenticate it, and then reuses it.

The boolean argument deserves more attention than many short examples give it. The PHP documentation warns that immediately deleting old session data can produce lost sessions or inconsistent state when concurrent requests or unstable networks are involved. Keeping the old record indefinitely is also undesirable. A higher-risk or highly concurrent application needs an explicit transition design: mark the old ID as obsolete, allow only a short overlap where justified, reject it after that window, and log suspicious reuse without logging the raw ID.

The small example uses false to avoid claiming that immediate deletion is universally safe. It is not a complete revocation implementation. Applications with parallel AJAX requests, multiple workers, or a custom Redis/database handler should design and test this transition against their actual storage and concurrency model.

Enforce expiry in application logic

Cookie expiry controls how long the browser may retain a cookie. PHP's garbage collection controls when old server-side records may be removed. Neither one, by itself, expresses the application's authorization policy. The PHP manual specifically cautions against relying on session.gc_maxlifetime as a guaranteed expiry mechanism and recommends timestamp-based lifetime management.

Two deadlines answer different questions:

  • An idle timeout asks how long an authenticated session may remain unused.
  • An absolute timeout asks how old the session may become even if it remains active.
$now = time();
$idleLimit = 30 * 60;
$absoluteLimit = 8 * 60 * 60;

$idleExpired = isset($_SESSION['last_activity_at'])
    && $now - $_SESSION['last_activity_at'] > $idleLimit;

$absoluteExpired = isset($_SESSION['authenticated_at'])
    && $now - $_SESSION['authenticated_at'] > $absoluteLimit;

if ($idleExpired || $absoluteExpired) {
    // Run the same complete invalidation used by logout, then require login.
} else {
    $_SESSION['last_activity_at'] = $now;
}

The numbers here illustrate the mechanism, not a universal policy. An editorial dashboard, a public preference session, and a payment approval screen have different consequences and usability costs. Choose limits from the sensitivity of the action, expected user behavior, and the availability of reauthentication. Store timestamps on the server, compare them on every protected request, and use a consistent server clock.

Logout has three layers

session_destroy() sounds complete, but its documented behavior is narrower: it destroys data associated with the current session and does not unset $_SESSION or delete the session cookie. A complete logout normally needs to clear all three layers.

$_SESSION = [];

if (ini_get('session.use_cookies')) {
    $params = session_get_cookie_params();

    setcookie(session_name(), '', [
        'expires' => time() - 42000,
        'path' => $params['path'],
        'domain' => $params['domain'],
        'secure' => $params['secure'],
        'httponly' => $params['httponly'],
        'samesite' => $params['samesite'] ?: 'Lax',
    ]);
}

session_destroy();

The deletion cookie must use the same name and compatible path/domain scope as the original. Otherwise, the browser may retain the original cookie. Concurrent requests remain a caveat here too: one in-flight request can interact with state while another is logging out. Sensitive systems may need server-side revocation records or account-wide session management instead of relying only on deletion of one record.

Verify behavior rather than trusting configuration

A review can be performed without attacking the application:

  1. Inspect the login response in browser developer tools. Confirm the session cookie has Secure, HttpOnly, the intended SameSite value, and no unnecessary Domain.
  2. Record only whether the ID changes, not the ID itself. It should rotate across a successful login and other privilege changes.
  3. Send an invented, unknown session ID to a test environment. With strict mode and the intended handler, PHP should issue or use a valid server-generated ID rather than adopt the supplied one.
  4. Advance controlled test timestamps to check idle and absolute expiry independently.
  5. Log out, then confirm that the browser cookie is removed and a replay of the old test cookie cannot access a protected route.
  6. Run concurrent-request tests around rotation and logout if the interface makes parallel requests.

Logs should record events such as creation, rotation, expiry reason, and logout, but not raw session IDs. Hashing an ID for correlation can still create sensitive linkable data, so retention and access should be limited.

What session hardening does not solve

A well-managed session can still authorize the wrong action if the application has broken access control. It can still be used by malicious script running in the page. It does not replace output encoding, CSRF protection, TLS, password security, rate limiting, or reauthentication before a particularly sensitive operation.

Binding a session rigidly to an IP address is not a simple cure either. Mobile networks, proxies, and changing addresses can reject legitimate users, while an attacker may share an apparent network path. Signals such as IP changes can inform risk detection, but treating them as proof of identity deserves careful testing.

A session is a process, not a cookie flag

The strongest improvement is not one magical option. It is consistency across the lifecycle: accept only server-generated IDs, carry them only in narrowly scoped secure cookies, rotate when trust changes, enforce timeouts on the server, and remove every layer at logout.

There is still a design choice around concurrency and old-ID invalidation, and the right timeout depends on the application. Making those choices explicit is more honest and more useful than copying a “secure session” snippet and assuming the work is finished.

References