Ghost CMS Caching Post-Mortem Telemetry: Member Signups Collapse alongside HTTP 400 Edge Log Evidence
Telemetry comparison: 2026 Ghost CMS member signups cliff alongside live NGINX and Cloudflare edge terminal logs.

The Post-Mortem: How an Aggressive Cache Strategy Sabotaged My Ghost CMS Signups

How a single-character pluralization typo and Cloudflare's counter-intuitive rule order silently broke Ghost CMS magic-link logins, tanking my newsletter signups by 85%.

In my earlier deep-dive, Cloudflare Advanced: The Layered Cache Strategy, I detailed how to configure edge caching in front of self-hosted infrastructure to achieve sub-second page loads. Offloading dynamic compute from an origin Docker server to Cloudflare’s global Anycast edge is one of the most effective optimizations you can implement.

But edge caching is a double-edged sword.

If your edge rules fail to account for how modern web applications manage state, security, and authentication, you can silently cripple your site without ever throwing a 500 error.

Thank you to a loyal reader who emailed & let me know! Thank you Ben!!!

For roughly two months, my community newsletter signups collapsed. Month-over-month growth dropped from an all-time peak of 107 new members in May to a catastrophic 11 in September. I initially brushed it off as the classic summer slump. It was not.

This post-mortem documents how a single-character pluralization typo, combined with a counter-intuitive rule-precedence trap in Cloudflare Cache Rules and an aggressive browser TTL, completely broke Ghost CMS magic-link authentication and member registrations across my site.

Series Roadmap • Part 4 of 4
Cluster: Edge Security & Ingress

The Cloudflare Edge & Perimeter Shield Series

A battle-tested blueprint for configuring Cloudflare DNS, securing dynamic home server IPs, hardening edge WAF rules against malicious bots, and tuning caching without breaking application authentication.

Tested on bare-metal production topology
Core Lab Edge Stack · Cloudflare + OPNsense DDNS + Reverse Proxy

The Symptoms: The Silent 400 Bad Request

The failure mode was insidious because the blog itself appeared 100% operational. Readers could browse articles, static images loaded instantly from edge cache, and RSS readers pulled updates without issue. However, whenever a reader entered their email into the subscribe bar or attempted to sign in via Ghost Portal, the UI flashed a generic error toast:

"Failed to log in, please try again."

Because Ghost uses magic links for both authentication and new member onboarding, this error didn't just block existing readers - it completely slammed the door on new subscribers.

Inspecting my live Ghost container (coreblog) logs revealed the exact point of failure:

[2026-10-17 21:37:30] WARN Request with invalid integrity token.
[2026-10-17 21:37:30] ERROR "POST /members/api/send-magic-link/" 400 2ms

The request could not be understood.

Error ID:
    4cd4a91b-cc01-46e5-b894-c843f7867006

BadRequestError: The request could not be understood.
    at verifyIntegrityToken (/var/lib/ghost/versions/6.71.0/core/server/services/members/middleware.js:317:15)
    at Layer.handle [as handle_request] (/var/lib/ghost/versions/6.71.0/node_modules/express/lib/router/layer.js:95:5)
    at next (/var/lib/ghost/versions/6.71.0/node_modules/express/lib/router/route.js:149:13)
    at /var/lib/ghost/versions/6.71.0/node_modules/body-parser/lib/read.js:137:5

Ghost’s member service was explicitly catching and rejecting the request inside verifyIntegrityToken with an HTTP 400 BadRequestError.


To understand why this broke, you need to understand how Ghost protects itself against member spam and bot floods. And heavens are there a LOT of bot floods these days!

Ghost does not use a traditional static CAPTCHA by default. Instead, it utilizes an ephemeral, signed HMAC token via RequestIntegrityTokenProvider:

  1. When a visitor navigates to the site, the client-side Portal script makes an asynchronous HTTP request: GET /members/api/integrity-token/.
  2. When the user submits their email, the client sends a payload to POST /members/api/send-magic-link/ containing the email address and the integrityToken.
  3. Ghost’s verifyIntegrityToken middleware parses the token, checks the signature, and evaluates the timestamp. If the token is older than 300 seconds (5 minutes), Ghost drops the request immediately.

