The contact form on this site worked perfectly on my machine and failed on the server. Not loudly — it filled in politely and came back with “Your session expired. Please try again.” Trying again did the same thing. Nothing appeared in the error log, because nothing had errored.
What the code was doing
The form has a CSRF token. The page that renders the form puts a random value in the session and writes the same value into a hidden field. The endpoint that receives the POST compares them:
function csrf_valid(?string $token): bool
{
session_start_once();
return is_string($token)
&& !empty($_SESSION['_csrf'])
&& hash_equals($_SESSION['_csrf'], $token);
}
On the server, $_SESSION['_csrf'] was empty every single time. The browser was
sending the session cookie — I could see it on the request. The token in the form was correct. The
session simply had nothing in it.
The part I had assumed
I had assumed that PHP configuration is a property of the server. It is not necessarily a property
of the server. Under CGI and FastCGI — which is what most cPanel hosting runs — PHP looks for a
php.ini in the directory of the script that is executing, and falls back to the
system one if it does not find it.
This site has two entry points that touch the session, and they live in different directories:
/index.php— renders the form, in the document root/includes/contact-submit.php— receives the POST, one level down
There was a php.ini in the document root. It applied to index.php. It
did not apply to contact-submit.php, which got the system defaults instead. The two
files disagreed about session.save_path.
Why it is so quiet
This is the detail that cost me the afternoon. When the endpoint called
session_start(), everything succeeded. The cookie was read. A session ID was
resolved. PHP went looking for that session's file in its save path, did not find one,
and did exactly what it is supposed to do in that situation: it started a new, empty session under
the same ID and returned success.
So there is no error to log. There is no warning. There is one session cookie, one session ID, and two directories of session files that never learn about each other. From the outside it is indistinguishable from a visitor whose session had genuinely expired — which is precisely what my error message told them, with total confidence, every time.
The fix
Stop relying on configuration I do not control, and pin the path in code before any session is started:
function session_storage_pin(): void
{
static $done = false;
if ($done) {
return;
}
$done = true;
$dir = storage_path('sessions');
if (!is_dir($dir)) {
@mkdir($dir, 0700, true);
}
// If the directory cannot be created or written, leave the host default
// alone: a shared session beats no session at all.
if (!is_dir($dir) || !is_writable($dir)) {
return;
}
ini_set('session.save_path', $dir);
}
Every entry point now calls session_start_once(), which pins the path first. Calling
session_start() directly is banned in this codebase, and the reason is written in a
comment above the function so that future me does not helpfully tidy it away.
One consequence came with it. The host sweeps its own temporary directory on a cron job, and that cron does not know about my folder, so my session files would have accumulated forever. Pinning the path means owning the cleanup:
ini_set('session.gc_probability', '1');
ini_set('session.gc_divisor', '100');
What I took from it
The bug was not in the session code. It was in an assumption underneath the session code — that two files in the same application share an environment — which was true everywhere I had tested and false where it mattered. Those are the expensive ones, because you debug the thing that is visibly wrong for hours before you think to question the floor it is standing on.
The other lesson is about the error message. “Your session expired” was a guess dressed up as a diagnosis. It blamed the visitor for a server misconfiguration, and it was so plausible that it slowed me down. An error message that says what was actually checked — “no token found in session” — would have pointed at the session store on the first read.
Worth saying: none of this could have lost an enquiry, because the form stores to the database and a log file before it does anything else. That is a rule I hold to for exactly this kind of afternoon.