CWE-287: Improper Authentication - PHP
Overview
PHP authentication appears both in hand-rolled session-based logins (native $_SESSION) and in framework auth guards (Laravel's Auth facade/guards, Symfony Security). Improper authentication commonly appears as a credential check that compares passwords with ==/!= or a raw hash function instead of password_verify() - which is timing-unsafe and permits weak or unsalted hashing schemes. It also appears in JWT handling with firebase/php-jwt: calling JWT::decode() with the deprecated array-of-algorithms form, or without a bound algorithm, leaves the token's own alg header able to influence which algorithm actually verifies it. Session fixation is another common finding - if session_regenerate_id() is never called after login, an attacker who sets a known session ID on the victim's browser inherits the authenticated session once the victim logs in.
The fix is to verify credentials with password_verify(), decode JWTs with firebase/php-jwt 6.x+'s Key-object form that binds one explicit algorithm to the key, and call session_regenerate_id() (or the framework-native equivalent) immediately after a successful login - see below on whether to pass true.
Common Vulnerable Patterns
Loose Password Comparison
<?php
// VULNERABLE - == comparison against a plaintext or weakly hashed password
if ($submittedPassword == $user['password']) {
$_SESSION['user_id'] = $user['id'];
header('Location: /dashboard');
}
// Attack example:
// If $user['password'] was stored via md5() and an attacker finds a colliding
// or precomputed hash, or if it is stored in plaintext and leaked via another
// vulnerability, == offers no protection and no timing resistance either
Why this is vulnerable: == performs a type-juggling comparison in PHP and, separately, is not constant-time - both make it the wrong tool for comparing secrets. Worse, this pattern is usually paired with a weak hashing function (md5(), sha1(), or no hashing at all).
JWT Decoded With the Deprecated Array-of-Algorithms Form
<?php
// VULNERABLE - legacy firebase/php-jwt call form (pre-6.0), and no explicit binding of key to algorithm
use Firebase\JWT\JWT;
$decoded = JWT::decode($jwtToken, $signingKey, ['HS256', 'RS256']);
$userId = $decoded->sub;
// Attack example:
// A token re-signed with a different algorithm from the list, using key
// material intended for a different algorithm family, can pass verification
// depending on library version and key type
Why this is vulnerable: Listing multiple algorithms - especially mixing HMAC and RSA families - widens the surface for algorithm-confusion attacks, and the deprecated array form does not bind one algorithm to one key the way the current Key object does.
Session Not Regenerated on Login
<?php
// VULNERABLE - the session ID from before login is reused after authentication
session_start();
if (password_verify($password, $user['password_hash'])) {
$_SESSION['user_id'] = $user['id']; // same session ID as before login
header('Location: /dashboard');
}
// Attack example:
// Attacker gets the victim to visit a link containing a known PHPSESSID,
// victim logs in, session ID is unchanged - attacker's copy of that ID is
// now an authenticated session
Why this is vulnerable: Without session_regenerate_id(), the session identifier that existed before authentication - which an attacker may have set via a crafted link, subdomain cookie, or exposed session parameter - simply becomes the authenticated session's identifier once login succeeds.
Secure Patterns
Verify Credentials With password_verify()
<?php
// SECURE - constant-time verification against a properly hashed password, and an
// unknown account costs the same as a wrong password
// A real bcrypt hash at the same cost as the stored ones, used only to spend the
// same time on an account that does not exist. It has to be a genuine hash:
// password_verify() against '' or a string too short to parse returns in
// microseconds and the timing gap reopens. Regenerate it with password_hash() if
// your deployment's cost differs from the 12 encoded here.
const DUMMY_HASH = '$2y$12$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG';
$user = getUserFromDatabase($submittedEmail);
// Verify on both branches. A row with no password set (an SSO-only account) takes
// the dummy as well, or it becomes the fast case instead of the missing row.
$storedHash = ($user === null || $user['password_hash'] === '')
? DUMMY_HASH
: $user['password_hash'];
if (!password_verify($submittedPassword, $storedHash)
|| $user === null
|| $user['password_hash'] === '') {
throw new AuthenticationException('Invalid credentials');
}
// Transparently upgrade the stored hash if hashing parameters have improved
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$newHash = password_hash($submittedPassword, PASSWORD_DEFAULT);
// persist $newHash for $user
}
Why this works: password_verify() re-derives the hash from the submitted password using the algorithm, cost, and salt embedded in the stored hash, and compares the result in constant time - eliminating both the type-juggling risk of == and timing side-channels. password_needs_rehash() lets the application transparently move users to stronger hashing parameters (a higher PASSWORD_ARGON2ID cost, for example) on their next successful login without a separate migration step.
Calling password_verify() on every branch is what keeps the response time from answering "does this address have an account". Returning as soon as getUserFromDatabase() comes back null skips the whole cost: measured on PHP 8.5, where PASSWORD_DEFAULT is bcrypt at cost 12, a wrong password took 218 ms and an unknown address 0.0004 ms - and once both branches verify, 220 ms against 223 ms. password_verify() is only constant-time within a comparison; it is not free, and the branch that skips it is the leak. Every login now pays the full hashing cost, so pair this with rate limiting on the endpoint.
Decode JWTs With an Explicit Algorithm Bound to the Key
<?php
// SECURE - firebase/php-jwt 6.x+: Key binds exactly one algorithm to the key
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$decoded = JWT::decode($jwtToken, new Key($signingKey, 'HS256'));
$userId = $decoded->sub;
// Key rotation: an array of Key objects, selected by the token's kid header
$decoded = JWT::decode($jwtToken, [
'current-key-id' => new Key($currentKey, 'HS256'),
'previous-key-id' => new Key($previousKey, 'HS256'),
]);
Why this works: Wrapping the signing key in a Key object pins one specific algorithm to that key in code, so JWT::decode() throws (UnexpectedValueException/SignatureInvalidException) on any token whose header claims a different algorithm, including none. Because the binding is explicit rather than inferred from the token or a loosely typed array, an attacker cannot use the token's own header to select a weaker verification path. The kid-keyed array form supports key rotation without reopening the algorithm-confusion risk the deprecated array-of-algorithms form carried.
Regenerate the Session ID on Login
<?php
// SECURE - a fresh session ID is issued at the moment authentication succeeds
session_start();
if (password_verify($submittedPassword, $user['password_hash'])) {
session_regenerate_id(true); // true deletes the old session data; see the trade-off below
$_SESSION['user_id'] = $user['id'];
header('Location: /dashboard');
exit;
}
Why this works: session_regenerate_id() issues a new session ID, so a session ID set before login - by an attacker's crafted link or otherwise - is no longer the one the authenticated state is attached to. Calling it immediately after credential verification, before writing user_id into the session, is what keeps the two from ever meeting. That much is the fix, and it does not depend on the argument.
On the true. It deletes the old session's data on the server, so the old ID stops referring to anything at all rather than to a stale but live session. The PHP manual argues the other way, and it is worth knowing why before treating true as simply correct: "You should not destroy old session data immediately, but should use destroy time-stamp and control access to old session ID", because "concurrent access to page may result in inconsistent state, or you may have lost session", and "immediate session data deletion disables session hijack attack detection and prevention also". The manual's own example writes a $_SESSION['destroyed'] timestamp instead and rejects the old ID after a short grace period, which both survives a request in flight and gives you a signal when someone presents an ID that was already rotated. Pass true when the login flow is simple enough that nothing else is mid-request, and take the manual's shape when it is not; what is not defensible is calling session_regenerate_id() and then continuing to honour the old ID indefinitely.
Framework-Specific Guidance
Laravel Authentication Guards
<?php
// SECURE - Laravel's Auth facade handles hashing, verification, and session regeneration
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class LoginController extends Controller
{
public function login(Request $request)
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (Auth::attempt($credentials)) {
$request->session()->regenerate(); // explicit defense-in-depth, even though
// Auth::attempt() also protects against fixation
return redirect()->intended('dashboard');
}
return back()->withErrors(['email' => 'Invalid credentials']);
}
}
Auth::attempt() compares the submitted password against the stored hash using Laravel's configured hasher (bcrypt/Argon2id via password_verify() internally) - never write a manual $user->password === $request->password check to "simplify" this. Calling $request->session()->regenerate() explicitly after Auth::attempt() succeeds is standard practice in Laravel's own starter kits and guards against fixation even if session handling is customized elsewhere in the request lifecycle.
Auth::attempt() closes the enumeration timing channel itself, and the mechanism is not the one you would expect from reading the credential check. SessionGuard::hasValidCredentials() is ! is_null($user) && $this->provider->validateCredentials($user, $credentials), so when the user provider finds no matching record the hasher is genuinely never reached. What removes the observable difference is one level up: attempt() runs its whole body inside $this->timebox->call(...) with $this->timeboxDuration, which the constructor defaults to 200000 microseconds, and $timebox->returnEarly() is called only on the success path. Every failing path, no-such-user included, is padded to 200 ms. Do not add a Hash::check($password, DUMMY_HASH) on the getLastAttempted() === null branch: on any supported release it is a second verification inside an already-padded call, which closes nothing and buys a wrong impression of the framework. The advice was correct against Laravel below 9.32.0, where no timebox existed - check the version before writing this up.
Symfony Security Component
# SECURE - security.yaml: password hashing algorithm and firewall configuration
security:
password_hashers:
App\Entity\User:
algorithm: auto # currently resolves to a strong bcrypt/Argon2i default
firewalls:
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
logout:
path: app_logout
Symfony's Security component performs password verification and session handling internally when form_login is used - it regenerates the session on successful authentication by default. Avoid bypassing the PasswordHasherInterface/UserAuthenticatorInterface services with a manual comparison in a custom controller; a hand-written check reintroduces exactly the risk the framework's authenticator already closes.
Testing
- Submit an incorrect password and confirm
password_verify()returnsfalseand the request is rejected, not merely logged. - Time four logins - known account with the right password, known account with a wrong password, unknown account, and an account row whose
password_hashis empty - and assert all four are within noise of each other. A sub-millisecond answer for any of them is the enumeration oracle, and a re-scan cannot see it. - Craft a JWT with
alg: noneor a mismatched algorithm and confirmJWT::decode()throws, and that the exception path rejects the request rather than falling back to unverified claims. - Capture the session cookie before login, authenticate, and confirm the pre-login session ID no longer works after
session_regenerate_id()runs. - For Laravel/Symfony, write a feature test asserting
Auth::attempt()/form_loginrejects bad credentials and that$request->session()->getId()(or the equivalent) changes across the login boundary. - Re-scan with the security tool that originally reported the finding to confirm it no longer fires.
Common Pitfalls
- Replacing
==withhash_equals()on a comparison that still uses a weak hash function (MD5, SHA1, or no hashing) -hash_equals()fixes the timing-safety of the comparison but does nothing about a weak or unsalted hash; usepassword_hash()/password_verify()for password storage specifically, reservinghash_equals()for comparing tokens like CSRF or API keys that are not password hashes. - Catching the exception
JWT::decode()throws and falling back to a default "guest" or previously cached claims instead of rejecting the request outright - any catch block around JWT verification must result in an authentication failure, never a silent downgrade to unverified data. - Leaving the old session live after regenerating:
session_regenerate_id()withouttruekeeps the old ID's data readable for the remainder of its lifetime, so the pre-login ID an attacker planted still resolves to a session. Passingtruecloses that, at the cost the manual warns about; the alternative it recommends is adestroyedtimestamp in the old session plus a short grace period. Either is defensible. Doing neither is the finding. - Adding a custom login controller in Laravel/Symfony that bypasses
Auth::attempt()/the security firewall "to support a special case" (SSO callback, legacy API) - each bypass needs its own explicit password verification and session regeneration; it does not inherit the framework's protections automatically.
Dependencies and Installation
firebase/php-jwt6.x or later (composer require firebase/php-jwt) - use theKey-objectJWT::decode()form; upgrade from any 5.x/legacy usage that still passes an array of algorithm names.- PHP's built-in
password_hash()/password_verify()require no extra package and should be the default for new password storage; preferPASSWORD_ARGON2IDwhen thesodium/argon2extension is available, otherwisePASSWORD_DEFAULT. - Laravel:
laravel/framework's bundledIlluminate\Authcomponent. Symfony:symfony/security-bundle. Both are already present in a standard framework installation.
The JWT signing secret must come from environment configuration or a secret manager, not a literal in source. A secret committed to the repository is readable by anyone with history access and cannot be rotated without a code change, which turns a routine rotation into a deployment.
Migration Considerations
Switching from a legacy hash (MD5/SHA1/plaintext) to password_hash() cannot re-hash existing stored values retroactively - use password_needs_rehash() to upgrade each user's hash transparently on their next successful login rather than forcing a mass password reset. Tightening JWT validation (moving to the Key-object form, narrowing accepted algorithms) will reject tokens issued under the old configuration; coordinate the change with whatever issues the tokens and expect a transition period where both old and new tokens may need to be accepted via the kid-keyed array form. Enabling session_regenerate_id() where it was previously absent invalidates any code path that assumed a stable session ID across the login boundary, such as a pre-login shopping cart correlated only by session ID - carry forward any pre-login session data explicitly before regenerating.