jeph / mysql-session
Installs: 1
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/jeph/mysql-session
Requires
- php: >=8.3
Requires (Dev)
- josephscott/phpcsfixer-config: ^0.0.6
- pestphp/pest: ^4.1.5
- phpstan/phpstan: ^2.1
README
Just Enough PHP to provide MySQL session storage.
A custom PHP session handler that stores session data in a MySQL database with proper session locking.
Usage
<?php // Create a PDO connection $pdo = new PDO( dsn: 'mysql:host=localhost;dbname=myapp;charset=utf8mb4', username: 'user', password: 'password' ); // Create the session handler $session_handler = JEPH\MySQL\Session::create( pdo: $pdo ); if ( $session_handler === false ) { die( 'Failed to create session handler' ); } // Register the handler session_set_save_handler( session_handler: $session_handler, register_shutdown: true ); // Start the session - use sessions as normal from here session_start(); $_SESSION['user_id'] = 123;
Configuration Options
All options are passed to the create() factory method, which returns Session|false:
$session_handler = JEPH\MySQL\Session::create( pdo: $pdo, table_name: 'sessions', // Session data table (default: 'sessions') lock_table_name: 'session_locks', // Lock table (default: 'session_locks') lock_timeout: 10, // Seconds to wait for lock (default: 10) lock_max_age: 30, // Seconds before lock is stale (default: 30) lock_retry_interval: 100, // Milliseconds between retries (default: 100) security_code: 'your_secret', // Secret for fingerprint (default: '') lock_to_user_agent: true, // Bind to User-Agent (default: false) lock_to_ip: true, // Bind to IP address (default: false) read_only: false // Read-only mode, no locks/writes (default: false) ); if ( $session_handler === false ) { die( 'Invalid configuration' ); }
The factory method returns false if table names contain invalid characters (only alphanumeric and underscores are allowed) to prevent SQL injection.
| Option | Default | Description |
|---|---|---|
| table_name | sessions |
Name of the sessions table |
| lock_table_name | session_locks |
Name of the locks table |
| lock_timeout | 10 |
Seconds to wait when acquiring a lock |
| lock_max_age | 30 |
Seconds before a lock is considered abandoned |
| lock_retry_interval | 100 |
Milliseconds between lock acquisition attempts |
| security_code | '' |
Secret string for session fingerprint validation |
| lock_to_user_agent | false |
Bind session to the client's User-Agent header |
| lock_to_ip | false |
Bind session to client IP address (bool or callable) |
| read_only | false |
Open session in read-only mode (no locks, no writes) |
Database Tables
session_locks
Used for table-based session locking to ensure data consistency during concurrent requests.
CREATE TABLE session_locks ( session_id VARCHAR(128) NOT NULL PRIMARY KEY, lock_token VARCHAR(64) NOT NULL, locked_at INT UNSIGNED NOT NULL, INDEX idx_locked_at (locked_at) ) ENGINE=InnoDB;
| Column | Type | Description |
|---|---|---|
| session_id | VARCHAR(128) | The PHP session ID (primary key) |
| lock_token | VARCHAR(64) | Unique token identifying the lock holder |
| locked_at | INT UNSIGNED | Unix timestamp when lock was acquired |
sessions
Stores the actual session data.
CREATE TABLE sessions ( session_id VARCHAR(128) NOT NULL PRIMARY KEY, data MEDIUMTEXT NOT NULL, fingerprint VARCHAR(64) NOT NULL DEFAULT '', last_accessed INT UNSIGNED NOT NULL, INDEX idx_last_accessed (last_accessed) ) ENGINE=InnoDB;
| Column | Type | Description |
|---|---|---|
| session_id | VARCHAR(128) | The PHP session ID (primary key) |
| data | MEDIUMTEXT | Serialized session data (up to 16MB) |
| fingerprint | VARCHAR(64) | SHA256 hash for session hijacking protection |
| last_accessed | INT UNSIGNED | Unix timestamp for garbage collection |
Session Hijacking Protection
This handler provides optional session hijacking protection through fingerprint validation. When enabled, a hash of client characteristics is stored with the session and validated on each read. If the fingerprint doesn't match, the session is destroyed.
Configuration
Enable protection by setting one or more of these options:
$session_handler = JEPH\MySQL\Session::create( pdo: $pdo, security_code: 'a_random_secret_string_12chars', // Server-side secret lock_to_user_agent: true, // Bind to browser lock_to_ip: true // Bind to IP address );
Options Explained
security_code - A secret string (recommended: 12+ characters with mixed case and numbers) that is included in the fingerprint calculation. This adds server-side entropy that an attacker cannot know, making it harder to forge a valid fingerprint.
lock_to_user_agent - When true, the session is bound to the client's User-Agent header. If the User-Agent changes, the session is invalidated. Note: Some browsers (especially older IE versions) may change User-Agent between requests, so test thoroughly.
lock_to_ip - When true, the session is bound to $_SERVER['REMOTE_ADDR']. This provides strong protection but may cause issues for users whose IP changes frequently (mobile networks, some ISPs).
Using a Callable for IP Address
If your application is behind a load balancer or reverse proxy, REMOTE_ADDR will be the proxy's IP. Use a callable to extract the real client IP:
$session_handler = JEPH\MySQL\Session::create( pdo: $pdo, security_code: 'your_secret', lock_to_ip: function(): string { // Check trusted proxy headers // WARNING: Only trust these headers if you control the proxy! foreach ( ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP'] as $header ) { if ( isset( $_SERVER[$header] ) && $_SERVER[$header] !== '' ) { // X-Forwarded-For may contain multiple IPs; take the first $ip = explode( ',', $_SERVER[$header] )[0]; return trim( $ip ); } } return $_SERVER['REMOTE_ADDR'] ?? ''; } );
Security Considerations
- Recommended: Always set
security_codewhen using fingerprint protection - User-Agent binding adds minor security; an attacker who steals a session cookie likely has the same browser
- IP binding is stronger but may cause legitimate session loss for mobile users
- A mismatched fingerprint destroys the session and returns empty data, forcing a new session
- Uses
hash_equals()for constant-time comparison to prevent timing attacks
Upgrading Existing Sessions
If you enable fingerprint protection on an existing application, sessions created before the upgrade will be invalidated (they have no stored fingerprint). Users will need to log in again.
Session Locking
This handler implements table-based session locking to prevent race conditions during concurrent requests. When a session is read, a lock is acquired and held until the session is closed.
How it works:
- When
session_start()is called, the handler attempts to acquire a lock - If the lock is held by another request, it retries until
lock_timeoutis reached - If a lock is older than
lock_max_age, it is considered abandoned and can be claimed - The lock is released when
session_write_close()is called or the script ends
Best practices:
- Call
session_write_close()early in long-running scripts to release the lock - Keep
lock_timeoutreasonable to avoid blocking requests indefinitely - The
lock_max_ageshould be longer than your maximum expected script execution time
Long-Running Scripts
For scripts that run longer than lock_max_age, use refresh_lock() to prevent the lock from becoming stale:
$session_handler = JEPH\MySQL\Session::create( pdo: $pdo ); session_set_save_handler( session_handler: $session_handler, register_shutdown: true ); session_start(); // For long operations, periodically refresh the lock foreach ( $large_dataset as $item ) { process_item( $item ); // Refresh lock every iteration (or based on time elapsed) $session_handler->refresh_lock(); } session_write_close();
The refresh_lock() method returns true if the lock was successfully refreshed, or false if no lock is held or the lock was lost. Call it at intervals shorter than lock_max_age (e.g., every lock_max_age / 2 seconds).
Read-Only Mode
Read-only mode allows you to access session data without acquiring locks and without saving any changes. This is useful for:
- High-traffic read-heavy pages where you only need to check if a user is logged in
- AJAX endpoints that read session data but don't modify it
- API endpoints where session data is needed for authentication but not modified
- Reducing lock contention when multiple requests need concurrent read access
Usage
$session_handler = JEPH\MySQL\Session::create( pdo: $pdo, read_only: true ); session_set_save_handler( session_handler: $session_handler, register_shutdown: true ); session_start(); // Read session data as normal $user_id = $_SESSION['user_id'] ?? null; // Any modifications to $_SESSION will NOT be saved $_SESSION['last_seen'] = time(); // This change is discarded
Behavior
When read_only is true:
- No lock is acquired - Multiple read-only sessions can access the same session concurrently
- No data is written -
write()returnstruebut doesn't save changes - No timestamp updates -
updateTimestamp()returnstruebut doesn't update - Cannot create new sessions - Writing to a non-existent session ID does nothing
- Fingerprint validation still works - But mismatched fingerprints return empty data instead of destroying the session
Checking Read-Only Status
Use is_read_only() to check if the session is in read-only mode:
if ( $session_handler->is_read_only() ) { // Don't try to save important data log_warning( 'Attempted to modify session in read-only mode' ); }
Use Cases
Authentication check on API endpoint:
// API endpoint that just needs to verify the user is logged in $session_handler = JEPH\MySQL\Session::create( pdo: $pdo, read_only: true ); session_set_save_handler( session_handler: $session_handler, register_shutdown: true ); session_start(); if ( empty( $_SESSION['user_id'] ) ) { http_response_code( 401 ); exit( json_encode( ['error' => 'Unauthorized'] ) ); } // Process the API request...
High-traffic page with session check:
// Homepage that shows different content for logged-in users $session_handler = JEPH\MySQL\Session::create( pdo: $pdo, read_only: true ); session_set_save_handler( session_handler: $session_handler, register_shutdown: true ); session_start(); $is_logged_in = ! empty( $_SESSION['user_id'] ); // Render page with appropriate content...
Testing
Tests are written using Pest and require a MySQL database.
Setup
- Create a test database and user:
CREATE DATABASE jeph_session_test; CREATE USER 'jeph_session_test'@'localhost' IDENTIFIED BY 'jeph_session_test'; GRANT ALL PRIVILEGES ON jeph_session_test.* TO 'jeph_session_test'@'localhost'; FLUSH PRIVILEGES;
- Configure the database connection via environment variables (if using different credentials):
export TEST_DB_HOST=localhost export TEST_DB_PORT=3306 export TEST_DB_NAME=jeph_session_test export TEST_DB_USER=jeph_session_test export TEST_DB_PASS=jeph_session_test
Running Tests
# Run all checks (style, lint, analyze, tests) make all # Run only tests make tests
The test suite includes:
- session-tests.php - Basic session handler functionality (read, write, destroy, gc)
- locking-tests.php - Lock acquisition, release, stale lock handling, and concurrent access tests
- fingerprint-tests.php - Session hijacking protection via fingerprint validation
- read-only-tests.php - Read-only mode functionality and behavior
- integration-tests.php - Real PHP session integration (
session_start,read_and_close,session_regenerate_id)