Secure Login and Logout — Session Regeneration, Enumeration Resistance, and Rate Limiting

Build a complete secure login flow that verifies credentials safely, regenerates the session ID on successful login with session_regenerate_id(true), implements secure logout that fully destroys session state, avoids leaking whether an email/username exists via identical error messages and consistent timing, and rate-limits repeated login attempts.

Getting password hashing right (the previous lesson) is necessary but not sufficient -- the login and logout endpoints built around a correctly hashed password can still leak information or leave a system open to abuse in several distinct ways: session fixation if the session ID isn't rotated on login, account enumeration if error messages or timing reveal which emails have accounts, brute-force credential stuffing if login attempts aren't rate-limited, and stale session data if logout doesn't fully tear the session down. This lesson builds a login/logout flow that closes all four.

Verifying Credentials and Regenerating the Session

A login handler looks up the account by the submitted identifier (email or username), verifies the submitted password against the stored hash with password_verify(), and only on success considers the user authenticated. The critical, easy-to-skip step comes right after: call session_regenerate_id(true) before writing anything new into the now-authenticated session.

Without regeneration, the session ID the user had BEFORE logging in (which could have been set by the server on their very first, unauthenticated visit, and which an attacker could have captured or even forced onto the victim -- a 'session fixation' attack) remains valid and now becomes an authenticated session ID after login. Regenerating swaps in a brand-new, freshly random session ID at the exact moment privilege changes, so any session ID an attacker might already know from before login becomes worthless afterward. Passing true to session_regenerate_id() additionally deletes the OLD session's data file on the server, not just issuing a new ID -- omitting true leaves the old session's data (and its old ID, if somehow still known) usable.

Only after regeneration should the handler write the authenticated user's ID (and nothing more sensitive than necessary) into $_SESSION. This ordering -- verify, THEN regenerate, THEN write session data -- matters: writing session data before regenerating means that data briefly exists under the old, potentially-fixated session ID.

• Verify credentials with password_verify() before doing anything else. • session_regenerate_id(true) on successful login -- the true argument deletes the old session file, not just issuing a new ID. • Regenerating defeats session fixation: any session ID known/set before login becomes worthless once the ID changes at the privilege boundary. • Order matters: verify -> regenerate -> THEN write $_SESSION data, never data first.

Secure Logout

Logging out has to remove session data both server-side and client-side, not just one or the other. Server-side, $_SESSION = [] clears the in-memory array and session_destroy() removes the session's stored data entirely. Client-side, the session cookie itself should be explicitly expired by resending it with a past expiration time -- session_destroy() alone does not clear the cookie from the browser, so without this step the browser keeps sending the now-meaningless session ID on every subsequent request.

A common incomplete implementation only calls session_destroy() and considers logout done; the visible symptom (the user appears logged out because $_SESSION is empty going forward) can mask that a stale cookie is still floating around client-side referencing a session ID the server has already discarded -- harmless once truly discarded, but sloppy, and a source of confusing bugs if any code path re-creates a session under that same stale cookie value.

• Clear the array first: $_SESSION = []. • Then destroy server-side storage: session_destroy(). • Then explicitly expire the cookie client-side by resending it with a past expiry time -- session_destroy() does not do this by itself. • After logout, any request using the old session ID should be treated as unauthenticated, never silently re-associated with the old account.

Avoiding Account Enumeration

Account enumeration is when an attacker can determine which email addresses or usernames have registered accounts purely from a login (or registration, or password-reset) form's behavior -- most commonly because 'no account with that email' and 'wrong password' return visibly different messages. Knowing which emails are registered is valuable reconnaissance for an attacker: it turns a general credential-stuffing list into a targeted one, and it can leak sensitive information on its own (e.g. confirming a specific person has an account on a dating app or a mental-health service).

The fix is returning the exact same generic message -- 'Invalid email or password' -- for both 'no such account' and 'account exists but wrong password', with the same HTTP status code and, ideally, the same rendered response shape, so nothing in the response body distinguishes the two cases.

Timing is a subtler enumeration channel: if a nonexistent email short-circuits immediately (no hash to verify against) while an existing email takes measurably longer (a real password_verify() call, which is deliberately slow), an attacker can distinguish the two purely by response latency even with identical error text. The fix is to always run a password_verify() call -- against a precomputed dummy hash when the account doesn't exist -- so the response takes a comparable amount of time either way.

