Session Security in PHP: The Three Problems That Are Invisible Until They Are Not

17 min read 3,227 words
Session Security in PHP: The Three Problems That Are Invisible Until They Are Not featured image

Session Fixation: When the Attacker Sets the Session ID Before You Log In

Session fixation is one of those vulnerabilities that seems theoretical until it happens to your application. The attacker does not need to steal anything. They simply set the session ID before the victim logs in, and after authentication, they walk straight into the account alongside the legitimate user.

The attack sequence is straightforward. The attacker visits the target site and receives a session ID from PHP, something like PHPSESSID=a3f8b21c9d3e4f7a6b2c8e1d0f9a2b5c. The attacker then sends the victim a link to the target site that includes this session ID as a parameter: https://example.com/login?PHPSESSID=a3f8b21c9d3e4f7a6b2c8e1d0f9a2b5c.

The victim clicks the link, visits the site, and logs in. Here is the critical part: the application does not regenerate the session ID on login. It keeps using the session ID the attacker already knows. Both the attacker and the victim now share the same session. When the victim authenticates, the attacker automatically has an authenticated session with the same ID.

The fix is a single function call that must happen immediately after every successful authentication, before any content that requires authentication is sent to the browser:

session_regenerate_id(true);

The true argument destroys the old session file. Without it, PHP keeps both sessions active and the old session data, including the pre-authentication state, remains accessible. Using session_regenerate_id(false) is a common mistake that provides a false sense of security while leaving the fixation vector open. The regeneration must happen immediately after the authentication check succeeds, before any output is sent.

Session fixation is especially dangerous in applications that accept session IDs from URL parameters. If your application reads session IDs from GET parameters, the attack above works trivially. This is why cookie-based sessions are significantly safer than URL-based sessions. PHP's default configuration uses cookies, but applications can be configured to fall back to URL session IDs, and some older or poorly written applications do exactly that.

Beyond login, session regeneration should also happen at privilege transitions. When a regular user escalates to admin access, regenerate the session ID. When a user changes their password, regenerate the session ID. Each time the trust level of the session changes meaningfully, a new session ID closes any window where an attacker might have had access to the old one.

For a broader view of authentication security patterns, a guide to PHP sessions and login state covers these patterns in a practical context.

Session Hijacking: When the Attacker Steals the Session ID After It Is Set

Session hijacking covers several different attack paths, all of which share the same goal: obtaining a valid session ID and using it to impersonate the user. The most common methods are network eavesdropping, cross-site scripting, and physical device access.

Network eavesdropping is the simplest to understand. If your application is served over HTTP instead of HTTPS, the session cookie travels across the network in plain text. Anyone on the same WiFi network, anyone who can intercept traffic at any point between the user and the server, and anyone with access to the server's network interfaces can read the session cookie and immediately use it.

This is why HTTPS is not optional for applications that use sessions. Without it, session hijacking is trivial for anyone on the same network.

Cross-site scripting provides a way to steal cookies even when HTTPS is in use. If the application reflects user input without proper output encoding, an attacker injects a script that reads document.cookie and sends it to a server the attacker controls. The script runs in the context of your domain, which means it has access to cookies that are not marked HttpOnly.

This is why output encoding is not optional: XSS and session hijacking are directly connected, and fixing XSS is the primary defence against this attack path.

PHP provides two cookie flags that make session hijacking significantly harder to execute. The HttpOnly flag tells the browser not to make the cookie available to JavaScript. Most modern browsers honour this. It does not prevent all XSS-based cookie theft but it removes the most common script-based path.

The Secure flag tells the browser only to send the cookie over HTTPS connections. Even if your site supports both HTTP and HTTPS, the session cookie will only travel over the encrypted connection, which prevents plain-text interception on unencrypted network paths.

ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);

These flags should be set at the configuration level rather than relying on ini_set calls in application code. The OWASP Top 10 for business web applications covers session-related vulnerabilities alongside other common security risks that affect PHP applications.

Missing Session Timeouts and Abandoned Sessions

PHP sessions last until the browser is closed by default. An authenticated session can persist for hours or days if the browser stays open on a laptop or desktop. This is a serious problem for applications on shared devices. Someone logs in, walks away to a meeting, and leaves a browser open. The next person who sits down has full access to the authenticated session without needing a password.

The fix is to implement session timeout at the application level. Track the last activity timestamp in the session and check on every request whether enough time has passed that the session should be considered expired:

session_start();

$timeout = 1800; // 30 minutes in seconds

if (isset($_SESSION['last_activity']) && 
    (time() - $_SESSION['last_activity'] > $timeout)) {
    session_destroy();
    header('Location: /login?expired=1');
    exit;
}

$_SESSION['last_activity'] = time();

This approach has a practical limitation worth understanding. The session is only checked when the user makes a request. A session left open on an abandoned browser that never makes another request will not expire until the garbage collector runs, which PHP triggers probabilistically based on session.gc_maxlifetime. For active sessions this approach works well. For abandoned sessions, you also need to set a short session.gc_maxlifetime to ensure the server-side session data is cleaned up within a reasonable window.

