A subscriber clicks the confirmation link in their welcome email. The browser fires a GET request to confirm.php?token=<value>. The naive implementation, which P8-5’s 501-returning shells were standing in for, handles this by reading the token from $_GET, finding the matching row, setting status = confirmed, and redirecting to a thank-you page.
The token is now in the browser history. It sits in the server’s access log. If there’s a reverse proxy in front, it’s in that log too. If the thank-you page makes any outbound request to a third party (an analytics ping, a font load, a favicon from a CDN), the token rides in the Referer header those requests carry. You confirmed the subscription once; the token now lives in at least five places that are not the database.
That’s the pattern P8-7 closes.
Why GET requests shouldn’t mutate
The HTTP spec is clear: GET is safe and idempotent. It retrieves; it does not change state. The confirm-on-arrival pattern violates this the moment the email lands in the inbox, because any crawler, prefetcher, or security scanner in the email client will follow the link and confirm the subscription before the human has read the email.
Gmail’s link-prefetching is the most common way this surfaces. A subscriber never clicks the link; Gmail’s safety scanner does, at arrival. The subscription is confirmed for an address whose owner may not have wanted it. The token is consumed. The database row is updated. The subscriber sees the thank-you page when they finally do click, but the decision was made without them.
For unsubscribe flows the same risk inverts. An unsubscribe link that mutates on GET can be triggered by any automated system in the delivery chain. The subscriber never intended to unsubscribe; the email client did it for them.
The interstitial pattern
The fix is an interstitial. The GET handler does two things only: validate the token exists and is current, then render a page with a form. The form’s submit fires a POST to the same endpoint. The POST handler validates the token a second time, executes the mutation, then invalidates the token.
Token flow:
- GET
confirm.php?token=<value>: validates, renders a form with the token in a hidden field - POST
confirm.php: reads token from body, validates again, mutates, clears
The token moves from the URL to a form body. POST request bodies don’t appear in browser history. They don’t appear in server access logs, which record path and query string only. They don’t ride in Referer headers because the form submit doesn’t carry the previous page’s URL out to third parties.
The leak surface is closed.
What the GET handler actually does
The GET handler is not a thin wrapper. Validating a token means:
- Token exists in the DB
- It has not expired
- The row it maps to is in the expected state (not already confirmed, not already cancelled)
- No prior consumption (idempotency guard)
If any check fails, the GET handler renders an error page, not a form. A subscriber who clicks a link twice sees a clear message on the second click that the action was already completed, not a silent redirect, not a 500.
The form carries no state beyond the token. No JavaScript dependency, no session requirement. The submit button is labelled with the action: “Confirm my subscription” or “Unsubscribe.” The human reads the page, reads the button, and clicks deliberately. That’s the intent signal.
The subscribe.php contrast
The subscribe handler (P8-6) follows a different pattern because the trust model is different. Subscribe is a fresh action from an anonymous user; it never carries a token. The pipeline runs in eight stages: method check, Origin check, per-IP rate limit, honeypot plus time-trap, email validation, DB upsert, confirmation mail dispatch. Around 225 lines.
Confirm and unsubscribe are time-delayed actions from a known address. The token substitutes for the Origin check and the rate limit: token possession is the credential. The same general shape applies (validate everything before acting), but the inputs differ.
The scaffold underneath
P8-5 shipped the six PHP classes these handlers sit on: around 600 lines across 15 files under cpanel-backend/. No framework, no Composer, no autoloader. Six classes loaded via plain require_once. The schema came in at P8-4. schema.sql, 190 lines of DDL with comments heavy enough that the file doubles as design documentation.
The decision to go framework-free matters here. Every framework I considered for a cPanel-hosted PHP backend brings a router, and most router tutorials show a confirm route that calls a single controller action triggered by GET. The interstitial pattern requires the same path to behave differently based on method. That’s not exotic; any router handles it. But the default scaffolding doesn’t enforce it and the tutorials don’t demonstrate it.
Without a framework, the method check is the first line of every handler. Explicit rather than implicit. The GET and POST branches are separate blocks, not actions sharing a name.
What this doesn’t cover
Email-client prefetching is one threat model. Direct link sharing is another: a subscriber who forwards the confirmation email gives the recipient a working link. For most newsletter flows that’s an acceptable trade-off. Single-use tokens with short expiry address it; the schema supports this. Binding to device fingerprint or IP is out of scope for this implementation.
Log retention is also out of scope here. The server access log still records every GET request, including path and query string. The token is in that log. The interstitial eliminates the access-log entry for the mutating request, which now arrives as POST with body not logged. It doesn’t eliminate the entry for the GET.
Why most frameworks skip this
They don’t skip it because they’re unaware of it. They skip it because the standard tutorial for email confirmation ends at redirect-after-GET-confirms and nobody tests it against a prefetching email client before shipping. The tutorial works in a browser. It fails in Gmail.
The interstitial adds one HTTP round-trip and one page render. For a flow that happens once per subscriber, that’s not a meaningful cost. The cost of skipping it is a class of mutations that fire before the human makes a decision. At scale, that’s a measurable proportion of your subscriber confirmations executed by an inbox security scanner rather than the subscriber.
The requirement is in constitution §1.2 V13.2. It was there before P8-7 shipped. It just needed someone to implement it rather than leave a 501 shell.
What the interstitial actually does
The pattern has a name in most web development literature, “Post/Redirect/Get”, though that framing targets form resubmission prevention rather than token security. The token-leak angle is a separate concern that happens to share the structural fix.
What the interstitial does is separate identification from authorisation. The GET establishes that the token is valid and the intended action is understood. The POST executes. The human in the middle is the authorisation step. Remove the interstitial and those two collapse: if the token exists, the action fires. Gmail’s scanner has a token. Gmail’s scanner is not the subscriber.
That collapses in the framing where “GET confirms, so the user confirmed” feels like a complete chain of custody. It’s not. The chain runs from the subscriber’s deliberate click to the database mutation. The interstitial is how you keep it intact.



