I built my first PHP login system when I was still copy-pasting md5($password) from a tutorial and feeling good about it. That was embarrassing. What's less funny is that versions of that mistake — not md5 specifically, but the same category of "I didn't know what I didn't know" — kept showing up in my work for years afterward. Subtle things. Session IDs that never rotated. Cookies missing the HttpOnly flag. A "remember me" feature that stored a raw token directly in the database.
Authentication is one of those topics where the basics feel trivially easy and the details will absolutely wreck you. PHP gives you enough rope to build a perfectly functional auth system or hang yourself with it, and the documentation is just permissive enough that both outcomes look similar on the surface.
This is a pattern catalog — numbered and scannable — pulled from real projects. Some of these I got right from the start. Most of them I learned after something went wrong, or after reading a security post-mortem that made my stomach drop because I recognized my own code in the example.
Session Configuration Patterns
1. Set your session configuration before session_start()
This one sounds obvious, but I've seen it missed more times than I can count. Any ini_set() calls for session settings need to happen before session_start(), not after. I keep a dedicated session_config.php file that gets required at the top of every entry point:
ini_set('session.use_strict_mode', 1);
ini_set('session.use_only_cookies', 1);
ini_set('session.use_trans_sid', 0);
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);
ini_set('session.cookie_samesite', 'Strict');
ini_set('session.gc_maxlifetime', 1800);
session_start();
On shared cPanel hosting, you often can't touch the global php.ini, so ini_set() is your only lever. Use it.
2. Always regenerate the session ID on privilege change
Login, logout, password change, role escalation — any event that changes what a user is allowed to do should regenerate the session ID. This prevents session fixation where an attacker sets a known session ID before authentication and then hijacks the authenticated session.
// After successful login
session_regenerate_id(true); // true = delete the old session file
$_SESSION['user_id'] = $user['id'];
$_SESSION['role'] = $user['role'];
The true argument matters. Without it, the old session file lingers on disk and the fixation attack still has a window.
3. Bind sessions to a user fingerprint — but loosely
Storing a fingerprint in the session (user agent + a partial IP hash) and validating it on each request adds a layer against session theft. The key word is loosely — don't use the full IP because mobile users switch networks and you'll log them out constantly. I use the first two octets of the IP and the first 100 characters of the user agent:
function generate_fingerprint(): string {
$ip_partial = implode('.', array_slice(explode('.', $_SERVER['REMOTE_ADDR'] ?? ''), 0, 2));
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 100);
return hash('sha256', $ip_partial . $ua . SECRET_SALT);
}
// On login
$_SESSION['fingerprint'] = generate_fingerprint();
// On each protected request
if ($_SESSION['fingerprint'] !== generate_fingerprint()) {
session_destroy();
header('Location: /login');
exit;
}
4. Set an absolute session timeout, not just an idle one
PHP's gc_maxlifetime handles idle timeouts, but a determined user who keeps making requests will never get logged out. Store a login timestamp and enforce a hard ceiling regardless of activity:
$_SESSION['login_time'] = time();
// On each request
if (time() - $_SESSION['login_time'] > 28800) { // 8 hours hard limit
session_destroy();
header('Location: /login?reason=expired');
exit;
}
Password and Credential Patterns
5. Use password_hash() with PASSWORD_DEFAULT and nothing else
Not md5. Not sha1. Not sha256 with a custom salt. password_hash($password, PASSWORD_DEFAULT) uses bcrypt by default and handles salt generation internally. When PHP upgrades the default algorithm (it happened with Argon2 in PHP 7.2), your next password_verify() call can transparently rehash and upgrade stored hashes:
// On login, after verifying the password is correct
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$new_hash = password_hash($plain_password, PASSWORD_DEFAULT);
// UPDATE users SET password_hash = ? WHERE id = ?
}
This pattern means you're always running the current best algorithm without a forced migration event.
6. Rate-limit login attempts at the application layer
On shared hosting you don't have nginx rate limiting or fail2ban. You do it yourself. I store failed attempt counts in a MySQL table with a timestamp and block the IP (or username) after five failures in ten minutes:
-- login_attempts table
CREATE TABLE login_attempts (
id INT AUTO_INCREMENT PRIMARY KEY,
identifier VARCHAR(255) NOT NULL, -- IP or username
attempted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_identifier_time (identifier, attempted_at)
);
Clean old rows with a scheduled cron job, or just delete them in the same query that checks the count. The index on (identifier, attempted_at) keeps the lookup fast even as the table grows — this is exactly the kind of compound index pattern I covered in detail in my MySQL indexing post.
7. Never put the username in the session for display purposes without re-fetching
It's tempting to store $_SESSION['username'] and slap it into the UI. The problem: if an admin changes that user's username or disables their account, your session still shows the old stale data. Store only the user ID in the session, then fetch a fresh user row at the start of each authenticated request and attach it to a request-scoped object or array.
"Remember Me" Patterns
8. Use a token pair: selector + verifier
The classic "remember me" mistake is storing a single random token in the database and in the cookie. If your database leaks, every persistent login is compromised. The safer pattern splits the token into a public selector (used to look up the row) and a private verifier (hashed before storage):
$selector = bin2hex(random_bytes(16));
$verifier = random_bytes(32);
$verifier_hash = hash('sha256', $verifier);
$token_cookie = $selector . ':' . bin2hex($verifier);
// Store $selector + $verifier_hash in the DB
// Set $token_cookie as the cookie value
// On validation:
[$selector, $raw_verifier] = explode(':', $cookie_value, 2);
// Fetch row by selector, then compare:
if (!hash_equals($row['verifier_hash'], hash('sha256', hex2bin($raw_verifier)))) {
// Token mismatch — possible theft, invalidate all tokens for this user
}
The hash_equals() call prevents timing attacks. Don't use === here.
9. Rotate the remember-me token on every use
Once a remember-me token authenticates a user, replace it immediately. Issue a new token, update the database, and set a fresh cookie. If an old token is ever presented again, treat it as a potential theft and invalidate all remember-me tokens for that account. This is called token cycling and it's a significant deterrent against stolen cookie replays.
CSRF and Output Patterns
10. Generate CSRF tokens per-session, validate on every state-changing request
A per-session token (not per-form) is simpler to implement on shared hosting and good enough for most apps. Generate once, store in session, embed in every form as a hidden field, validate on POST/PUT/DELETE:
// Generate once
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// In your form
// <input type="hidden" name="csrf_token" value="...">
// On form submission
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
http_response_code(403);
exit('CSRF validation failed');
}
11. Escape output — always, everywhere, reflexively
Every piece of user-supplied data that goes into HTML output needs htmlspecialchars($value, ENT_QUOTES, 'UTF-8'). I wrap this in a short helper so I'm not typing the full call a hundred times:
function e(string $value): string {
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
If you're dealing with rich text, that's a different problem — strip or sanitize with a dedicated library like HTML Purifier, not a hand-rolled regex. This connects directly to the kind of error feedback loops I wrote about in designing error states — bad output escaping and bad error messaging both share the same root cause: not thinking about what untrusted input looks like on the receiving end.
Logout and Invalidation Patterns
12. Destroy the session completely on logout — don't just unset variables
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$params['path'], $params['domain'],
$params['secure'], $params['httponly']
);
}
session_destroy();
Just calling session_destroy() without clearing $_SESSION first can leave the data accessible in the current request. Just calling unset($_SESSION['user_id']) leaves the session file on disk. Do all three steps.
13. Implement a server-side session blocklist for critical logout
For apps handling anything sensitive — payments, medical data, admin panels — you need the ability to force-invalidate a session from the server side even if the client doesn't cooperate. On shared hosting this means storing active session IDs in a database table and checking that table on every authenticated request. Expensive? A little. Worth it for admin roles? Absolutely.
14. Log authentication events, not just errors
Successful logins, failed logins, logouts, password changes, remember-me usage — all of it should write a row to an auth_log table with a timestamp, user ID or identifier, IP, and user agent. This isn't just for security forensics; it's how you discover that your rate limiting is too aggressive, or that a particular user's account is being hammered at 3am. Without the log, you're flying blind.
Putting It Together
None of these patterns are exotic. They're not cutting-edge security research. They're the baseline — the floor of what a PHP authentication system on shared hosting should do. The reason I'm writing a catalog of them is that I've audited enough small-project codebases (including my own early ones) to know that most of them are missing at least four or five of these items, and the missing ones are never randomly distributed. It's always the same set: no session regeneration, no CSRF tokens, remember-me tokens stored raw, output not escaped consistently.
Go through this list against your current project. Be honest. If you find three gaps, fix those three before you ship the next feature. Authentication debt compounds faster than any other kind.
Frequently Asked Questions
Should I use PHP sessions or JWTs for a traditional PHP web application?
For server-rendered PHP apps on shared hosting, sessions are almost always the right call — they're simpler to invalidate, don't require a token refresh dance, and keep sensitive data server-side. JWTs shine for stateless APIs or multi-service architectures, which is overkill for most cPanel-hosted projects.
How do I prevent session fixation attacks in PHP?
Call session_regenerate_id(true) immediately after a successful login — the true parameter destroys the old session file. Never accept a session ID from a GET parameter, and make sure session.use_only_cookies is enabled in your php.ini or set via ini_set() at runtime.
What's the safest way to store a "remember me" token in PHP?
Generate a cryptographically random token with random_bytes(32), store its hash (using hash('sha256', $token)) in your database alongside the user ID and an expiry timestamp, then write the raw token to a long-lived HttpOnly, Secure, SameSite=Strict cookie. Never store the raw token in the database directly.