SoFloPHPIndependent reference

Storing Passwords in PHP with password_hash and Argon2id

Use PHP's built-in password_hash with argon2id to store passwords securely, including a migration strategy from legacy hashes with password_needs_rehash.

The password_hash Argon2id Default Is Not What Your Database Expects

A VARCHAR(60) column silently truncates an Argon2id hash. The hash looks valid to the application but fails every verification because the salt or parameters are chopped off. If you migrated to Argon2id without altering your users table, you are storing corrupted hashes right now. Most developers assume calling password_hash() with PASSWORD_ARGON2ID in PHP 8.x is the end of the password storage story. The real break happens earlier, at the schema level.

PHP Password Storage Argon2id: The Algorithm Choice

The OWASP Password Storage Cheat Sheet (current edition) lists Argon2id as the preferred choice because it combines Argon2i’s side-channel resistance with Argon2d’s resistance to GPU cracking. PHP exposes it through the password_hash() function with the PASSWORD_ARGON2ID constant, available since PHP 7.3. The password_hash PHP API handles salt generation internally. Never pass a custom salt. Doing so raises a deprecation notice and, since PHP 8.0, a fatal error. The function returns a string that includes the algorithm identifier, the salt, and the digest, all delimited by dollar signs.

Why VARCHAR(60) Breaks the New Default

A PASSWORD_BCRYPT hash is always 60 characters. A PASSWORD_ARGON2ID hash is variable but usually 96 characters or longer depending on the memory_cost and time_cost parameters. When you insert a 96-character string into a VARCHAR(60) column and the server is not in strict mode, MySQL truncates the value without a warning. The stored hash is now missing its trailing parameters, and password_verify PHP calls return false for the correct password. This is the single most common production failure when teams adopt the new constant without a schema migration.

Alter the column to VARCHAR(255). That size gives you a margin for future algorithm output growth.

A Complete Migration Path from Legacy Hashes

Migrate Active Users on Login

Most production stores hold a mix of old md5, sha1, or bcrypt hashes alongside newer ones. You do not need a one-time mass rehash operation. The password_needs_rehash() function checks whether a stored hash matches the current scheme and options. When a user logs in successfully, run password_needs_rehash() on their stored hash. If it returns true, call password_hash() with your current parameters and save the new hash. Active users migrate automatically. Inactive accounts keep their legacy hash. Handle those with a forced reset on next login.

Handle Legacy Markers and Peppers

This pattern works from any legacy scheme as long as you store a marker to identify the scheme. Store a column like password_alg with values 0 for the old hash type and 1 for the current one. During verification, branch on this value. A common mistake is to upgrade from a salted scheme to Argon2id and discard the old salt routine. If your legacy hash used a pepper, that pepper must still be applied before Argon2id takes over.

password_hash PHP: Tuning the Argon2id Parameters

Set the Three Required Options

The password_hash PHP function accepts an array of options when using Argon2id. The three you must set are memory_cost, time_cost, and threads. The defaults set memory_cost to 64 MiB, time_cost to 4, and threads to 1. The OWASP Cheat Sheet recommends a minimum of 46 MiB for memory_cost and a time_cost that produces a hash in under 500 milliseconds on your production hardware. Measure this. A common error is to copy example values that run in 50 ms on a local machine but take 2 seconds on a small production instance, causing login timeouts under load.

Benchmark Before You Commit

Benchmark with a script that calls password_hash() ten times and records the median. Adjust memory_cost first. It provides the strongest resistance to cracking. If your server runs multiple requests concurrently, the memory per hash matters. A high memory_cost is safe on a server with 2 GiB of RAM only if you limit the number of concurrent logins. The password_needs_rehash call you use during migration must match these tunable values exactly. Store them in a config constant that both hashing and rehash checks reference.

password_verify PHP: Timing Attacks and Early Exit

password_verify() uses a constant-time comparison internally for the scheme that the stored hash declares. If the hash uses Argon2id, the comparison is constant-time. If you write your own verification logic that checks a legacy hash first and then calls password_verify, you introduce a timing side-channel on the legacy path because hash_equals() was not used. Every code branch that compares a user-provided secret must use hash_equals() or a built-in constant-time function. Never compare hashes with == or ===.

When password_verify returns false, your application must not reveal whether the user exists or the password was wrong. A single error message like “Invalid credentials” covers both. Log the failure server-side with a generic message that does not include the password attempt. This is a basic account enumeration defence, and it applies to every authentication endpoint in your PHP application.

PHP Password Migration: The Schema and Code Checklist

Three Layers to Update

A migration to Argon2id touches three layers. First, the column must be VARCHAR(255). Second, the user table needs a column to track the hash scheme version, such as a password_version TINYINT that starts at 1 for the new scheme. Third, the login controller runs password_needs_rehash() and updates both the hash and the version column in a single transaction. Rollback the transaction if the update fails. This avoids a state where the password is reset to a legacy hash after the user is upgraded.

Bcrypt to Argon2id Specifics

For a bcrypt to Argon2id migration, the steps are identical except the initial version check skips the old scheme marker. Bcrypt hashes are identifiable by their $2y$ prefix, so password_needs_rehash() detects them automatically. A common mistake is to run password_needs_rehash() on every page load rather than only after a successful password verification. This causes unnecesary CPU load with no security benefit. The rehash check must sit inside the block that runs only after password_verify() returns true.

