PHP Authentication and Sessions: A Security Reference

Published Updated

Authentication is the boundary where a request stops being anonymous and starts belonging to a specific account. Get that boundary wrong and every other control in the application is already negotiating from a weak position. A login flow decides who a request is, a session decides how long the server keeps believing it, and a cookie carries the one identifier that ties the two together. This reference covers the security shape of that flow in PHP, written for someone reviewing a small app rather than copying a 2003-era login script.

Reach for a framework or a maintained identity provider once the application needs account recovery, MFA, audit trails, SSO, device history, or real abuse controls. Those are large systems with their own failure modes, and rolling them by hand is how teams ship credential leaks. If you're building a small PHP app to understand the moving parts, the rules below are the floor you don't drop under.

Store Password Hashes, Not Passwords

A stored password is a future breach disclosure waiting for a database dump. Treat the user table as something an attacker will eventually read, then make sure what they read is useless to them. Use password_hash() when the account is created and password_verify() when a login attempt arrives. Never store plaintext, never invent your own salt, and never reach for md5() or sha1() or some clever chain of functions that looked defensible two decades ago.

<?php

$hash = password_hash($plainPassword, PASSWORD_DEFAULT);

$statement = $pdo->prepare(
    "INSERT INTO users (email, password_hash)
     VALUES (:email, :password_hash)",
);

$statement->execute([
    "email" => $email,
    "password_hash" => $hash,
]);

PHP packs the algorithm and cost into the generated hash, so password_verify() can check a later login without a separate salt column to manage. Give the column room for formats that don't exist yet, since VARCHAR(255) is the ordinary choice and costs you nothing. On a login attempt, fetch the account by a stable identifier and then verify the submitted password against the stored hash.

<?php

$statement = $pdo->prepare(
    "SELECT id, email, password_hash
     FROM users
     WHERE email = :email",
);

$statement->execute(["email" => $email]);
$user = $statement->fetch(PDO::FETCH_ASSOC);

if (! $user || ! password_verify($plainPassword, $user["password_hash"])) {
    throw new RuntimeException("Invalid email or password");
}

Return the same error for a missing account and a wrong password every single time. The server can log the difference for its own triage, but a browser that learns which email addresses exist has just been handed a free enumeration tool.

Session Configuration

A PHP session is trivial to start and easy to underthink, which is exactly why session handling shows up in so many findings. The cookie carries the session ID and the server holds the session data, so the cookie settings are part of the authentication system rather than cosmetic detail. Set the cookie parameters before session_start(), because anything set afterward is fighting a cookie the browser already holds.

<?php

ini_set("session.use_strict_mode", "1");

session_set_cookie_params([
    "lifetime" => 0,
    "path" => "/",
    "secure" => true,
    "httponly" => true,
    "samesite" => "Lax",
]);

session_start();

Setting lifetime to 0 makes the browser treat this as a session cookie that disappears when the window closes. The Secure flag keeps the cookie off plain HTTP, and HttpOnly keeps ordinary JavaScript from reading it, which shrinks the blast radius of an XSS bug. SameSite=Lax buys useful cross-site protection without replacing your CSRF tokens. The PHP manual's session-security guidance also calls out session.use_strict_mode, so enable it and let PHP reject any session ID it didn't issue itself.

Session ID Rotation

A session that survives unchanged across the login event is a session-fixation finding. The fix is mechanical: when the password check passes, rotate the session ID before you write any authenticated state into it. That keeps a pre-login identifier, possibly one an attacker planted, from quietly becoming the authenticated session.

<?php

if (! session_regenerate_id()) {
    throw new RuntimeException("Could not rotate session");
}

$_SESSION["user_id"] = (int) $user["id"];
$_SESSION["authenticated_at"] = time();

Order is the whole point here, because the pre-login identifier has to stay untrusted until you've replaced it. Rotate first, then write the user ID and the timestamp into the fresh session. Write user_id before rotation and you've made the old, attacker-known identifier more valuable than it ever needed to be.

