Skip to main content

My AI Agent Caught My Sign-Up Page Being Served From Cache

An AI agent found my self-hosted sign-up form frozen in the page cache, with a stale nonce and timestamp. Here's the one-file fix and the freshness test that actually works.

One of my side projects is a self-hosted WordPress site with its own sign-up form, sitting behind an nginx page cache that a managed panel sets up for me. While planning a sign-in gate for the site’s tools, I had an AI agent check every account page from the outside. It found something I’d never have spotted by clicking around: the sign-up page was being served from the cache.

The page loaded fine and looked fine. It just wasn’t fresh.

How the agent spotted it

The agent made two signed-out requests to the sign-up page, a couple of seconds apart, and compared what came back. Both came back as cache hits, and both had the same hidden form fields: the same security nonce, and the same form timestamp. That timestamp is meant to be the moment the form was rendered. Decoded, it was about three hours old.

So every visitor for hours had been getting one frozen copy of the form. That breaks two things:

  • The timing check is useless. The form rejects anything submitted less than 3 seconds after it was rendered, which catches lazy bots. With a timestamp baked in hours earlier, every bot looks “slow enough”.
  • Real sign-ups start failing. WordPress nonces are only valid for 12 to 24 hours. Once the cached copy is older than that, a genuine person fills in the form and gets rejected, and nothing in the logs says why.

The agent checked the neighbours too. The login page was already skipped by the panel’s default cache rules, and the email-verify link was fine because URLs with a query string skip the cache on this setup. A few password-reset addresses I half-expected turned out not to exist at all. So the hole was really just the sign-up page.

Advertisement

Fix it in code, not in a settings screen

There were two ways to fix it: add the page to the panel’s cache exclude list, or have WordPress mark the response as uncacheable at the source. The agent recommended the second, for three reasons:

  • It was already proven on this server. A couple of tool pages on the same site already send no-store headers, and they come back as a miss on every request, never a hit.
  • It travels with the code. A panel reset, reinstall or settings re-save can’t silently undo it, and it’s one file you can see and roll back.
  • A settings change from the command line might not reach nginx. The panel writes its nginx rules when you save its screen. Editing its stored option from the CLI could leave the screen and the real config disagreeing.

So the exclude list became an optional second layer, and the main fix is a small must-use plugin: one file, no theme edits, nothing deleted.

<?php
// mu-plugins/nocache-auth.php (simplified)
function nca_is_auth_request(): bool {
    if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) {
        return false;
    }
    if ( isset( $_GET['verify'] ) ) {
        return true; // email-verify landing
    }
    $path = '/' . trim( (string) parse_url( $_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH ), '/' ) . '/';
    return in_array( $path, array( '/register/', '/login/', '/complete-profile/' ), true );
}

function nca_send(): void {
    if ( ! nca_is_auth_request() || headers_sent() ) {
        return;
    }
    if ( ! defined( 'DONOTCACHEPAGE' ) ) {
        define( 'DONOTCACHEPAGE', true ); // respected by most WP cache plugins
    }
    nocache_headers();
    header( 'Cache-Control: no-cache, must-revalidate, max-age=0, no-store, private', true );
    header( 'X-Accel-Expires: 0', true );  // nginx fastcgi_cache: never store this
    header( 'X-NoCache-Marker: auth', true ); // easy to grep for in curl checks
}
add_action( 'init', 'nca_send', 0 );              // before any verify handler can redirect
add_action( 'template_redirect', 'nca_send', 0 ); // and again before template output

It covers the login page and the verify landing as well, even though they were already fine. That way a future change to either one can’t quietly start caching them.

The nonce gotcha that would have fooled me

My first instinct for “is it fresh now?” would have been to check that the nonce changes between requests. The agent warned me that test is wrong. For signed-out visitors, WordPress gives everyone the same nonce for a whole 12-hour window, so two fresh requests a few seconds apart will normally show the same value. That’s correct behaviour.

The real test for freshness has two parts:

  • the form timestamp goes up on every request, and
  • the nonce on the page matches what WordPress would issue right now, checked with wp eval and wp_verify_nonce().

A runbook for the agent with server access

The agent I was working with can’t get a shell on that server, and I prefer it that way. So it wrote a runbook for a second AI agent that does have shell access. The runbook had read-only pre-checks (find the form’s handler and nonce action, read the cache rules, record a baseline), a backup, the exact plugin file with a syntax check, a cache purge, a verification list with expected outputs, a one-minute rollback (rename the file and purge), and the changelog line to add afterwards.

It also said what not to do. There was to be no test registration, because that would create a real account nobody asked for. The nonce check proves the form works without one. And there was a regression check that the rest of the site still caches: the homepage should be a miss, then a hit.

What came back

I gave the second agent the go-ahead and it ran the runbook the same evening. The sign-up page came back as a cache miss three times in a row, with a new timestamp each time and a nonce that verified. The login page still bypassed the cache, and a fake verify link returned a 400 with the marker header.

There were two harmless differences from the expected output. The X-Accel-Expires header never shows up in the response, because nginx consumes it, and the final Cache-Control line had lost the word “private”. The second agent also couldn’t read the nginx config without sudo, so the behaviour checks stood in for that step. Then my agent re-ran the checks from outside, signed out, and got the same result. Only then did the change get its changelog entry.

Ship checklist

  1. Request every form page twice, signed out, and compare the cache status header and the hidden fields.
  2. Decode any form timestamp. If it’s older than a few seconds, the page is cached.
  3. Check the neighbours too: login, verify links, password reset and profile completion.
  4. Mark auth pages uncacheable in code: DONOTCACHEPAGE, nocache_headers(), no-store, X-Accel-Expires: 0.
  5. Add a marker header so curl checks can prove the plugin ran.
  6. Treat the panel’s exclude list as an extra layer, saved through the panel itself.
  7. Test freshness with a rising timestamp and a nonce that verifies, not a nonce that changes.
  8. Don’t create test accounts to prove a form works.
  9. Check that the rest of the site still caches after the change.
  10. Re-check from outside, then log the change with its rollback line.

FAQ

Why would a page cache store a form page at all?

To the cache, a signed-out sign-up page looks like any other public page. Unless the page or the cache rules say otherwise, it gets stored like a blog post. Login pages are often excluded by default. Custom sign-up pages usually aren’t.

Doesn’t skipping the cache slow the page down?

Only for a handful of account pages, which get a tiny share of traffic. The rest of the site still comes straight from the cache.

Why a must-use plugin rather than the theme?

Must-use plugins load on every request and can’t be switched off by accident in the admin, and the fix lives outside the theme. My live theme had been edited a few times recently, so keeping new behaviour out of it avoids merge headaches.

Where did the AI actually help?

It noticed the frozen timestamp, which I’d never have checked. It knew the nonce test I’d have used was wrong, and it wrote a runbook with exact expected outputs. That meant a second agent could run it and I could tell at a glance whether it had worked.

Closest related Builders notes?

My AI Agent Spotted My Login Name in an Author URL is the same outside-in, signed-out checking applied to author pages. I Let an AI Agent Patch a Theme With a Tiny Reversible Plugin uses the same small, easy-to-undo file pattern, and I Gave My AI Agents One Shared Changelog With a Rollback Line is where the change gets logged.

Share this article
Written by MustafaEngineer running a 24/7 homelab since 2022. Every guide here is built and tested on my own hardware. No paid placements.

Keep reading