PHP login: verify passwords and bound authenticated sessions
A reasoned learning setup with Argon2id, actual browser observations and clear boundaries for business integration.

A login involves several separate decisions. Does the submitted password match a stored verifier? May the account be used at all? Which session receives the confirmed identity? How long does that association remain valid and how is it revoked? Keeping these questions separate makes failures easier to identify than relying on a single username lookup.
This guide connects password storage and session management with a PHP example that was actually executed. The components were checked using fictional account data. They deliberately cover a bounded learning scope. A complete business login also needs controls against large numbers of attempts, account recovery, appropriate authorisation and potentially additional authentication factors. A successful password comparison does not complete those tasks.
Answer identity and permission questions separately
Authentication establishes whether a presented credential matches an identity. Authorisation decides whether that identity may perform a particular action on a particular object. A logged-in account must therefore not automatically be allowed to read every invoice or use any customer identifier supplied in a URL. Every protected business action needs a server-side permission decision.
A session connects several requests. The browser normally holds a random identifier while the confirmed account identity and other state remain on the server. A cookie containing a freely editable account number is not a substitute. Neither is a submitted field claiming an administrator role trustworthy evidence of permission.
For an existing application, first examine its identity management. An established framework or suitable identity service may already support maintenance and error handling. Designing original cryptography is not a useful shortcut for an ordinary business application. The example below explains particular transitions. It is not a reason to replace an operational login without a migration plan.
Store passwords as verifiable password hashes
PHP provides password_hash as a suitable password derivation interface. The example explicitly selects Argon2id. The PHP build must support that algorithm, which was checked for the runtime used here. An application should detect a missing prerequisite rather than silently switching to an arbitrary fast hash.
The stored result contains the algorithm, parameters and salt required for verification. The function generates the salt. It is not another password and does not need to be secret. Two accounts using the same passphrase should receive different stored hashes. This frustrates reuse of precomputed attacks and was checked with hashes actually generated by the example.
Reversible encryption of a password is also the wrong goal for normal login. A service needs to check a match without returning the original password. A fast general-purpose hash such as a simple SHA-256 call is not an alternative to deliberately expensive password derivation. Protecting the account store and its backups remains necessary even when appropriate hashes are used.
Choose resource parameters deliberately
The OWASP password storage guidance includes an Argon2id minimum configuration of 19 MiB memory, two iterations and one degree of parallelism. The example expresses the memory setting as 19456 KiB in PHP. The units matter and must not be confused.
This configuration is a reasoned starting point, not a capacity claim for every server. Resource demands accumulate across simultaneous logins. Deployment needs to consider legitimate load, abusive attempts and available limits together. The test described here includes no load measurement and makes no claim about achievable logins per second.
A store using other algorithms requires its own migration decision. In particular, bcrypt's known input limit must not be hidden through silent truncation of long passwords. The verification component shown here expressly expects Argon2id records. It is not a universal importer for arbitrary legacy password formats. An unsupported stored format produces an implementation error rather than an unannounced downgrade.
Compare the password input without incidental changes
password_verify checks the input against the stored hash. Creating another hash with a fresh random salt and comparing the strings would generally fail to recognise the correct input. The verification needs to use the parameters encoded in the existing stored verifier.
A login should not casually alter a passphrase by trimming whitespace or changing case. The example preserves the submitted bytes. Its tests cover whitespace, Unicode and different characters beyond the first 72 bytes. These statements describe the tested Argon2id path rather than a property of every historical password scheme.
The 1024-byte processing limit is a technical boundary of this learning component. Longer input is rejected rather than shortened. This does not recommend allowing one-character passwords at registration. Registration rules, usable long passphrases and screening against known compromised passwords need separate decisions. Login input validation and password selection rules serve different purposes.
Handle errors and unknown accounts consistently
A login form should not unnecessarily reveal whether a particular account exists or is disabled. The OWASP authentication guidance consequently considers status codes and processing differences alongside visible messages. In the executed example, a wrong password, an unknown account and a disabled account all receive status 401 with the same outward error message.
An unknown account is checked against a valid dummy hash prepared on the server. This removes the obvious distinction between immediately returning and performing an expensive password verification. The dummy hash is not generated for each request and must not come from a form field. Even if an input happens to match that dummy hash, no account identity may result.
This measure does not prove identical total response times. Account lookup, different stored parameters, infrastructure and other checks can still introduce differences. The report therefore confirms the executed functions and matching outward error responses only. It makes no comprehensive claim against timing-based account enumeration. Invalid data types and excessive request sizes also require bounded handling before expensive processing.
Upgrade old parameters after successful verification
password_needs_rehash compares a stored hash with the desired parameters. This question is asked after the password has been successfully verified. When necessary, a replacement hash is created from the just-confirmed password input. The old hash string is not itself treated as a password and hashed again.
The example component returns a replacement value without persisting it. In a real application, the account store must handle that update and concurrent changes. An update tied to the previous stored version can prevent a delayed login from overwriting a password that has since been changed. The appropriate transaction depends on the storage system.
A failed login must not use this mechanism to change the account. The tests begin with a fictional Argon2id hash using weaker parameters and check both the returned replacement and its actual verifiability. No real account migration was performed. Persistence errors also need a deliberate operational policy rather than being silently reported as a completed password upgrade.
Configure session cookies before starting the session
The PHP session management documentation describes important prerequisites including cookie-only identifiers and strict mode. The example enables these settings before starting a session. With the file handler used in the test, a freely invented, uninitialised identifier is not adopted as an existing session.
The cookie is named __Host-azsid. It is set with Secure, HttpOnly, SameSite=Lax and the path /, without a Domain attribute. The Set-Cookie documentation explains the extra host-prefix requirements in supporting browsers. The actual attributes were inspected in the isolated browser context.
HttpOnly prevents ordinary page script from directly reading the cookie. It does not prevent injected script from making browser requests with existing cookies. Secure does not replace mandatory HTTPS configuration for the application. A session cookie without a persistent expiry also does not reliably promise that every browser closure ends every session, particularly where session restoration is involved.