Ghost signs an integrity token on the origin using the internal theme_session_secret. In Ghost's core source code, this token has an explicit hardcoded TTL of 5 minutes:

module.exports.requestIntegrityTokenProvider = new RequestIntegrityTokenProvider({
    themeSecret: settingsCache.get('theme_session_secret'),
    tokenDuration: 1000 * 60 * 5, // 5 minutes
});

Now consider what happens if a reverse proxy or CDN edge caches that GET /members/api/integrity-token/ endpoint.

If Cloudflare caches that token for even 10 minutes, every single visitor served that cached response receives an integrity token that is already dead on arrival. The client browser faithfully forwards the token, Ghost checks the clock, sees it expired 5 minutes ago, and aborts with HTTP 400.


The Investigation: Probing Edge vs. Origin

I correlated SWAG’s NGINX access logs with the Ghost container logs during a live login attempt over a cellular LTE connection. The access log told the first half of the story:

10.0.0.117 - - [17/Oct/2026:21:37:14 -0400] "GET /members/api/member/ HTTP/2.0" 204 0
10.0.0.117 - - [17/Oct/2026:21:37:14 -0400] "GET /members/api/announcement/ HTTP/2.0" 200 21
10.0.0.117 - - [17/Oct/2026:21:37:21 -0400] "POST /members/api/send-magic-link/ HTTP/2.0" 400 224

The request for GET /members/api/integrity-token/ never reached my server. The client went straight from polling announcements to submitting the magic link form.

Next, I probed Cloudflare's Anycast edge IP directly using curl with the --resolve flag to bypass local DNS:

curl -sI --resolve corelab.tech:443:172.64.80.1 https://corelab.tech/members/api/integrity-token/

The response was the smoking gun:

HTTP/2 200 
server: cloudflare
date: Sun, 18 Oct 2026 01:20:49 GMT
cf-cache-status: HIT
age: 27771
cache-control: private, max-age=14400
last-modified: Sun, 18 Oct 2026 17:18:18 GMT
Poor Hudson

Look at those headers:

  • cf-cache-status: HIT - Cloudflare was serving the token directly from its edge cache.
  • age: 27771 - The token being handed to readers was 7.7 hours old. A token with a 5-minute lifespan was being served nearly 8 hours past its expiration date!
  • cache-control: private, max-age=14400 - Cloudflare had rewritten the downstream browser cache header, ordering visitors' mobile phones and desktop browsers to store that expired token locally on their devices for 4 hours.

The Three Root Causes

How did this happen? It was not a single bug, but a cascade of three distinct configuration flaws interacting simultaneously. It's always in the details, the little things... Members vs member...

Flaw 1: The Singular vs. Plural Typo

In my Cloudflare Cache Rule expression, I had created a rule to bypass dynamic paths. In that expression was this condition:

http.request.uri.path contains "/member/"

Notice the trailing singular slash: /member/. Ghost's active session endpoint is singular (/members/api/member/), but its authentication, integrity, and SSR endpoints are strictly plural: /members/api/integrity-token/, /members/api/send-magic-link/, and /members/ssr/.

Because /members/api/integrity-token/ did not match /member/, the request fell through my bypass filter and was captured by my broad caching rules.

Flaw 2: The "Last Matching Rule Wins" Trap

When migrating from legacy Page Rules or traditional firewalls (like OPNsense / pfSense / iptables), engineers are conditioned to believe that First Matching Rule Wins. In NGINX, the first matching location prefix stops processing. In standard firewall rulebases, the top rule executes first.

Cloudflare Cache Rules work in the exact opposite direction: LAST MATCHING RULE WINS.

In Cloudflare’s modern Ruleset Engine, rules are evaluated sequentially from top to bottom. If multiple rules match a request, any subsequent rule overwrites conflicting settings from earlier rules.

