04 · Sessions & Forms¶
Web apps are stateless by default — every HTTP request arrives with no memory of the last one. Sessions give a visitor a small pocket of server-side storage that persists across requests (so "logged in" can mean something), and forms are how a visitor sends data back to your script in the first place. Both come with well-known security traps that are worth understanding from day one.
Reading form input: $_GET vs $_POST¶
<?php
// search.php -- reached via a GET form: <form method="get" action="search.php">
if ($_SERVER["REQUEST_METHOD"] === "GET" && isset($_GET["q"])) {
$query = trim($_GET["q"]);
echo "You searched for: " . htmlspecialchars($query);
}
<!-- login.php -- reached via a POST form -->
<form method="post" action="login.php">
<input type="text" name="username">
<input type="password" name="password">
<button type="submit">Log in</button>
</form>
<?php
// login.php
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$username = trim($_POST["username"] ?? "");
$password = $_POST["password"] ?? "";
// ... validate and log in below
}
$_GET values come from the URL's query string (visible in browser history,
bookmarkable, size-limited) — use it for searches and filters, never for
passwords or anything sensitive. $_POST values come from the request body
and are the right choice for anything that changes state (logging in,
submitting a purchase, deleting a record) or contains sensitive data.
Always escape output: preventing XSS¶
If a form value is ever echoed back into HTML without escaping, a visitor
who submits <script>...</script> as their input can run arbitrary
JavaScript in every other visitor's browser who sees that output — a
cross-site scripting (XSS) attack. htmlspecialchars() converts HTML's
special characters into harmless text.
<?php
$comment = '<script>alert("hacked")</script>';
echo $comment; // DANGEROUS: browser executes the script
echo htmlspecialchars($comment); // SAFE: prints the literal text
// <script>alert("hacked")</script>
The rule of thumb: escape on output, every time you print anything that originated from user input, not just once "when it comes in."
Validating form input¶
<?php
function validateSignup(array $input): array
{
$errors = [];
$email = trim($input["email"] ?? "");
if ($email === "" || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors["email"] = "Enter a valid email address";
}
$age = $input["age"] ?? "";
if (!ctype_digit((string) $age) || (int) $age < 13) {
$errors["age"] = "You must be at least 13 years old";
}
$password = $input["password"] ?? "";
if (strlen($password) < 8) {
$errors["password"] = "Password must be at least 8 characters";
}
return $errors;
}
$submission = ["email" => "not-an-email", "age" => "9", "password" => "short"];
$errors = validateSignup($submission);
foreach ($errors as $field => $message) {
echo "$field: $message\n";
}
// email: Enter a valid email address
// age: You must be at least 13 years old
// password: Password must be at least 8 characters
Validate every field server-side even if you also validate in JavaScript — client-side checks are a convenience for honest users, not a security boundary; anyone can submit a request directly and skip your JavaScript entirely.
Starting a session and storing data¶
<?php
// Must run before ANY output (even a stray blank line) -- it sends a
// Set-Cookie header, and headers can't be sent after the response body starts.
session_start();
$_SESSION["cart"] ??= []; // initialize once
$_SESSION["cart"][] = "Widget"; // persists across requests for this visitor
echo "Items in cart: " . count($_SESSION["cart"]) . "\n";
A session works by giving the browser a cookie (PHPSESSID by default)
containing a random ID; PHP uses that ID to load a matching file of
serialized data on the server for every subsequent request from that
browser. The visitor never sees the actual stored data, only the ID.
Logging in safely: session fixation¶
Session fixation is an attack where someone tricks a victim into using a session ID the attacker already knows (e.g. via a crafted link), then waits for the victim to log in under that same ID — hijacking the now-authenticated session. The fix is simple: always issue a brand-new session ID the moment privilege changes (typically, right after a successful login).
<?php
session_start();
function attemptLogin(string $username, string $password): bool
{
$validUsername = "ada";
$validPasswordHash = password_hash("s3cret!", PASSWORD_DEFAULT);
if ($username === $validUsername && password_verify($password, $validPasswordHash)) {
// Issue a fresh session ID -- invalidates any ID an attacker
// might have fixed beforehand, and deletes the old session file.
session_regenerate_id(delete_old_session: true);
$_SESSION["user"] = $username;
return true;
}
return false;
}
if (attemptLogin("ada", "s3cret!")) {
echo "Logged in as: " . $_SESSION["user"] . "\n";
// Logged in as: ada
}
Never store or compare plaintext passwords — password_hash() (one-way,
salted) and password_verify() are the standard library functions for this;
never roll your own hashing scheme.
CSRF: making sure the request really came from your form¶
Cross-site request forgery (CSRF) tricks a logged-in visitor's browser into submitting a request to your site from a different site — the browser automatically attaches the victim's session cookie, so the request looks legitimate. The defense is a random token embedded in your own form that an attacker's page can't guess or read.
<?php
session_start();
// When rendering the form:
$_SESSION["csrf_token"] ??= bin2hex(random_bytes(32));
$token = $_SESSION["csrf_token"];
echo '<input type="hidden" name="csrf_token" value="' . htmlspecialchars($token) . '">';
// When handling the POST:
$submitted = $_POST["csrf_token"] ?? "";
if (!hash_equals($_SESSION["csrf_token"] ?? "", $submitted)) {
http_response_code(403);
exit("Invalid or missing CSRF token.");
}
hash_equals() compares strings in constant time, avoiding a timing attack
that could otherwise let an attacker guess the token one character at a
time by measuring how long comparisons take.
Sessions & forms cheat sheet¶
| Tool | Purpose |
|---|---|
$_GET, $_POST |
Read query-string vs. request-body input |
htmlspecialchars($value) |
Escape output to prevent XSS |
filter_var($v, FILTER_VALIDATE_EMAIL) |
Validate common formats |
session_start() |
Begin/resume a session (call before any output) |
$_SESSION |
Server-side, per-visitor storage across requests |
session_regenerate_id() |
Issue a fresh session ID (call on login) |
password_hash() / password_verify() |
Safely store and check passwords |
hash_equals() |
Timing-safe string comparison, e.g. for CSRF tokens |
How It Actually Works¶
$_GET and $_POST are superglobal arrays that PHP populates once, at the very start of the request, by parsing the raw HTTP request line's query string and (for POST) the request body according to its Content-Type header — this parsing happens in the SAPI layer before your script's first opcode even runs, which is why these arrays are already filled by the time execution reaches line one. session_start() is where PHP's shared-nothing lifecycle gets its one deliberate escape hatch: it reads a session ID from a cookie (or generates a new one), then loads a serialized blob from a session store (by default, a file in /tmp named sess_<id>) into the $_SESSION superglobal — this is disk I/O happening synchronously on every request that touches sessions, restoring state that would otherwise be lost the instant the previous request's process exited. Escaping output with htmlspecialchars() prevents XSS because it operates on the string level, converting characters like < and & into HTML entities before they're ever written to the response body, so the browser's HTML parser can never interpret injected text as markup — CSRF tokens work by a similar wire-level trick: a per-session secret value embedded in the form is compared against $_SESSION on submit, and because that value never reaches a cross-origin attacker's page (same-origin policy blocks reading the token via JavaScript or a forged form), a forged request from another site can't reproduce it.
Exercise¶
Build a two-file login demo: login_form.php renders a form with username,
password, and a hidden CSRF token stored in $_SESSION; login.php handles
the POST, rejects the request if the CSRF token doesn't match
(hash_equals), validates the username/password against a hard-coded
password_hash()ed value, and on success calls session_regenerate_id()
before storing $_SESSION["user"]. Add a logout.php that calls
session_destroy(). Test the flow with PHP's built-in server:
php -S localhost:8000.