That two-line sample teaches the boundary, and it is not a production session policy. Larger applications have to think hard about concurrent requests, the window where the old ID still works, and reuse that looks suspicious. The session_regenerate_id() documentation is careful about unstable networks and immediate deletion of the old session for good reasons, so read it before you promote a teaching snippet into traffic that carries real accounts.

Keep the Login Guard Small

An authenticated page should not know how login works. It asks one small guard whether a user ID is present in the session, then gets on with its job.

<?php

function requireUserId(): int
{
    if (! isset($_SESSION["user_id"])) {
        header("Location: /login", true, 302);
        exit;
    }

    return (int) $_SESSION["user_id"];
}

That guard answers exactly one question, which is whether someone is logged in. It is not the authorization system, and treating it as one is how a logged-in user reaches a page they were never cleared for. A user ID in the session does not prove the holder can edit this invoice, delete this post, or open the admin panel. Authentication identifies the account, and a separate permission check decides what that account is allowed to touch.

<?php

$userId = requireUserId();

$statement = $pdo->prepare(
    "SELECT id, email
     FROM users
     WHERE id = :id",
);

$statement->execute(["id" => $userId]);

Keep every query for the current user parameterized, the way the guard hands you an integer ID and the prepared statement binds it. The boundary identifies who is asking, and your SQL constraints decide what they get back.

Presence Tracking

A "who's online" list is an old sessions-era feature, and it lies if you treat it as authentication state. A user can be logged in and idle for an hour. A browser can close without telling the server a thing. A mobile connection can vanish between two requests and reappear on a new one. The reliable shape is a server-owned timestamp that you update after the normal guard has already identified the account.

<?php

function touchPresence(PDO $pdo, int $userId): void
{
    $update = $pdo->prepare(
        "UPDATE user_presence
         SET last_seen_at = CURRENT_TIMESTAMP
         WHERE user_id = :user_id",
    );

    $update->execute(["user_id" => $userId]);

    if ($update->rowCount() > 0) {
        return;
    }

    $insert = $pdo->prepare(
        "INSERT INTO user_presence (user_id, last_seen_at)
         VALUES (:user_id, CURRENT_TIMESTAMP)",
    );

    $insert->execute(["user_id" => $userId]);
}

List the active accounts by a short trailing window and let the timestamp do the work.

<?php

$cutoff = (new DateTimeImmutable("-5 minutes"))->format("Y-m-d H:i:s");

$statement = $pdo->prepare(
    "SELECT u.id, u.email, p.last_seen_at
     FROM user_presence p
     JOIN users u ON u.id = p.user_id
     WHERE p.last_seen_at >= :cutoff
     ORDER BY p.last_seen_at DESC",
);

$statement->execute(["cutoff" => $cutoff]);
$onlineUsers = $statement->fetchAll(PDO::FETCH_ASSOC);

A single database table covers a small app comfortably. A busier chat or community app wants a shared store with expirations, refreshed through a heartbeat request. Either way, keep the claim honest and label it as presence: this account was active recently, not a guarantee that someone is staring at the page right now.

Logout

Logout is only real when it clears the server-side session data and tells the browser to drop the session cookie. Doing one without the other leaves a live session the user thinks they ended.

<?php

$_SESSION = [];

if (ini_get("session.use_cookies")) {
    $params = session_get_cookie_params();

    setcookie(
        session_name(),
        "",
        [
            "expires" => time() - 3600,
            "path" => $params["path"],
            "domain" => $params["domain"],
            "secure" => $params["secure"],
            "httponly" => $params["httponly"],
            "samesite" => $params["samesite"] ?? "Lax",
        ],
    );
}

session_destroy();

Don't lean on logout as your only way to end a session. A password change, an account recovery, or a flagged login event may all need to invalidate active sessions that the user never logged out of, and you want that path built before you need it.

CSRF Survives a Correct Login

A session cookie rides along on every request the browser makes, which is precisely why CSRF works. An attacker doesn't need the cookie value at all, because the victim's own browser will attach it to a forged request to your endpoint. For every state-changing form, mint a CSRF token tied to the session and check it on submission.

<?php