• Return the identical generic message ('Invalid email or password') whether the email doesn't exist or the password was wrong. • Same HTTP status code and response shape for both cases -- nothing in the response should let a client distinguish them. • Timing leaks the same information even with identical text -- always run a password_verify() (against a dummy hash if no account exists) so response time doesn't vary. • The same principle applies to registration ('email already in use') and password reset -- covered in the next lesson.

Rate Limiting Login Attempts

Without a limit, an attacker (or a botnet running credential stuffing with breached password lists from other sites) can attempt unlimited password guesses against a single account or sweep many accounts with one common password each, entirely automated. Rate limiting caps how many login attempts are allowed within a time window, per relevant key -- typically both per-IP-address and per-account (email) -- so a single source (or a single targeted account) can't attempt unbounded guesses.

A simple implementation tracks a counter and a window-start timestamp keyed by IP and/or email in a fast store (Redis, memcached, or even a database table for lower-traffic sites), incrementing on each failed attempt and rejecting further attempts once a threshold is hit within the window, with the counter resetting after the window elapses or immediately on a successful login. Locking an account out entirely after repeated failures is a more aggressive variant, but has its own downside -- it can itself become a denial-of-service vector, where an attacker deliberately fails logins against a victim's known email just to lock them out of their own account; a short, sliding time-based delay is usually safer than an indefinite hard lockout.

• Track failed attempts per IP AND per account (email) -- either alone can be bypassed (many IPs, or many accounts from one IP). • Reject further attempts once a threshold is exceeded within a time window; reset the counter on success or after the window elapses. • A hard, indefinite account lockout can itself be weaponized as a denial-of-service against a specific victim -- prefer a time-limited backoff. • Combine rate limiting with generic error messages -- a rate limit alone doesn't fix enumeration, and enumeration-resistant errors alone don't stop brute force.

session_regenerate_id() and Secure Logout

session_regenerate_id(true) -- the boolean argument matters; without it the old session data lingers server-side. The cookie-expiry block must run BEFORE session_destroy() while session_name() and the cookie params are still meaningful. setcookie() with a past timestamp tells the browser to delete the cookie immediately.

// On successful login — regenerate BEFORE writing session data
session_regenerate_id(true); // true = also delete the old session's server-side data
$_SESSION['user_id'] = $userId;

// On logout — clear array, destroy storage, THEN expire the cookie
$_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();

Code Examples

Rate limiting is checked first (per email+IP combination). password_verify() always runs -- against a real hash or a dummy one -- so timing doesn't distinguish 'no account' from 'wrong password', and both failure paths return the exact same generic error and 401 status. Only on success does session_regenerate_id(true) run, before any session data is written. The dummy hash must be a real, validly-formatted Argon2id/bcrypt hash string (of any password) so password_verify() performs the same expensive computation it would for a real account -- an empty string or malformed hash would fail fast and reintroduce the timing leak.

$hashToCheck = $row['password_hash'] ?? '$argon2id$v=19...dummy...';

Common mistake: Returning 'No account with that email' when $row is null and 'Incorrect password' when it isn't -- two different, informative messages that let an attacker enumerate registered emails one login attempt at a time.

The three steps run in order: clear the in-memory session array, expire the cookie on the client (while cookie parameters are still available), then destroy the server-side session storage -- leaving neither client nor server holding anything usable from the old session. Expected result: Set-Cookie header with an expired timestamp, plus an empty server-side session going forward

<?php
declare(strict_types=1);

const MAX_ATTEMPTS = 5;
const WINDOW_SECONDS = 900; // 15 minutes
const GENERIC_ERROR = 'Invalid email or password';