flowchart TD
    subgraph WRONG ["INCORRECT RULE ORDER (BROKE AUTH)"]
        W1["Rule #1: Bypass Cache (/members/, /ghost/)"] --> W2["Rule #2: Cache Everything (Host eq corelab.tech)"]
        W2 -->|"Rule #2 Overwrites Rule #1!"| WOUT["Result: /members/api/ is CACHED at Edge"]
    end

    subgraph RIGHT ["CORRECT RULE ORDER (LAST RULE WINS)"]
        R1["Rule #1: Cache Everything (Host eq corelab.tech)"] --> R2["Rule #2: Bypass Cache (/members/, /ghost/)"]
        R2 -->|"Rule #2 Overwrites Rule #1!"| ROUT["Result: /members/api/ is BYPASSED (DYNAMIC)"]
    end

When my Bypass rule was placed at Rule #1 (top), and my broad 7-day caching rule was positioned at Rule #2 (bottom), Cloudflare evaluated Rule #1, marked the path to bypass, then evaluated Rule #2, matched the hostname, and re-enabled 7-day edge caching.

To make a bypass rule effective in Cloudflare, the Bypass rule must ALWAYS sit at the very bottom of the execution order.

Flaw 3: The 4-Hour Browser Cache TTL

Under Cloudflare’s Caching → Configuration → Browser Cache TTL, if you specify a static duration (such as 4 hours), Cloudflare overwrites the origin's Cache-Control headers downstream.

Ghost intentionally outputs no-cache, private, no-store on API endpoints. Cloudflare replaced this with max-age=14400. When my mobile phone visited the site, Firefox Android cached that single expired integrity token in physical storage. Even when I purged Cloudflare's entire edge cache, the phone didn't query Cloudflare, it fetched the 20-minute-old token out of its local SQLite browser cache.


The Impact: Correlating the Data Drop

To quantify the real-world impact of this flaw, I pulled my complete registration history across all 548 members directly from the Ghost Admin API via a custom Python script:

xychart-beta
    title "Core Lab Ghost Member Signups (2026)"
    x-axis ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct (MTD)"]
    y-axis "Signups" 0 --> 120
    bar [25, 35, 24, 50, 107, 72, 41, 18, 11, 9]

The correlation was undeniable:

  • May 2026: 107 new signups (Peak growth!!!).
  • June 2026: 72 new signups. (WOW!)
  • July 1-26: Consistent daily signups averaging 1 to 4 registrations per day.
  • July 27 - August 2: ZERO signups for 7 consecutive days, precisely aligning with my reverse proxy and edge rule tuning from a year or so ago.
  • August - September: Signups collapsed by ~85% down to 18 in August and 11 in September.

via GIPHY

Why did any signups succeed at all?

If the token was cached, why didn't signups drop to absolute zero? My guess is because Cloudflare is an Anycast network composed of hundreds of independent edge data centres.

In Core Lab low-traffic regional PoPs (e.g., Frankfurt, Sydney, Tokyo), if an edge cache had expired, the first visitor triggered a cache MISS. If that reader entered their email within the 5-minute lifespan of that fresh token, their signup succeeded. But in high-traffic hubs like Toronto (YYZ) or Ashburn (IAD), the token was perpetually refreshed in cache, locking out more than 95% of prospective subscribers❗


The Production Fix: Cache Defence in Depth

To solve this permanently, I implemented a 3-layer architecture ensuring that even if Cloudflare rules are modified or reordered in the future, the origin reverse proxy forcefully prevents caching. SWAG to the rescue, again!

sequenceDiagram
    autonumber
    actor User as Visitor Mobile / Browser
    participant CF as Cloudflare Edge
    participant SWAG as SWAG (NGINX)
    participant Ghost as Ghost CMS Docker

    User->>CF: GET /members/api/integrity-token/
    Note over CF: Evaluates Rules: Bypass Rule #2 WINS
    CF->>SWAG: Pass-through to Origin (Bypass Cache)
    SWAG->>Ghost: Forward to coreblog:2368
    Ghost-->>SWAG: Generates fresh HMAC token (5m TTL)
    Note over SWAG: Injects: Cloudflare-CDN-Cache-Control: no-store
    SWAG-->>CF: Return token with origin no-store headers
    Note over CF: Passes downstream: cf-cache-status: DYNAMIC
    CF-->>User: Delivers fresh token to browser
    User->>Ghost: POST /members/api/send-magic-link/ (HTTP 201 Created)