function csrfToken(): string
{
    if (! isset($_SESSION["csrf_token"])) {
        $_SESSION["csrf_token"] = bin2hex(random_bytes(32));
    }

    return $_SESSION["csrf_token"];
}

function requireCsrfToken(string $token): void
{
    if (! hash_equals($_SESSION["csrf_token"] ?? "", $token)) {
        throw new RuntimeException("Invalid form token");
    }
}

Carry the token in the form that performs the action, escaped on the way out.

<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrfToken(), ENT_QUOTES) ?>">

A valid token proves the request carried a value your server planted in the session, and it says nothing about whether the submitted data is any good. You still validate every field and you still write through prepared statements. The token closes the forgery gap, and the rest of your input handling stays exactly as important as it was.

CAPTCHA Is Anti-automation, Not Identity

Login forms attract automation, so CAPTCHA belongs in this conversation, with a narrow verdict attached. CAPTCHA is an anti-automation control, and it proves nothing about who the human in front of it is. Reach for it only when a flow has an abuse problem it can actually reduce, such as account-creation bursts, credential stuffing, password-reset floods, or contact-form spam. Even there it sits beside rate limits, per-account and per-IP throttles, lockouts, logging, and a clean recovery path.

Putting CAPTCHA in front of every login by default punishes legitimate users, adds accessibility friction, and still gets bypassed or outsourced to a solving service for pennies. A login flow with safe password storage, measured throttling, session rotation, and reviewable logs is a stronger first line than a challenge that mostly annoys the people you want to keep.

Remember-me Is a Separate System

Stretching the session lifetime to fake a "remember me" feature turns the session cookie into a long-lived login token, and that cookie is the key to the active session. The PHP session-security guidance is blunt about long-life session IDs for that reason. When the product genuinely needs persistent login, build a dedicated token system instead of overloading the session.

  • Store a selector alongside a hashed validator in the database.
  • Send the raw validator only inside the cookie.
  • Rotate the token every time it gets used.
  • Expire tokens on a deliberate schedule.
  • Let the user revoke a remembered device.

That is more work than bumping a lifetime value, and it keeps the session and the persistent-login path from collapsing into one shared failure when either is breached.

What This Reference Covers

Strip the legacy login folklore away and the current security floor for a small PHP app reduces to a short list. Authentication is password hashing plus server-side session state behind a tightly scoped cookie. Online-user lists are presence timestamps and never proof that a session is valid forever. Cookies transport a small identifier, and they are no place for password hashes or permission flags. CAPTCHA is friction aimed at abuse rather than a stand-in for authentication or rate limiting. CSRF protection belongs on every state-changing request even after the login system is otherwise correct.

If the app is taking real users, this boundary is where a security review starts, and the rest of the application security work builds on top of it.

Next

Head back to the Application Security hub to pick up pull-request review and the static checks that sit beside this login boundary.

Frequently Asked Questions

How long should a login session last?

Match the risk of the account. Ordinary accounts often use a rolling window of a few hours to a day; administrative access should be shorter. Set both an idle timeout and an absolute maximum, so a forgotten open tab cannot stay valid indefinitely.

Should you store sessions in a database instead of files?

Files are fine for a single server. Move to a database or a shared store such as Redis when several servers must read the same session, or when you need to list and revoke active sessions for a user.

What happens when a user logs in from two devices?

By default both sessions work independently, because each has its own session ID. If a single active session per account is required, the application must record which ID is current and invalidate the others at login.

Is it safe to store the user ID in the session?

Yes. Session data lives on the server, and the browser only holds the identifier. Never store the password, and treat anything in the session as trusted only because you put it there after authenticating the user.

Do you need to call session_start() on every page?

On every request that reads or writes session data, yes, before any output is sent. Pages that need no session can skip it, which avoids the lock contention that a busy file-based session store can cause.

Sources

  1. [1]
  2. [2]
  3. [3]
  4. [4]
  5. [5]
    OWASP Authentication Cheat Sheet
    (cheatsheetseries.owasp.org)
  6. [6]
    OWASP Session Management Cheat Sheet
    (cheatsheetseries.owasp.org)
  7. [7]
  8. [8]
  9. [9]
    PDO::prepare
    (php.net)