Modernize a Legacy PHP Login System Safely
Author: Jeffery L. Paris
Date: July 25, 2026
Time: 9:12 PM EDT
Website: https://phpog.com/
------------------------------------------------------------
There are plenty of PHP authentication tutorials that explain how to build a registration and login form from the beginning. They start with an empty database, create a clean users table and assume every account will use modern password hashing from the first day.
That kind of lesson has value, but it doesn’t answer the harder question many working developers eventually face:
How do you modernize a PHP login system that already has real users, old password hashes, active sessions and dependent scripts without locking people out?
A live application may store MD5 password hashes. It may place a username and password hash into the session, then compare both values against the database on every protected page. Dozens of older scripts may expect $_SESSION['username'] and $_SESSION['password'] to exist, and removing either one without understanding the full path may break parts of the application that appeared unrelated.
I’ve never believed older code should be mocked simply because time moved forward. A system that has served people for years deserves to be studied before it’s changed, because working software carries history, decisions and dependencies that aren’t always visible from the surface.
You can’t always replace that system in one deployment. Even when a complete rewrite looks cleaner on paper, it may create more risk than a careful migration. Sometimes the wiser job isn’t building a new bridge beside the old one; it’s strengthening the bridge while people are still crossing it.
This article explains how to modernize that kind of PHP authentication system gradually. The goal is to improve password storage, session handling, authorization and account recovery without abandoning the users or dependable code that already exist.
Legacy Code Isn’t Automatically Bad Code
Older PHP systems were built according to the hosting limits, documentation and common practices available at the time. MD5 password hashing appeared in books, tutorials and classrooms. Shared hosting accounts offered inconsistent extensions, and developers often needed software that could run on almost any ordinary PHP and MySQL server.
Some of those applications have remained useful for ten, fifteen or twenty years because they solved real problems and kept working. That doesn’t make every old decision safe today, but it does mean the work deserves a fair reading.
The purpose of modernization isn’t to insult the original developer or replace every old decision simply because a newer option exists. The purpose is to understand the system, preserve what remains dependable and replace the parts that are no longer safe.
Good modernization resembles restoration more than demolition. You inspect the structure, identify the weak points and replace them in an order that keeps the rest of the building standing. That approach may not look as dramatic as a complete rewrite, but mature engineering isn’t measured by how much code we throw away. It’s measured by whether the system becomes safer without creating new harm.
The Common Legacy Login Pattern
A legacy PHP application may use a pattern similar to this:
$username = $_SESSION['username'];
$password = $_SESSION['password'];
$sql = 'SELECT *
FROM users
WHERE username = ?
AND password = ?
LIMIT 1';
The value stored in the session may be an MD5 hash rather than the plain-text password, but the design problem remains the same.
The application stores two reusable credentials in the session:
- The username
- The password or password hash
Every protected request repeats a database query to confirm that those values still match a user record. At first, this can look like additional security because the application appears to authenticate the user on every page.
That isn’t what’s really happening. The user isn’t proving knowledge of the password again; the application is replaying a credential that was copied into the session earlier. Once the session is accepted, the stored password hash begins behaving like another session token.
The password hash is now being used for two different responsibilities:
- Verifying the password during login
- Proving that the user remains logged in afterward
Those responsibilities should be separated because a value created for one security purpose shouldn’t quietly become the foundation of another.
Separate Authentication, Session Management and Authorization
A login system becomes easier to secure when we stop treating login as one large action and divide it into three clear jobs.
Authentication
Authentication answers one question:
Has this person successfully proven control of the account?
The user submits a password, the application verifies it against the stored password hash and authentication succeeds only when the comparison is valid. That password check normally belongs at login, during reauthentication or before another especially sensitive action.
Session Management
Session management answers a different question:
How does the application remember that authentication already succeeded?
After login, PHP gives the browser a random session identifier. The browser returns that identifier with later requests, allowing PHP to load the corresponding server-side session data. The user doesn’t need to submit the password again on every page because the session exists to remember the result of the earlier authentication.
Authorization
Authorization answers:
What is this authenticated account permitted to do?
A customer may view orders. An editor may manage articles. An administrator may manage users or system settings.
Authentication proves identity, session management remembers the authenticated state and authorization controls access. When those responsibilities are combined inside one repeated password query, the application becomes harder to understand and easier to misuse.
A Password Hash Isn’t an Encrypted Password
This distinction matters because encryption and password hashing solve different problems.
Encryption is intended to be reversible when the correct key is available. Password hashing is intended to be one-way, so a properly stored password hash doesn’t need to be decrypted.
During login, PHP evaluates the submitted password against the information encoded in the stored hash and reports whether the password is correct. PHP provides three primary functions for this work:
password_hash()
password_verify()
password_needs_rehash()
password_hash() creates a password hash using a password-specific algorithm. The algorithm identifier, options and salt information are included in the resulting value, so a separate salt column isn’t normally required.
A basic modern hash can be created with:
$password_hash = password_hash($password, PASSWORD_DEFAULT);
The database column should allow enough room for future formats:
password_hash VARCHAR(255) NOT NULL
Don’t size the column only for the exact hash length used today. PASSWORD_DEFAULT is designed to evolve as PHP changes its default recommendation, and the database shouldn’t become the reason the application can’t move forward later.
Why MD5 Isn’t Suitable for Password Storage
MD5 was designed to produce a fast general-purpose digest. That speed is exactly why it’s unsuitable for password storage.
Password hashing should be deliberately expensive enough to slow large guessing attacks. An attacker who obtains an MD5 password database can test enormous numbers of possible passwords quickly, and common passwords or passwords already exposed in public breaches may be recovered almost immediately.
Modern password-hashing algorithms are designed to require more computation, memory or both. They can’t make a weak password wise, but they can make large-scale guessing more costly and give the application a stronger foundation.
This doesn’t mean every account with an MD5 hash must be locked immediately. It means MD5 should be treated as a temporary legacy format with a controlled removal plan. Security improves when we face the weakness honestly without turning the repair into unnecessary punishment for the people using the system.
Don’t Automatically Force Every User to Reset
One migration option is to invalidate every password and email every user a reset link. That may be necessary after a breach or when the old password format can’t be safely verified, but it shouldn’t automatically be the first choice for every legacy application.
A forced reset can create several problems:
- Some users no longer have access to the original email address
- Reset messages may be filtered, delayed or rejected
- Inactive users may never complete the process
- Support requests may increase sharply
- A mail-delivery problem can become a site-wide lockout problem
A smoother approach is to upgrade the password hash when the user successfully logs in. This is often called opportunistic password migration.
During the transition, the application understands two types of stored passwords:
- Modern hashes created by password_hash()
- The old legacy format
When a legacy user logs in successfully, the application verifies the password using the old method one final time. It then creates a modern hash and replaces the old value immediately.
The user continues into the application without seeing a migration screen or changing the password. The repair happens quietly beneath the interface, which is often where the best engineering work belongs.
Identify the Stored Password Format Explicitly
The clearest transitional design is to store the password format beside the hash:
ALTER TABLE users
CHANGE password password_hash VARCHAR(255) NOT NULL,
ADD password_format VARCHAR(20) NOT NULL DEFAULT 'md5',
ADD password_changed_at DATETIME NULL,
ADD auth_version INT UNSIGNED NOT NULL DEFAULT 1;
Existing accounts can begin with:
password_format = md5
New and migrated accounts can use:
password_format = modern
The format column may become unnecessary after every account has been migrated. During the transition, it prevents the application from guessing what kind of value it’s reading, and security code shouldn’t have to guess when the database can state the answer clearly.
Some systems identify MD5 values by checking for exactly 32 hexadecimal characters:
function is_legacy_md5_hash($stored_hash) {
if (strlen($stored_hash) !== 32) {
return false;
}
if (!ctype_xdigit($stored_hash)) {
return false;
}
return true;
}
That may be acceptable as a temporary compatibility measure, but an explicit format column is easier to audit and less likely to misclassify data.
The migration must also reproduce the actual legacy hashing method. If the old system used a prefix, suffix, custom salt or more than one hashing step, don’t replace that history with an assumption. Study the existing login code and verify the real formula before changing anything.
Find the Account Before Checking the Password
The login query should retrieve the account by a stable identity field, usually a username or email address. It shouldn’t search for the user by both username and password hash because the database lookup and the password verification are separate responsibilities.
A procedural mysqli query can begin like this:
$sql = 'SELECT
user_id,
username,
password_hash,
password_format,
account_status,
auth_version
FROM users
WHERE username = ?
LIMIT 1';
$stmt = mysqli_prepare($db, $sql);
if ($stmt === false) {
throw new RuntimeException('Unable to prepare the login query.');
}
mysqli_stmt_bind_param($stmt, 's', $username);
if (mysqli_stmt_execute($stmt) === false) {
mysqli_stmt_close($stmt);
throw new RuntimeException('Unable to execute the login query.');
}
mysqli_stmt_bind_result(
$stmt,
$user_id,
$stored_username,
$stored_hash,
$password_format,
$account_status,
$auth_version
);
$user_found = mysqli_stmt_fetch($stmt);
mysqli_stmt_close($stmt);
Using mysqli_stmt_bind_result() avoids depending on mysqli_stmt_get_result(), which may not be available on every PHP installation. I prefer examples that respect ordinary hosting environments because a lesson becomes more useful when readers can apply it without discovering that one hidden dependency made the code unusable.
After retrieving the account, check whether it’s eligible to authenticate:
if ($user_found === false) {
$login_valid = false;
} elseif ($account_status !== 'active') {
$login_valid = false;
} else {
$login_valid = true;
}
The public failure message should remain generic:
The username or password was incorrect.
Don’t reveal whether the username exists, whether the password failed or whether the account has been disabled. Detailed reasons can be written to a protected audit log when appropriate, but the public response shouldn’t help an attacker map the account database one guess at a time.
Verify and Upgrade a Legacy MD5 Password
A temporary migration check can support modern hashes and the old MD5 format:
$password_verified = false;
$password_upgraded = false;
if ($login_valid) {
if ($password_format === 'modern') {
$password_verified = password_verify($password, $stored_hash);
} elseif ($password_format === 'md5') {
$submitted_legacy_hash = md5($password);
$password_verified = hash_equals($stored_hash, $submitted_legacy_hash);
}
}
This does not make MD5 secure. It allows the application to recognize the existing password long enough to replace the old hash.
After a successful legacy verification, create a modern hash:
if ($password_verified && $password_format === 'md5') {
$new_password_hash = password_hash($password, PASSWORD_DEFAULT);
$new_password_format = 'modern';
$sql = 'UPDATE users
SET
password_hash = ?,
password_format = ?,
password_changed_at = NOW(),
auth_version = auth_version + 1
WHERE user_id = ?
AND password_hash = ?
LIMIT 1';
$stmt = mysqli_prepare($db, $sql);
if ($stmt === false) {
throw new RuntimeException('Unable to prepare the password upgrade.');
}
mysqli_stmt_bind_param(
$stmt,
'ssis',
$new_password_hash,
$new_password_format,
$user_id,
$stored_hash
);
if (mysqli_stmt_execute($stmt)) {
if (mysqli_stmt_affected_rows($stmt) === 1) {
$password_upgraded = true;
$auth_version++;
}
}
mysqli_stmt_close($stmt);
}
Including the original hash in the WHERE clause prevents the request from blindly overwriting a password that changed between the original SELECT and the UPDATE.
Production code should record failed migration writes. A successful legacy login shouldn’t hide a database problem that prevents the account from being upgraded, because a migration that looks successful while leaving the weakness behind is only moving the problem out of sight.
Keep Modern Hashes Modern
Password migration doesn’t end after MD5 disappears. Hardware improves and password-hashing recommendations evolve, so a hash created several years ago may still verify correctly while using settings that should now be strengthened.
PHP provides password_needs_rehash() for that purpose:
if ($password_verified && $password_format === 'modern') {
if (password_needs_rehash($stored_hash, PASSWORD_DEFAULT)) {
$new_password_hash = password_hash($password, PASSWORD_DEFAULT);
$sql = 'UPDATE users
SET
password_hash = ?,
password_changed_at = NOW()
WHERE user_id = ?
AND password_hash = ?
LIMIT 1';
$stmt = mysqli_prepare($db, $sql);
if ($stmt !== false) {
mysqli_stmt_bind_param(
$stmt,
'sis',
$new_password_hash,
$user_id,
$stored_hash
);
mysqli_stmt_execute($stmt);
mysqli_stmt_close($stmt);
}
}
}
This allows the application to improve password storage gradually during normal successful logins. A healthy system shouldn’t need another crisis before it’s allowed to become stronger.
What Should Be Stored in the Session
After the password is verified, the session should identify the authenticated account without storing the password or reusable password hash.
A reasonable session may contain:
$_SESSION['authenticated'] = true;
$_SESSION['user_id'] = $user_id;
$_SESSION['username'] = $stored_username;
$_SESSION['auth_version'] = $auth_version;
$_SESSION['login_time'] = time();
$_SESSION['last_activity'] = time();
The internal user ID is the most important identity value. Usernames and email addresses can change, but a numeric user ID normally remains stable.
The session shouldn’t contain:
$_SESSION['password']
$_SESSION['password_hash']
$_SESSION['md5_password']
Those values aren’t needed after authentication. Removing them also prevents older scripts from treating a password hash as an access token, which is one of those small architectural corrections that can simplify many later decisions.
Regenerate the Session ID After Login
A visitor may already have a PHP session before logging in. That session could contain a shopping cart, CSRF token or display preferences.
After authentication succeeds, regenerate the session identifier:
if (session_regenerate_id(true) === false) {
throw new RuntimeException('Unable to regenerate the session identifier.');
}
The authenticated values can then be written to the new session. Regeneration helps prevent session fixation, where an attacker attempts to make a victim authenticate using a session identifier already known to the attacker.
Applications that make concurrent requests may need a careful handover instead of immediately deleting the old session data. Test AJAX requests, file uploads and mobile behavior before changing session regeneration globally. Security changes should be tested against the way the application truly behaves, not only the clean path we imagined while writing the code.
Configure Session Cookies Deliberately
Session security shouldn’t depend entirely on server defaults. Defaults are useful starting points, but the application should state its own security expectations clearly.
Configure the session before calling session_start():
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.use_trans_sid', '0');
session_name('phpog_session');
session_set_cookie_params(array(
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax'
));
session_start();
Strict Mode
session.use_strict_mode prevents PHP from accepting an uninitialized session ID supplied by the browser. This helps protect against session adoption and fixation.
Secure
The browser sends the cookie only through HTTPS. A login system shouldn’t operate through an unencrypted connection because credentials and authenticated sessions are too important to leave exposed in transit.
HttpOnly
JavaScript can’t directly read the session cookie. This doesn’t cure cross-site scripting, but it can reduce some forms of session theft.
SameSite
Lax is a practical default for many traditional websites. Applications with external identity providers, embedded services or cross-site workflows may require a different policy and additional testing.
Cookie-Only Sessions
Disabling transparent session IDs prevents PHP from placing session identifiers in URLs. A session identifier shouldn’t appear in links, analytics records, referrer headers or copied browser addresses.
Replace the Password Query With an Identity Query
Some applications still need to check the user record on every protected request, and that isn’t automatically wrong. The application may need to confirm:
- The account still exists
- The account remains active
- The role hasn’t changed
- The session hasn’t been administratively revoked
- The password hasn’t changed since login
The problem isn’t the database check itself. The problem is using the password hash as the value that proves the session should remain trusted.
Query by user ID instead:
$sql = 'SELECT
account_status,
user_role,
auth_version
FROM users
WHERE user_id = ?
LIMIT 1';
Then compare the database state with the session:
if ($account_status !== 'active') {
destroy_authenticated_session();
}
if ((int) $database_auth_version !== (int) $_SESSION['auth_version']) {
destroy_authenticated_session();
}
This provides a straightforward way to invalidate existing sessions. When the password changes, increment auth_version. When an administrator revokes every session for the account, increment it again. Any session carrying an older version becomes invalid.
The application is now checking account state instead of replaying a password credential. The difference may look small in code, but it changes what the system trusts, and that’s where security architecture really lives.
Maintain Compatibility With Older Scripts
A large application may have many checks like this:
if (isset($_SESSION['username']) && isset($_SESSION['password'])) {
// Allow access.
}
Changing every controller in one deployment can create avoidable mistakes. I prefer to place a compatibility layer between the old expectation and the new design, then move the dependent scripts across in controlled groups.
A compatibility function provides that safer transition:
function user_is_logged_in() {
if (!isset($_SESSION['authenticated'])) {
return false;
}
if ($_SESSION['authenticated'] !== true) {
return false;
}
if (!isset($_SESSION['user_id'])) {
return false;
}
if ((int) $_SESSION['user_id'] < 1) {
return false;
}
return true;
}
New and updated scripts can use:
if (user_is_logged_in() === false) {
header('Location: /login');
exit;
}
Older controllers can then be migrated in manageable groups. During the transition, the application may temporarily continue storing a harmless compatibility value such as the username:
$_SESSION['username'] = $stored_username;
Don’t continue populating the old password field. That would preserve the dependency the migration is intended to remove.
A compatibility layer isn’t an excuse to keep the old design forever. It’s scaffolding: useful while the repair is underway, dangerous when everyone forgets it was supposed to come down.
Sessions Need Expiration Rules
A session shouldn’t remain trusted forever simply because its storage record still exists. Time is part of the security decision, so use both idle expiration and absolute expiration.
Idle Expiration
The user is logged out after a period without activity:
$idle_limit = 1800;
if (isset($_SESSION['last_activity'])) {
$idle_age = time() - (int) $_SESSION['last_activity'];
if ($idle_age > $idle_limit) {
destroy_authenticated_session();
}
}
$_SESSION['last_activity'] = time();
In this example, the idle limit is thirty minutes. The correct value depends on the application; a financial administration panel should normally use a stricter policy than a discussion forum.
Absolute Expiration
The session ends after a maximum lifetime even when the user remains active:
$absolute_limit = 28800;
if (isset($_SESSION['login_time'])) {
$session_age = time() - (int) $_SESSION['login_time'];
if ($session_age > $absolute_limit) {
destroy_authenticated_session();
}
}
Here, the maximum authenticated lifetime is eight hours. After expiration, the user must authenticate again.
Idle and absolute limits serve different purposes. One responds to abandonment while the other places a final boundary around trust, and mature security usually depends on more than one boundary.
Logout Must Remove the Entire Session
Setting one session value to false isn’t a complete logout. A proper logout should clear the session data, expire the cookie and destroy the server-side session:
function destroy_authenticated_session() {
$_SESSION = array();
if (ini_get('session.use_cookies') === '1') {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
array(
'expires' => time() - 42000,
'path' => $params['path'],
'domain' => $params['domain'],
'secure' => $params['secure'],
'httponly' => $params['httponly'],
'samesite' => 'Lax'
)
);
}
session_destroy();
header('Location: /login');
exit;
}
For sensitive applications, invalidate the session in server-side storage before destroying it locally so a copied cookie can’t continue using a surviving session record.
Logout should mean the relationship between that session and the account has ended. Anything less leaves a door appearing closed while the latch is still open.
A Session Doesn’t Prevent CSRF
A valid session identifies the account associated with the request, but it doesn’t prove that the user intentionally initiated the request.
A malicious website may attempt to make a logged-in browser submit a request to another application. Because the browser includes cookies automatically, the forged request may arrive with a valid PHP session.
State-changing forms need CSRF protection.
Create a token:
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
Place it inside the form:
<input
type="hidden"
name="csrf_token"
value="ESCAPED_SESSION_TOKEN"
>
Verify it when processing the request:
if (!isset($_POST['csrf_token'])) {
exit('Invalid request.');
}
if (hash_equals($_SESSION['csrf_token'], $_POST['csrf_token']) === false) {
exit('Invalid request.');
}
Use CSRF protection for actions such as:
- Changing an email address
- Changing a password
- Deleting content
- Updating account settings
- Creating administrative users
- Processing financial actions
- Logging out through a POST request
A session answers who the browser is acting as. A CSRF token helps answer whether the request came through a form and flow the application intended. Those are different questions, and both matter.
Remember Me Should Be a Separate System
A permanent PHP session cookie isn’t a complete remember-me system. Persistent login should use a separate random token that can be individually expired and revoked.
A common design uses:
- A public selector
- A secret validator
- A database record for the remembered device
- A stored hash of the validator
- An expiration date
The browser receives the selector and validator. The database stores the selector and only a hash of the validator.
When the cookie returns:
- Find the token record by selector
- Hash the submitted validator
- Compare it with the stored hash using hash_equals()
- Confirm that the record hasn’t expired
- Create a new authenticated session
- Rotate the validator
- Replace the old cookie
The plain validator shouldn’t be stored in the database. Each device should receive its own token record so the user can revoke one remembered device without changing the account password or ending every other session.
Persistent login tokens should be invalidated after:
- A password change
- A password reset
- Suspicious activity
- An account suspension
- A manual sign-out-everywhere action
Remember-me access is a convenience, not a permanent promise of trust. Build it so the application can withdraw that trust cleanly when circumstances change.
Avoid Rigid IP Address Locking
Binding a session permanently to one IP address may appear safer, but strict matching can create more trouble than protection. Mobile networks change addresses, corporate networks use proxies, Internet providers rotate connections and privacy services may change routes during the same browsing session.
A changed IP address doesn’t automatically mean the session was stolen. IP addresses and user-agent values can be useful as risk signals or audit information, but they shouldn’t become permanent authentication credentials.
For sensitive actions, a better response to unusual activity may be:
- Require the password again
- Send a security notice
- Terminate other sessions
- Require an additional authentication factor
- Temporarily restrict high-risk account changes
Security should protect legitimate users, not constantly mistake ordinary life for an attack. A system becomes wiser when it can notice risk without treating every change as guilt.
Add Rate Limiting Without Creating Easy Denial of Service
Password hashing slows offline attacks against a stolen database, but it doesn’t stop someone from repeatedly trying passwords through the public login form. A login system should rate-limit attempts.
Track enough information to identify repeated abuse, but don’t permanently lock an account after a small number of failures. An attacker could intentionally submit bad passwords for another user and keep that account unavailable.
A balanced design may combine:
- Short delays after repeated failures
- Per-account attempt tracking
- Per-network attempt tracking
- Temporary limits
- Additional challenges after suspicious activity
- Security logging
- Administrative alerts for extreme abuse
Don’t expose exact limits in public error messages. Also avoid recording submitted passwords in logs because a debugging system that stores the entire $_POST array can accidentally create a plain-text password archive.
The protection shouldn’t become another weapon an attacker can use against the account owner. Good security closes one path without opening another.
Password Changes Must Revoke Old Access
Changing a password should do more than replace the stored hash. A secure password-change process can:
- Verify the current password
- Validate the new password
- Create a modern hash
- Increment auth_version
- Delete persistent login tokens
- Terminate other active sessions
- Regenerate the current session ID
- Send a security notification
The current session may remain active after a verified password change, but it should receive the new authentication version.
A password reset should normally revoke all previous authenticated sessions because the reset may have occurred after the original password was compromised. When the key to a home changes, the old keys shouldn’t continue opening the door.
Measure the Migration
Don’t remove legacy password support the day after deployment. Measure the transition because a migration isn’t complete when the new code exists; it’s complete when the remaining users and old data have been handled safely.
Useful numbers include:
- The number of remaining legacy hashes
- The number of successful upgrades
- The number of failed upgrade writes
- The number of inactive legacy accounts
- Changes in login failures after deployment
- Support requests related to authentication
- Accounts requiring manual recovery
A simple administrative report can show migration progress:
SELECT
password_format,
COUNT(*) AS account_count
FROM users
GROUP BY password_format;
When the remaining legacy population becomes small, decide how those accounts will be handled.
Options include:
- Continue opportunistic migration for a defined period
- Require resets for the remaining accounts
- Contact recently active users directly
- Disable extremely old unused accounts
- Archive accounts according to the application’s retention policy
Only after the remaining accounts have been handled should the MD5 verification path be removed. Temporary compatibility code often becomes permanent when no removal date or condition is defined, and yesterday’s bridge can quietly become tomorrow’s weakest dependency.
A Practical Deployment Order
Modernization is safer when divided into controlled stages. I’d rather make ten understood changes than one impressive change nobody can confidently explain or reverse.
Stage 1: Document the Existing System
Find every place that:
- Creates an authenticated session
- Checks whether a user is logged in
- Stores or compares passwords
- Changes passwords
- Resets passwords
- Logs users out
- Creates persistent login cookies
- Checks roles or permissions
Don’t assume there’s only one login path. Old applications often contain forgotten entry points created for administration, mobile use, integrations or earlier versions of the site.
Stage 2: Back Up the Application and Database
Verify that the backup can actually be restored. A backup that has never been tested is only a hope, and hope is important, but it isn’t a recovery procedure.
Stage 3: Add the New Database Fields
Add space for modern hashes, password-format identification, authentication versions and password-change timestamps. Don’t overwrite the old data until the application understands both formats.
Stage 4: Centralize Authentication Functions
Create shared functions for:
- Starting secure sessions
- Authenticating users
- Checking login state
- Checking authorization
- Expiring sessions
- Logging out
- Validating CSRF tokens
Centralization doesn’t mean building a large framework. It means giving one responsibility one dependable home so the rules don’t drift across dozens of files.
Stage 5: Enable Dual-Format Login
Allow modern hashes and the temporary legacy format. All new registrations and password changes should use modern hashing immediately.
Stage 6: Upgrade Hashes During Successful Login
Record successful upgrades and failed migration writes. Don’t assume the migration is complete because one test account worked.
Stage 7: Replace Password-Based Session Checks
Move protected pages to user-ID, account-status and authentication-version checks.
Stage 8: Add Revocation and Expiration
Implement idle expiration, absolute expiration and password-change invalidation.
Stage 9: Remove Remaining Legacy Dependencies
Search the application again for MD5, SHA-1, password session variables and direct password comparisons. The first search tells you where the migration begins; the final search helps prove what was actually removed.
Stage 10: End Legacy Support
Handle the remaining legacy accounts, remove the old verification path and document the final migration date.
The Goal Isn’t Merely Newer Code
The goal isn’t to replace old code with fashionable code. Newer doesn’t automatically mean wiser, just as older doesn’t automatically mean dependable.
The goal is to create a system that’s easier to reason about:
- The password proves identity during login
- The session remembers that login succeeded
- The internal user ID identifies the account
- Authorization controls what the account may do
- The authentication version invalidates old sessions
- The password hash can improve over time
- Legacy users migrate without unnecessary lockouts
- Security rules live in centralized functions
That separation makes the application safer and easier to maintain. You no longer have many scripts carrying password hashes and running slightly different versions of the same authentication query.
You have one authentication process, one session contract and one place to improve the rules. The system becomes easier to explain, and code we can explain is usually code we have a better chance of protecting.
Final Checklist
- New passwords use password_hash()
- Login uses password_verify()
- Existing modern hashes use password_needs_rehash()
- Legacy MD5 verification is temporary and documented
- Password hashes aren’t stored in $_SESSION
- The session ID is regenerated after login
- Strict session mode is enabled
- Cookies use Secure, HttpOnly and an appropriate SameSite policy
- Session identifiers never appear in URLs
- Protected requests use the internal user ID
- Account status and authorization are checked separately
- Idle and absolute expiration are enforced
- Password changes invalidate old access
- Persistent login uses separate random tokens
- CSRF tokens protect state-changing requests
- Login attempts are rate-limited
- Error messages don’t reveal whether an account exists
- Passwords never enter application logs
- Logout destroys the complete session
- Legacy authentication code has a defined removal point
A legacy PHP application doesn’t need to be discarded because one part of it was built under older assumptions. Study it, respect what still works and separate the responsibilities that became tangled together before improving them one controlled step at a time.
That approach is slower than copying a new login tutorial into the application, but it’s how we modernize a real system without abandoning the people who already depend on it.
I believe the best technical work does more than make code newer. It preserves what deserves to remain, removes what can no longer be trusted and leaves the next developer with a system that’s easier to understand than the one we inherited.