The 3-Tier Defence in Depth Architecture

How requests traverse the stack without ever caching authentication tokens

Tier 1: Cloudflare Edge (Global Anycast) EDGE LAYER
  • Rule #2 (Bottom): Forces Bypass cache on all /members/, /ghost/, and preview paths.
  • Browser TTL: Set to Respect Existing Headers so client devices don't store expired tokens.
↓
Tier 2: SWAG (NGINX) Reverse Proxy REVERSE PROXY
  • Header Override: Stamped with Cloudflare-CDN-Cache-Control: no-store on /members/api/.
  • Fail-Safe: Cloudflare edge is forced by protocol to bypass cache even if dashboard rules glitch.
↓
Tier 3: Ghost CMS Docker Origin ORIGIN CONTAINER
  • Dynamic Token Generation: Issues fresh 5-minute HMAC tokens via RequestIntegrityTokenProvider.
  • Magic Link Verification: Validates subscriber requests instantly with HTTP 201 Created.

Step 1: Origin Level (SWAG / NGINX)

In my SWAG NGINX site configuration (corelab.tech.conf), I created a dedicated location block for /members/api/ positioned above the root location / handler. This block injects explicit upstream CDN cache-control headers:

# ----------------------------------------
# Ghost Members API (Dynamic Auth & Magic Links - NEVER CACHE)
# ----------------------------------------
location ^~ /members/api/ {
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-Host $host;

    # Defence in depth: Force Cloudflare and downstream browsers to NEVER cache auth endpoints
    add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
    add_header Cloudflare-CDN-Cache-Control "no-store, max-age=0" always;
    add_header CDN-Cache-Control "no-store, max-age=0" always;

    proxy_pass http://10.0.0.34:2368;
}

By specifying Cloudflare-CDN-Cache-Control: no-store, Cloudflare's proxy worker is instructed to bypass the cache regardless of downstream settings.

Step 2: Cloudflare Cache Rules Stack

I organized my Cloudflare dashboard (Caching → Cache Rules) into a strict two-rule stack:

Rule #1 (Top): Broad Guest Cache

  • Match Expression: (http.host eq "corelab.tech")
  • Cache Eligibility: Eligible for cache
  • Edge Cache TTL: 4 hours (or 1 day)
  • Browser Cache TTL: Respect Existing Headers

Rule #2 (Bottom / Must be Last): Ghost Core Bypass

  • Cache Eligibility: Bypass cache

Match Expression:

(http.host eq "corelab.tech" and http.cookie contains "ghost-members-ssr") or 
(http.host eq "corelab.tech" and http.cookie contains "ghost-admin-api-session") or 
(http.request.uri.path contains "/ghost/") or 
(http.request.uri.path contains "/p/") or 
(http.request.uri.path contains "/members/") or 
(http.request.uri.path contains "sitemap") or 
(http.request.uri.path contains ".xsl") or 
(starts_with(http.request.uri.path, "/.ghost/activitypub/")) or 
(starts_with(http.request.uri.path, "/.well-known/"))

Notice the inclusion of (starts_with(http.request.uri.path, "/.well-known/")). This prevents Cloudflare from caching ActivityPub federation discovery endpoints (webfinger, nodeinfo) and Ghost's internal JWKS keys (/ghost/.well-known/jwks.json).

Step 3: Global Browser TTL Setting

Under Cloudflare Caching → Configuration → Browser Cache TTL, set the value to "Respect Existing Headers".

Step 4: The Invalidation Trap - Naive Global Purges vs. Surgical URL Purging