The absolute maximum session lifetime should not exceed what the application actually needs. An application where users log in once and stay logged in for weeks needs different handling than a financial application where session expiry after 15 minutes of inactivity is appropriate. Choose the timeout based on what the data in the application warrants.

PHP Session Configuration Settings That Matter

There are several PHP configuration settings that control session security behaviour. Setting these correctly at the php.ini level, or in an .htaccess or .user.ini file for Apache or in the Nginx PHP-FPM pool configuration, provides a baseline that applies to every session in the application without requiring individual code changes.

session.cookie_httponly = 1
session.cookie_secure = 1
session.use_strict_mode = 1
session.gc_maxlifetime = 1800
session.cookie_samesite = Strict

The use_strict_mode setting is particularly important and it is not enabled by default in most PHP installations. When enabled, PHP will not accept a session ID that the application itself did not create. This prevents attackers from setting a known session ID via URL parameter or cookie and then using it to hijack a session.

Without strict mode, PHP accepts any session ID the client sends, even if the application never created that ID. Combined with session fixation, this is a complete attack path. With strict mode enabled, PHP-generated session IDs are the only ones accepted.

SameSite cookies control whether the session cookie is sent with cross-site requests. Setting this to Strict means the cookie is only sent on same-site requests. Setting it to Lax means it is sent on same-site requests and on top-level cross-site GET requests, but not on subresource requests like AJAX or image loads from third-party domains.

SameSite: Lax provides significant protection against cross-site request forgery while maintaining better usability for users who navigate to your site from external links. SameSite: Strict provides stronger protection but can create usability issues when links from other sites bring users to your application.

None of these settings are enabled by default in a standard PHP installation. They are not obscure. They do not require additional libraries. They exist in every PHP version that is currently supported. They just need to be enabled.

Session Storage: What You Are Using and What You Should Be Using

PHP stores session data in files by default, in the directory specified by session.save_path. On a shared server, other websites on the same server may be able to read those files if filesystem permissions are not configured correctly. Even on a dedicated server, session files written to a world-readable directory are a problem waiting to happen.

The default /tmp directory on many Linux systems is world-readable. Any user on the server can list /tmp and read session files belonging to other users or other websites. For applications handling sensitive data, session files should be stored in a location that is only accessible to the web server process and the application.

Database session storage solves this and adds the ability to track and invalidate sessions across multiple servers. If your application runs on multiple servers behind a load balancer, file-based sessions cause users to be logged out when their requests hit a different server than the one where their session file exists. A shared database session store keeps sessions consistent across all application instances. More importantly, database storage gives you visibility into active sessions and the ability to revoke them individually or globally, which file storage does not support.

Redis is a better option for session storage in high-traffic applications. It is significantly faster than database storage for read and write operations, supports automatic expiry, and handles concurrent access well. PHP can be configured to use Redis as the session save handler:

session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379?database=0"

For most applications, moving session storage out of the default filesystem location and into a database or Redis is a worthwhile hardening step that takes less than an hour to implement. The security improvement is substantial, and for applications that need to scale across multiple servers, it is essential.

What to Check in an Existing Application

Auditing session security in an existing PHP application follows a systematic checklist. Start with configuration and work toward code. This approach catches the most common issues first and saves time compared to reading through authentication code before knowing whether the basic protections are even in place.

First, check the session cookie flags in the running PHP configuration: session.cookie_httponly, session.cookie_secure, session.use_strict_mode, and session.cookie_samesite. The most common finding in a session security audit is that none of these are enabled. They are straightforward to enable and provide meaningful protection immediately.

Second, check whether session_regenerate_id(true) is called on every successful authentication. Search the codebase for session_regenerate_id and verify it is called with true as the argument immediately after login succeeds. Also check whether it is called at privilege transitions.

Third, check whether the application enforces session timeout at the application level. Look for last_activity or equivalent timestamp tracking in the session. If it is not present, add it. The implementation is a dozen lines of code.

Fourth, check whether session IDs are accepted from URL parameters. If session.use_only_cookies is not set to 1, or if the application explicitly reads session IDs from $_GET or $_REQUEST, that is a session fixation risk. Force cookie-only sessions:

session.use_only_cookies = 1;

Fifth, verify that HTTPS is enforced across the entire application, not just on the login page. A session established on an HTTPS page can be hijacked if the application later serves pages over HTTP and the cookie does not have the Secure flag set.

For a detailed checklist approach to reviewing PHP security, a PHP security checklist for business websites covers session configuration alongside other important hardening steps.

Common Mistakes That Undermine Session Security

Several recurring mistakes appear frequently in PHP applications that use sessions. Understanding them helps you avoid introducing them in new code and gives you specific things to look for during code reviews.

The first mistake is regenerating session IDs with the wrong argument. Using session_regenerate_id(false) keeps the old session data accessible under the old session ID. An attacker who knows the old ID can still read session data after regeneration if the old ID was exposed before login. Always use true to destroy the old session.