Database Column Sizes That Break or Work
Column DefinitionWorks for BcryptWorks for Argon2idFailure Mode
VARCHAR(60)YesNoSilent truncation; verifications fail
VARCHAR(96)YesDepends on parametersSome hashes truncated at high memory_cost
VARCHAR(255)YesYesNo failure with current PHP defaults
TEXTYesYesSlower indxes; avoid for a password column
CHAR(96)No (wasted space)YesPading breaks hash comparison

OWASP Password Storage and the Salt-and-Pepper Pattern

Apply the Pepper Before Hashing

The OWASP Password Storage Cheat Sheet recommends a pepper in addition to the salt that password_hash() generates. A pepper is a random string, at least 32 bytes, stored outside the store, typically in an environment variable. You apply the pepper before handing the password to password_hash(). The simplest method is to concatente the pepper with the user’s password: $pepered = $password . getenv('PEPPER');. Then pass $pepered to password_hash(). During verification, you reconstruct the same string.

Rotate Peppers Safely

If the pepper is rotated, you must store a pepper version in the store and try each active pepper during verification until one matches or all fail. The pepper provides a defence in depth: if the store is compromised but the environment variable is not, the hashes cannot be cracked. PHP developers often skip this step because the password_hash() API abstracts the salt away. But a salt alone protects against precomputed tables, not against brute-force cracking of a weak password once the store is stolen. The pepper raises the cost of cracking to the point where an attacker must also compromise the server configuration.

Testing Your Argon2id Implementation Before It Hits Production

Test Hashing and Verification

Write a PHPUnit test that calls password_hash() with a known password and your production memory_cost and time_cost, then verifies it immediately with password_verify(). Assert that the result is true. Then assert that the returned hash string starts with $argon2id$. After that, test the negative case: a modified hash string must fail verification. This catches the silent truncation problem if your test column is sized incorrectly.

Test the Migration Path

A second test must cover the migration path. Insert a bcrypt hash for a test user, simulate a login, and assert that password_needs_rehash() returns true for that hash and false for a fresh Argon2id hash. Then simulate the update and verify the new hash works. These three tests, run against a store that matches your production schema, will catch the majority of password storage bugs before they reach users.

PHP 8.x Changes That Affect Password Storage

PHP 8.0 removed the ability to pass a custom salt to password_hash(). Any code that still does this will throw a fatal error. If you are migrating from PHP 7.4, audit every call to password_hash() for a third argument that contains a “salt” key. PHP 8.1 added the password_algos() function, which returns an array of available scheme constants. Use this to check for PASSWORD_ARGON2ID support before configuring it, especially if your code runs on unknown hosting environments where the constant may not be defined. PHP 8.2 and 8.3 did not change the password API, but the underlying Argon2 library may have been updated, affecting hash compatibility. Hashes generated on a newer libargon2 are verifiable on older versions as long as the parameters are the same.

What Still Goes Wrong Even After You Do Everything Above

The most persistent failure is a mismatch between the hashing parameters in your config file and the password_needs_rehash() call. If you tune memory_cost from 64 MiB to 128 MiB and update only the password_hash() call, every login triggers a rehash because the stored hash parameters differ from the new ones. This causes a write storm on the user table. Store your hashing options in a single constant and reference it from both the hash and rehash functions.

The second persistent failure is deploying to a server where the argon2id constant does not exist. This throws a fatal error on the first login attempt. Check for the constant with defined(‘PASSWORD_ARGON2ID’) before using it, and fall back to PASSWORD_BCRYPT with a log warning so you know the server is misconfigured.

Common Questions

Does password_hash() need a salt parameter with Argon2id?

No. PHP generates a cryptographically secure salt internally. Passing a custom salt was deprecated in PHP 7.0 and removed in PHP 8.0. Calling password_hash() with a salt option now throws a fatal error.

What happens if my column is too small for the hash?

The stored value is truncated. password_verify() returns false for the correct password because the salt or paramters are missing. Alter the column to VARCHAR(255) before switching to Argon2id.

How do I migrate old md5 or sha1 paswords to Argon2id?

Use password_needs_rehash() after a successful login with the legacy hash. Store a version flag to track which scheme a hash uses. Inactive users keep their old hash until they log in.

Should I use a pepper with password_hash()?

Yes. The OWASP Password Storage Cheat Sheet recommends a pepper stored outside the store. Concatente it with the password before calling password_hash(). This protects aganst cracking if the store alone is compromised.

What is the best memory_cost for Argon2id in production?

Measure on your hardware. The OWASP Cheat Sheet recommends a minmum of 46 MiB. A good starting point is memory_cost => 65536 (64 MiB) with time_cost => 4. Adjust until hashing takes no more than 500 ms.

Does password_verify() use constant-time comparison?

Yes, when the hash scheme is Argon2id or bcrypt. If you write a custom legacy verification path, you must use hash_equals() for the comparison or you introduce a timing sid-channel.

Can I use Argon2id on PHP 7.4?

Argon2id was added in PHP 7.3, but it must be compild in. Use password_algos() to check for the PASSWORD_ARGON2ID constant before relying on it. Some shared hosts did not compile libargon2.