function attemptLogin(string $email, string $password, string $ip): array
{
    if (isRateLimited($email, $ip)) {
        http_response_code(429);
        return ['error' => 'Too many attempts. Please try again later.'];
    }

    $row = Database::queryOne('SELECT id, password_hash FROM users WHERE email = ?', [$email]);

    // Dummy hash used when the account doesn't exist, so password_verify() still runs
    // and takes a comparable amount of time — closes the timing side-channel.
    $hashToCheck = $row['password_hash'] ?? '$argon2id$v=19$m=65536,t=4,p=1$c29tZXNhbHQAAAAAAAAAAA$dummydummydummydummydummydummy';

    $passwordOk = password_verify($password, $hashToCheck);

    if ($row === null || !$passwordOk) {
        recordFailedAttempt($email, $ip);
        http_response_code(401);
        return ['error' => GENERIC_ERROR]; // identical for "no such user" and "wrong password"
    }

    clearFailedAttempts($email, $ip);

    // Regenerate BEFORE writing any authenticated session data
    session_regenerate_id(true);
    $_SESS
…
<?php
declare(strict_types=1);

function logout(): void
{
    $_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();

    http_response_code(200);
    echo json_encode(['status' => 'logged out']);
}

Login: the privilege boundary where the session ID must change

Regenerating at the exact moment of privilege escalation makes any pre-login session ID worthless afterward.

Before login:                          After successful login:
session id: abc123 (anonymous)         session_regenerate_id(true)
                                                  |
    ...attacker could have                       v
    seen/set this ID...             session id: xyz789 (NEW, unguessable)
                                     $_SESSION['user_id'] = 42   <- written AFTER regen
                                     abc123 is now deleted server-side, useless forever

Important Rules

Always call session_regenerate_id(true) immediately on successful login, before writing any authenticated data into $_SESSION.

Logout must clear $_SESSION, expire the client cookie, AND call session_destroy() -- all three, not just one.

Return an identical generic error message and status code whether the account doesn't exist or the password was wrong.

Always run password_verify() (against a dummy hash if needed) even when the account doesn't exist, to avoid a timing-based enumeration channel.

Rate-limit login attempts per IP and per account, with a time-limited backoff rather than an indefinite hard lockout.

Why login forms feel deliberately vague

Banks, healthcare portals, and most mature SaaS products all show the same generic 'incorrect email or password' message regardless of which part was wrong, specifically as an enumeration defense -- and many pair it with a visible or invisible CAPTCHA plus IP/account rate limiting after a handful of failed attempts. The vagueness that sometimes frustrates a user who genuinely forgot their email is a deliberate, well-established tradeoff against exposing which addresses are registered.

Writing $_SESSION['user_id'] = $id immediately after verifying the password, without calling session_regenerate_id() first.

Call session_regenerate_id(true) first, THEN write $_SESSION data -- verify, regenerate, write, in that exact order.

Skipping regeneration leaves the pre-login session ID valid post-login, which is exactly what a session fixation attack exploits -- an attacker who got the victim to use a known session ID before login now has an authenticated session after the victim logs in.

Login returns 'No account found' for a missing email and 'Wrong password' for a bad password.

Return the identical 'Invalid email or password' message and status code for both cases, and always run password_verify() (even against a dummy hash) so timing doesn't differ either.

Distinct messages (or distinct response timing) let an attacker enumerate which emails have registered accounts, turning a generic credential list into a targeted attack surface and potentially leaking sensitive membership information on its own.

Why must session_regenerate_id(true) be called on successful login rather than relying on the pre-login session ID? A. It makes the login process faster B. It prevents session fixation by replacing any session ID that existed (and could have been known to an attacker) before login with a fresh, unguessable one C. It is required for password_verify() to work correctly D. It automatically hashes the user's password

B. It prevents session fixation by replacing any session ID that existed (and could have been known to an attacker) before login with a fresh, unguessable one — If the session ID isn't rotated at login, a session ID an attacker set or captured before authentication remains valid and becomes an authenticated session after the victim logs in -- exactly what session fixation exploits.

Why should a login endpoint run password_verify() even when the submitted email has no matching account? A. To generate a new password for the user automatically B. To keep response timing consistent between 'no such account' and 'wrong password', preventing an attacker from distinguishing the two via response latency C. password_verify() is required to check whether the email format is valid D. It isn't necessary; skipping it is more efficient and has no downside

B. To keep response timing consistent between 'no such account' and 'wrong password', preventing an attacker from distinguishing the two via response latency — If a nonexistent account short-circuits without calling password_verify(), it responds measurably faster than an existing account (where the deliberately slow verify actually runs) -- letting an attacker enumerate valid emails purely from response timing, even with identical error text.

Extend the attemptLogin() example above so that after MAX_ATTEMPTS failures for a given email+IP, the response includes a Retry-After header (in seconds) telling the client exactly how long until the rate limit window resets, instead of just a generic 429.

function isRateLimited(string $email, string $ip): ?int { $key = 'login_attempts:' . $email . ':' . $ip; $data = Cache::get($key); if ($data === null || $data['count'] < MAX_ATTEMPTS) return null; $elapsed = time() - $data['first_attempt_at']; return max(0, WINDOW_SECONDS - $elapsed); } Then in attemptLogin(): $retryAfter = isRateLimited($email, $ip); if ($retryAfter !== null) { header('Retry-After: ' . $retryAfter); http_response_code(429); return ['error' => 'Too many attempts']; }

Cache::get($key) already stores when the window started (or store a 'first_attempt_at' timestamp alongside 'count'); Retry-After = WINDOW_SECONDS - (time() - first_attempt_at).