The second mistake is regenerating the session ID in the wrong place. If you regenerate the session ID after sending content to the browser, headers have already been sent and the regeneration fails silently or throws a warning. The regeneration must happen before any output, including whitespace before the opening HTML tag. This is why session_regenerate_id is typically called immediately after a successful authentication check and before any other code runs.

The third mistake is assuming that setting cookie flags in application code is sufficient. If someone deploys a hotfix that bypasses your ini_set calls, or if the application runs in an environment where ini_set is disabled, the flags may not be set. Configuration-level settings in php.ini or the server configuration are more reliable than application-level calls.

The fourth mistake is not handling session expiry gracefully. Redirecting to a login page when a session expires without a clear message frustrates users. Adding a query parameter like /login?expired=1 lets you display a helpful message explaining that the session timed out for security reasons.

Making Session Security Part of Your Development Process

Session security is not a feature you add once and forget. It needs to be part of how you build and maintain PHP applications going forward. Adding a session security checklist to your code review process catches these issues before they reach production.

When you add authentication to any new application or module, the session security checklist is short. Verify that session_regenerate_id(true) is called after every successful login. Verify that HttpOnly, Secure, and SameSite flags are set at the configuration level. Verify that session timeout is enforced at the application level. Verify that session.use_strict_mode is enabled. These four checks cover the most common session vulnerabilities and take minimal time to perform.

If you maintain an existing application that was built without these protections, working through the checklist systematically identifies what needs to change. The configuration changes take minutes to implement. The code changes, particularly adding session_regenerate_id calls and session timeout logic, may take longer depending on how the authentication code is structured. Prioritise the configuration changes first because they provide protection without requiring code modifications.

Session security improvements often have minimal impact on user experience when implemented thoughtfully. Setting a 30-minute timeout with a clear message when sessions expire is far better for users than leaving sessions open indefinitely. Regenerating session IDs on login is invisible to users and prevents a serious attack vector. These are the kinds of improvements that make applications meaningfully more secure without creating friction for legitimate users.

Frequently Asked Questions

Is session_regenerate_id(true) enough to prevent session fixation?
It is necessary but not sufficient on its own. It must be called with true immediately after every successful authentication and at every privilege transition. The application must also have use_strict_mode enabled, otherwise PHP will accept attacker-controlled session IDs. Without strict mode, an attacker can set a known session ID before the victim logs in, and regeneration does not prevent the attacker from using the same ID in a race condition before the regeneration completes.
Does HTTPS alone prevent session hijacking?
No. HTTPS prevents network-level eavesdropping, but it does not prevent XSS-based cookie theft, physical device access, session IDs leaked in browser history or server access logs, or session IDs passed as URL parameters in referrer headers. HTTPS combined with the HttpOnly and Secure flags, proper output encoding to prevent XSS, and session timeout provides layered protection. No single measure is sufficient alone.
What is the difference between session timeout and session garbage collection?
Session timeout is application-enforced expiry based on activity time. The application tracks when a session was last used and terminates it if too much time has passed. Session garbage collection is a PHP internal process that probabilistically deletes session files after session.gc_maxlifetime has passed without activity.
Can I track how many devices a user is logged in from?
File-based PHP sessions do not support this directly. You need to implement session tracking at the application level, typically by storing a session token in a database alongside a user ID and device identifier. When a new login occurs, you can check existing sessions for that user, display them, and offer the option to revoke individual sessions or all sessions except the current one. This is not built into PHP's session handling. It requires custom implementation but is straightforward to add to most applications.
How do I test whether my session security is working correctly?
Functional testing does not reveal session security issues because legitimate users never trigger the attack paths. To test session fixation, open two browser instances, set a known session ID in one, authenticate in the other with that same ID, then verify whether the first browser also has an authenticated session. To test timeout, leave a session idle past your timeout threshold and confirm it is terminated. Test cookie flags using browser developer tools to verify HttpOnly, Secure, and SameSite attributes are set correctly on the session cookie.
What PHP versions support the security settings mentioned here?
All currently supported PHP versions include these session security settings. PHP 7.0 and later have session.use_strict_mode available, and SameSite cookie support became properly configurable around PHP 7.3. If you are running an older PHP version, upgrading should be a priority regardless of session security, as unsupported versions no longer receive security patches.
Should I use a third-party session library instead of PHP's built-in sessions?
PHP's built-in session handling is adequate for most applications when properly configured. Third-party libraries may offer additional features such as encrypted session storage, database-backed session management with user tracking, or integration with external identity providers. For most business web applications, configuring the built-in session handling correctly covers the security requirements. If your application has specific needs such as distributed session management across data centres or detailed audit logging of session activity, a third-party library may be worth evaluating.
How do session security issues affect users of my application?
When session security is weak, users face the risk of account compromise without any action on their part beyond using the application. Their authenticated session can be stolen through network interception, cross-site scripting on a related site, or simply by someone else using their device while they are logged in. Users have no way to detect these attacks and no practical way to protect themselves from them. The responsibility for preventing session-based attacks lies entirely with the application developer.