DEV Community

Bryan Hamilton
Bryan Hamilton

Posted on

A Login Form Beat My Twenty Years of PHP

A Login Form Beat My Twenty Years of PHP

I have written PHP for over twenty years. I survived the register_globals era, magic quotes, and every framework that promised to save me from myself. And this week, a login form - the software equivalent of a screen door - beat me for the better part of a day.

This is the coroner's report. There were three monsters, and none of them were where I looked first.

The setup

I run a live DJ event calendar. Plain PHP, vanilla JavaScript, zero dependencies - if it needs a build step, I don't want it. I decided to do the auth stack properly: one signup page, one signin page, CSRF protection, real sessions. Classic work. An afternoon, tops.

It worked flawlessly. On my machine. With my warm, already-authenticated cookies.

Then I opened a private window.

Monster one: the doppelganger

Every page on my site boots with a small compatibility shim. Or so I believed - it turned out there were TWO files with the same name in two different directories. The one at the root: harmless, does what it says.

The one buried in the includes directory contained a "fix" - and I want you to appreciate the irony - for a real edge case: when a PHP session is active but the client sent no cookie, things get weird. Except this fix handled the weirdness by ABORTING THE SESSION and stripping the Set-Cookie header. Silently.

Read that again: the exact moment a brand-new session needed to be born, this file executed it. My auth code was fine. It was being assassinated by a file with the same name as its bodyguard.

Lesson: grep for duplicate filenames before you grep for bugs. Two files with the same name in different directories is not a refactor waiting to happen - it is a refactor that already happened, badly, to someone who did not notice. That someone was me.

Monster two: the cookie eater

Like most small sites, I sit behind a CDN that caches pages at the edge. Here is a thing I knew in the abstract and got to learn viscerally: for cacheable GET responses, the edge strips the Set-Cookie header.

This is CORRECT behavior. You absolutely do not want the edge serving visitor A's session cookie to visitor B - that is not a bug, that is an incident report. The CDN is protecting my users from my misconfiguration.

But follow the consequences: my login page is a GET. The edge cached it and ate every Set-Cookie it ever tried to send. POST requests are never cached, so the only thing that could ever hand a browser a session cookie was the form submission itself.

So the session worked. The browser just never found out about it until it was too late.

Lesson: any page that participates in authentication should not be edge-cacheable, or should bypass the cache when a session cookie is present. "Cacheable" is not a page property - it is a per-request decision, and auth pages make that decision for you.

Monster three: the second-try bug

Now combine the two monsters.

First visit ever: GET the signup page, edge eats the cookie, submit the form, server-side state says there is no session, "Session expired."

Retry: the first POST already set a cookie, so now everything works.

Works-on-retry is the worst possible bug class in existence. It is invisible in every test suite. It survives every staging environment. It makes your users feel like THEY did something wrong, because the second try works and now they think the internet is haunted and so do you.

And here is the part that humbled me: I knew WHY it happened. Every developer on my side of the glass tests with warm cookies. Every user arrives cold. The bug only existed for people who had never been there before - which is to say, every person I was trying to convert into a regular.

Lesson: test auth flows in a fresh private window, every single time, from cold. Warm cookies are a lie you tell yourself.

The fix: stop keeping state at all

I could have played whack-a-mole with cache rules and shim files forever. Instead I asked a better question: why does my CSRF protection need server-side state at all?

The answer, it turns out, is that it doesn't. Enter the stateless HMAC token - the CSRF token that keeps no secrets the cache can eat:

// Issue: payload + signature, no server storage
$payload = base64_encode(json_encode([
    'exp' => time() + 7200,
    'n'   => bin2hex(random_bytes(16)),
]));
$token = $payload . '.' . hash_hmac('sha256', $payload, CSRF_KEY);

// Verify: recompute, compare, check expiry
[$payload, $sig] = explode('.', $token, 2);
$valid = hash_equals(hash_hmac('sha256', $payload, CSRF_KEY), $sig)
      && json_decode(base64_decode($payload), true)['exp'] > time();
Enter fullscreen mode Exit fullscreen mode

The payload is just an expiry and a random nonce. The signature proves I issued it. The server holds NOTHING - there is no session row to lose, no state to expire, nothing for the CDN to eat. The token rides inside the form, the form rides inside the POST, and POSTs are never cached.

The whole class of bugs I had been fighting - session swamps, cookie theft, first-visit death spirals - evaporated, because there was no longer anything TO lose.

Monster four: debugging against yesterday

I would be lying if I said the monsters fought fair. There was a fourth: my own server page-caches HTML for five minutes. Which means every time I shipped a fix and checked the live page, I was frequently debugging a page that no longer existed - inspecting the corpse of yesterday's code and wondering why my fix "didn't work."

I verified the same fix three separate times because the page kept lying to me.

Lesson: cache-bust your own debugging. If the page can be cached, your verification URL should look like nothing the cache has ever seen.

The honest trade-offs

Because nothing is free: a stateless token cannot be revoked individually. There is no session row to delete when someone's token needs to die - your options are waiting for the TTL or rotating the key and nuking every token at once. Keep the expiry short. And the token is bearer-style until it expires, so treat the key like a password and the token like cash.

That is the deal, and for a form that exists to stop cross-site forgeries, it is a deal I will take every time.

The scoreboard

Twenty years of PHP: lost the first round to a screen door.

Final score: three monsters, two doppelganger files, one cookie-eating CDN, one caching server, and a login form that now works on the very first try - for the people who have never been there before. You know. The ones who matter.

If you have a login horror story, I want it in the comments. Misery loves company, and company loves misery.


Read next: The Search Engine With Zero Server Calls - how I took every search bar off my server. Or if you want something that hums: The Equalizer That Actually Listens, a Web Audio analyser deep-dive.

Top comments (0)