Fixing the Cache Rules was only half the battle. During my comprehensive 2026 infrastructure audit, I uncovered a second silent saboteur: my legacy auto-purger Cloudflare Worker. It was listening to Ghost's coarse site.changed webhook and executing purge_everything: true on every trigger.

Because site.changed fires on draft edits, tag tweaks, and minor administrative touches, the Worker was wiping the entire domain cache over 290 times a week. This decimated my edge cache hit ratio, battered my origin SWAG reverse proxy, and caused unpredictable token invalidation loops.

The Modern Fix: I scrapped the legacy script, deleted the noisy site.changed webhook, and deployed a modern ES-module Worker bound to a private Zone Route (corelab.tech/.ghost-cache-purge*). It listens strictly for post.published and post.published.edited, extracting the updated post URL from the payload to purge strictly the targeted files: { files: [postUrl, homeUrl, rssUrl, tagUrl] }.

Step 5: Edge WAF Governance - Unlocking Crawlers & Self-Hosted RSS Readers

The final trap was Cloudflare's Bot Management and WAF rate-limiting. In late 2026, Cloudflare introduced strict Super Bot Fight Mode scoring that challenged automated non-browser clients. While intended to block scrapers, it began serving HTTP 403 Forbidden and Managed Challenges to verified search crawlers (Googlebot, Bingbot) and, worst of all, self-hosted RSS readers (FreshRSS, Miniflux, Feedbin).

Self-hosted readers do not execute JavaScript or solve Captchas. They simply marked Core Lab as "Feed Errored" and stopped polling. The solution was an explicit top-priority WAF Custom Rule with action: skip for Super Bot Fight Mode and managed WAF: cf.client.bot or ((http.request.method in {"GET" "HEAD"}) and (ends_with(http.request.uri.path, "/rss/") or ends_with(http.request.uri.path, "/rss")))

Ghost already sets public, max-age=31536000 (1 full year) on static images (/content/images/) and theme CSS/JS bundles. It sets max-age=0 on published posts so revisions appear instantly, and no-store on auth. Forcing a blanket 4-hour browser TTL ruins that intelligence.


Verification: Confirming the Fix

After purging Cloudflare's cache, I re-tested the entire magic-link flow through my external domain via Python:

[2026-10-17 21:39:02] INFO "GET /members/api/integrity-token/" 200 1ms
[2026-10-17 21:39:03] INFO [Inbox links] Found no inbox links
[2026-10-17 21:39:03] INFO "POST /members/api/send-magic-link/" 201 391ms

Ghost successfully generated a fresh token, validated it within 1 millisecond, and returned HTTP 201 Created. Checking the response headers directly on the edge confirms the endpoint is permanently dynamic:

HTTP/2 200 
server: cloudflare
cf-cache-status: DYNAMIC
cache-control: no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0
cdn-cache-control: no-store, max-age=0

Key Takeaways for Self-Hosters

  1. Cloudflare Cache Rules are "Last Matching Wins": Never place broad cache-everything rules below specific bypass rules. Your bypass rules must be at the very bottom of your list.
  2. Pluralization Matters: In Ghost, session checks are singular (/member/), but dynamic authentication and token issuance are strictly plural (/members/).
  3. Always Set Browser TTL to "Respect Existing Headers": Setting a blanket 4-hour or 1-day Browser Cache TTL will force mobile devices to hoard dynamic tokens locally in SQLite, persisting bugs long after you purge your CDN.
  4. Implement Defence in Depth at NGINX: Do not rely solely on Cloudflare dashboard toggles. Add Cloudflare-CDN-Cache-Control: no-store in your reverse proxy config so origin rules are enforced regardless of CDN misconfigurations.
  5. Audit Member Growth Against Deployment Timelines: If your newsletter subscriptions or user signups drop significantly, don't dismiss it as seasonal fluctuation. Check your application logs for HTTP 400 Bad Request errors on token endpoints.

Written by

Core Lab Joe
Core Lab Joe
Dad, IT guy, Self-Hosting Evangelist! https://www.reddit.com/r/corelabtech/
corelab.tech
Facebook
Mastodon

On this page

What's Next?