Change the identifier when login succeeds
A confirmed identity should not simply be inserted into an already known anonymous session identifier. session_regenerate_id permits an identifier change. The example first marks the old anonymous record as retired. It then generates a new identifier and assigns the account identity exclusively to the new session.
The old record is not immediately deleted. Its marker prevents further use in this example. The new record also receives a new CSRF token. The tests deliberately copy the previous identifier into a second browser context and check that it cannot obtain protected access. This examines the security-relevant boundary rather than just checking that two strings differ.
This bounded transition describes an initial login from an anonymous state. Rotating already authenticated sessions, unreliable networks and concurrent requests need a more comprehensive lifecycle design. Immediately deleting old records is not a general solution to those races. Multiple servers also require coordinated storage and revocation. The component does not claim to solve those distributed operational problems.
Protect state changes with method and CSRF checks
The integration accepts login and logout only through POST. Simply visiting a GET address does not log anyone in or out. Both actions also require a random token from the relevant session. Missing or mismatched values cause rejection before the action takes place.
The OWASP CSRF guidance explains this session-bound synchronizer-token approach and recommends using existing framework protection. The token belongs in an appropriate request component rather than a URL or analytics log. Its unpredictable server-side generation matters more than an inventive field name.
In the example, the previous token is deliberately reused for logout after a successful login. The request is rejected and the valid session remains active. Only the new correct token causes logout. This establishes one actual protection path. It does not replace a review of every endpoint or protection against injected page script. Callers must keep method and token checks in front of the state-changing component functions.
Check inactivity and absolute lifetime on the server
A browser cookie and PHP storage cleanup do not alone determine how long authentication should remain valid. The example records the start of the confirmed session and the last successfully checked protected access. Each relevant request checks both intervals against a server-side clock.
The learning example uses 900 seconds of inactivity and an absolute lifetime of 28800 seconds. These are deliberately selected example values. Sensitive systems may need different limits and renewed confirmation before important actions. Continuous background polling should not inadvertently defeat the intended business meaning of inactivity.
| Observed case | Result | Meaning |
|---|---|---|
| 899 seconds since last protected access | Access allowed in the example | The inactivity threshold has not been reached |
| Exactly 900 seconds since last successful access | Status 401 | The boundary is inclusive |
| Regular activity before eight hours | Protected requests allowed | The last-activity timestamp advances |
| Exactly eight hours since login despite activity | Status 401 | The absolute boundary still applies |
| Identifier reused after logout | No authenticated access | The server-side record has been retired |
The test clock was controlled inside the private fixture. A request parameter must not supply the authoritative time in a real application. A negative interval caused by an inconsistent clock should not silently extend authentication either. The component rejects implausible timestamp ordering. Production clock management and monitoring remain part of the surrounding system.
The verified PHP components
The following complete component expects trusted account records and correctly configured session storage. Before calling the login transition, the integration must have checked the method, CSRF token and credentials. The components were executed together with an isolated integration. A product application should preserve these contracts through its own central authentication layer.
<?php
declare(strict_types=1);
const PASSWORD_OPTIONS = ['memory_cost' => 19456, 'time_cost' => 2, 'threads' => 1];
const IDLE_SECONDS = 900;
const ABSOLUTE_SECONDS = 28800;
function createPasswordHash(string $password): string
{
if ($password === '' || strlen($password) > 1024) {
throw new InvalidArgumentException('Passworteingabe ausserhalb des Umfangs.');
}
return password_hash($password, PASSWORD_ARGON2ID, PASSWORD_OPTIONS);
}
// $account and $dummyHash come from trusted server-side storage.
// Registration policy and persistence of a replacement hash are separate tasks.
function verifyAccount(?array $account, mixed $password, string $dummyHash): ?array
{
if (!is_string($password) || $password === '' || strlen($password) > 1024) {
return null;
}
$hash = $account['hash'] ?? $dummyHash;
if (!is_string($hash) || password_get_info($hash)['algoName'] !== 'argon2id') {
throw new LogicException('Dieses Beispiel erwartet Argon2id-Datensaetze.');
}
$matches = password_verify($password, $hash);
if ($account === null || !$matches || ($account['active'] ?? false) !== true) {
return null;
}
if (!is_int($account['id'] ?? null) || $account['id'] < 1) {
throw new LogicException('Ungueltiger serverseitiger Kontodatensatz.');
}
return [
'id' => $account['id'],
'replacement_hash' => password_needs_rehash($hash, PASSWORD_ARGON2ID, PASSWORD_OPTIONS)
? createPasswordHash($password) : null,
];
}
// Call before output. The external application MUST enforce HTTPS.
function beginSession(): bool
{
if (session_status() !== PHP_SESSION_NONE) {
throw new LogicException('Sitzung bereits gestartet.');
}
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.use_trans_sid', '0');
session_name('__Host-azsid');
session_set_cookie_params([
'lifetime' => 0, 'path' => '/', 'domain' => '',
'secure' => true, 'httponly' => true, 'samesite' => 'Lax',
]);
if (!session_start()) {
throw new RuntimeException('Sitzung konnte nicht gestartet werden.');
}
if (isset($_SESSION['retired_at'])) {
return false;
}
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
return true;
}
function validCsrf(mixed $token): bool
{
return is_string($token) && strlen($token) === 64
&& is_string($_SESSION['csrf'] ?? null)
&& hash_equals($_SESSION['csrf'], $token);
}
// Only call after successful credential verification and a valid login CSRF token.
// $now is server time, never a time supplied in the request.
function establishLogin(int $accountId, int $now): void
{
if ($accountId < 1 || $now < 1 || session_status() !== PHP_SESSION_ACTIVE
|| isset($_SESSION['retired_at']) || isset($_SESSION['auth'])) {
throw new LogicException('Anmeldeuebergang nicht zulaessig.');
}
$_SESSION['retired_at'] = $now;
if (!session_regenerate_id(false)) {
throw new RuntimeException('Sitzungswechsel fehlgeschlagen.');
}
$_SESSION = [
'csrf' => bin2hex(random_bytes(32)),
'auth' => ['id' => $accountId, 'created_at' => $now, 'last_seen' => $now],
];
}
function authenticatedUser(int $now): ?int
{
$auth = $_SESSION['auth'] ?? null;
if (isset($_SESSION['retired_at']) || !is_array($auth)) {
return null;
}
$valid = is_int($auth['id'] ?? null) && $auth['id'] > 0
&& is_int($auth['created_at'] ?? null) && is_int($auth['last_seen'] ?? null)
&& $auth['created_at'] > 0 && $auth['created_at'] <= $auth['last_seen']
&& $auth['last_seen'] <= $now
&& $now - $auth['last_seen'] < IDLE_SECONDS
&& $now - $auth['created_at'] < ABSOLUTE_SECONDS;
if (!$valid) {
unset($_SESSION['auth'], $_SESSION['csrf']);
$_SESSION['retired_at'] = $now;
return null;
}
$_SESSION['auth']['last_seen'] = $now;
return $auth['id'];
}
// The caller must require POST and a valid CSRF token first.
function retireSession(int $now): void
{
$_SESSION = ['retired_at' => $now];
setcookie(session_name(), '', [
'expires' => time() - 3600, 'path' => '/', 'domain' => '',
'secure' => true, 'httponly' => true, 'samesite' => 'Lax',
]);
}
Treat logout as a server-side state transition
Logout removes the confirmed identity from the current session record and marks that record retired. The matching browser cookie is additionally expired with the appropriate attributes. Merely deleting a browser cookie would not automatically invalidate an identifier that had previously been copied from it.
The test therefore explicitly attempts to reuse the logged-out identifier in another browser context. Protected access remains denied. A “log out everywhere” feature would require a separate mechanism covering all sessions associated with an account. This single-session storage example does not include that capability. It must not be inferred from the presence of a logout action.
Interpret the actual verification scope
The run completed 68 component checks and 81 Edge checks through isolated HTTPS, for 149 checks in total. Component cases cover hashes, input boundaries, parameter upgrades, tokens and timestamp boundaries. Browser cases examine cookie properties, methods, error responses, identifier rotation, replay and expiry. These are concrete observations rather than a general security certification.
The HTTPS setup uses only a fictional account and a self-signed fixture certificate on a loopback address. An isolated browser context accepts that certificate for this test. This does not verify a production website's certificate chain or transport configuration. Distributed sessions, attack load and simultaneously competing requests were also outside the verification scope.
Checklist for a business deployment
Before public deployment, establish how login attempts will be limited. A source-IP limit alone incompletely addresses distributed attempts against the same account. A rigid permanent account lock can itself be used against the legitimate person. An operational policy therefore combines appropriate account references, bounded observation windows, other signals and a controlled recovery route. Its implementation must work across processes and servers rather than giving each concurrent request an independent counter.
These decisions belong before expensive password processing or within a coordinated admission policy. Otherwise an attacker can exploit the intended password cost to exhaust resources. Internal events may remain distinguishable for operational diagnosis, but outward responses should not reveal extra account information. The example contains no improvised per-session counter presented as a complete defence against automated attempts.
Account recovery deserves the same care as login. Good password storage is undermined if a predictable link allows an attacker to replace the password. Recovery credentials need a bounded purpose, appropriate randomness, suitable short validity and controlled single use. Whether other sessions are invalidated and what notification is sent are explicit design decisions. Important accounts may also need additional confirmation or another authentication factor. Those separate processes are not implemented or claimed as verified here.
Usability also influences protection. A login form should support password managers, allow passphrase pasting and provide properly labelled fields. Reauthentication should support legitimate work without copying sensitive form data into a URL. Any post-login return destination needs validation so an arbitrary external address does not become an apparently trusted redirect. These are integration decisions rather than properties supplied by a more complicated password field.
- Evaluate the existing identity service and its maintenance before building a replacement.
- Check password derivation, stored parameters and supported runtime.
- Preserve passphrases and reject unsupported lengths explicitly.
- Consider generic errors across status, content and processing.
- Test HTTPS, cookie attributes and actual identifier rotation together.
- Protect login, logout and other state changes against CSRF.
- Define inactivity, absolute lifetime and server-side revocation.
- Add appropriate throttling, recovery and further factors.
- Check business permissions on every protected operation.
- Limit logs to necessary events and exclude passwords and session tokens.
A verified example provides an inspectable technical component. A dependable login combines that component with deliberate operation, checked business permissions and an accountable process for recovering access.








