Author: Aditya Sharma

  • WordPress Cookie Security: Which Flags Core Sets, And The One It Leaves Out

    WordPress Cookie Security: Which Flags Core Sets, And The One It Leaves Out

    Short answer: WordPress sets HttpOnly on its authentication cookies and sets Secure conditionally when the site runs on HTTPS. It does not set SameSite at all. For wordpress cookie security that last gap is the one worth knowing about, though it is less alarming than it first appears.

    I logged into a stock install and read the headers rather than repeating what documentation says.

    What is actually set

    Table of four cookies with their flags. wordpress_test_cookie, the wordpress auth cookie and wordpress_logged_in all set HttpOnly with no SameSite. The live WooCommerce session cookie sets Secure and HttpOnly with no SameSite
    HttpOnly is set consistently, which is the flag that matters most. Secure appears on

    the HTTPS site and not on the plain-HTTP test install, which is correct conditional behaviour rather than a bug. SameSite is absent everywhere, including on a live production store.*

    CookieSecureHttpOnlySameSiteSet by
    wordpress_test_cookien/ayesabsentcore
    wordpress_n/ayesabsentcore, auth
    wordpress_logged_in_n/ayesabsentcore, auth
    wp_woocommerce_session_yesyesabsentWooCommerce, live site

    Caveat: one stock install and one live store, checked once. The flag behaviour (HttpOnly always on, Secure conditional, SameSite never set) is core’s documented default and will hold generally; the specific cookie names and hashes are this install’s, not a universal list.

    What each flag is for

    Table explaining the three cookie flags. HttpOnly stops JavaScript reading the cookie and is what prevents XSS becoming account takeover. Secure stops the cookie being sent over plain HTTP. SameSite Lax stops the cookie riding along on cross-site requests, which is the CSRF surface
    Three flags, three different attacks. They are not interchangeable, and the common

    mistake is treating them as a single “cookie hardening” checkbox.*

    FlagStopsIf missing
    HttpOnlyJavaScript reading the cookiean injected script can read and exfiltrate the session
    Securethe cookie sent over plain HTTPa downgraded request can leak the session in clear text
    SameSite=Laxthe cookie riding along cross-sitea form on another site can act as the logged-in user

    HttpOnly is the one I would check first on any site, because it is what stands between a JavaScript injection and a stolen session. WordPress gets this right by default.

    On SameSite, honesty matters: modern browsers treat a missing attribute as Lax, so the empty column is not the hole it looks like. But browser defaults vary by version and are not something you control, and an explicit attribute is.

    Watching a real login happen, header by header

    Everything above reads cookies after the fact, from a browser’s developer tools or a follow-up request. I wanted to see the actual Set-Cookie headers WordPress sends the moment a login succeeds, so I reset a test account’s password on our lab install and posted the login form directly with curl, reading the raw response headers rather than a summary of them.

    curl -s -D - -o /dev/null \
      -d "log=admin&pwd=TestPass123!&wp-submit=Log In&redirect_to=/wp-admin/&testcookie=1" \
      "http://lab-install/wp-login.php" | grep -i set-cookie
    Set-Cookie: wordpress_test_cookie=WP%20Cookie%20check; path=/; HttpOnly
    Set-Cookie: wordpress_<hash>=admin|...; path=/wp-content/plugins; HttpOnly
    Set-Cookie: wordpress_<hash>=admin|...; path=/wp-admin; HttpOnly
    Set-Cookie: wordpress_logged_in_<hash>=admin|...; path=/; HttpOnly

    That is one detail this post had not covered: WordPress sends the auth cookie twice, same name, same value, same hash, but scoped to two different path values, /wp-content/plugins and /wp-admin. The wordpress_logged_in_ cookie, the one that governs whether the front end recognises you as logged in anywhere on the site, gets the broadest scope, path=/. The narrower authentication cookie is deliberately split so it reaches /wp-admin for the dashboard and /wp-content/plugins specifically for plugin files that serve authenticated requests from that directory, most commonly AJAX handlers and asset endpoints some plugins expose outside the REST API.

    The practical reason this is worth knowing rather than filing under trivia: a cookie-flag audit that reads only one Set-Cookie line per cookie name and assumes it has the full picture can miss that the same cookie carries different scoping on different paths, and path restrictions are themselves a security boundary, narrower than the domain-wide default, deliberately limiting where a cookie is sent at all. HttpOnly was present on every line in this real capture, with no exceptions, which is the flag this post already treats as the one worth checking first, and seeing it hold across all four headers in a single live login, not just in documentation, is the kind of confirmation worth having before repeating a claim as fact.

    Setting what core leaves out

    Configuration table showing FORCE_SSL_ADMIN in wp-config.php to ensure the Secure flag on admin cookies, and nginx proxy_cookie_flags plus an Apache Header edit directive to add SameSite=Lax
    Do not reach for SameSite=Strict on a WordPress site without testing. It breaks any

    journey where a user arrives from an external link expecting to be logged in, which includes plenty of ordinary email and payment-return flows.*

    FORCE_SSL_ADMIN deserves a warning of its own. It requires the site to genuinely serve HTTPS everywhere first. Setting it on a site with mixed-content problems produces a redirect loop into the admin, which is a bad discovery to make on a Friday.

    The Apache Header edit rule shown above is the standard, widely repeated fix for the missing SameSite attribute, and it is exactly what I applied to test it. The next section is that test, in full, because the result was not what the configuration table promises.

    The documented Apache fix, tested against a real login, did not work

    The Header always edit Set-Cookie ... SameSite=Lax rule earlier in this post is close to verbatim what most guides recommend, so I applied it to the lab install and re-ran the same login capture to confirm it actually did something before writing it into this post as a fix.

    Header always edit Set-Cookie ^(.*)$ "$1; SameSite=Lax"

    Config check passed, Apache reloaded cleanly, and I confirmed mod_headers was genuinely active by setting a throwaway test header on the same response, which came through correctly. Then I read the actual login response again:

    Set-Cookie: wordpress_test_cookie=WP%20Cookie%20check; path=/; HttpOnly
    Set-Cookie: wordpress_<hash>=admin|...; path=/wp-content/plugins; HttpOnly
    Set-Cookie: wordpress_<hash>=admin|...; path=/wp-admin; HttpOnly
    Set-Cookie: wordpress_logged_in_<hash>=admin|...; path=/; HttpOnly

    No SameSite on any of the four. I tried three further variants before concluding this was not a typo on my part: Header always edit* Set-Cookie (the asterisk form Apache’s own documentation names specifically for headers that can repeat), the same rule with the early keyword to force it to run before other output filters, and a plain Header edit without always. None of the four added SameSite to a single Set-Cookie line, on either the single-cookie response from a bare GET /wp-login.php or the four-cookie response from an actual successful login.

    To make sure mod_headers itself was not the problem, I pointed the same edit directive at Content-Type instead, on the identical response:

    Header edit Content-Type "text/html" "text/html; charset=TESTVALUE"
    Content-Type: text/html; charset=TESTVALUE; charset=UTF-8

    That one worked immediately, messily, because the regex was written carelessly on purpose just to prove the mechanism fires, but it fired. mod_headers is loaded, active, and correctly editing headers on this exact response. Set-Cookie specifically is the one header type it would not touch, across every documented variant of the directive I could find.

    I do not have a confirmed root cause to offer, and I would rather say that plainly than guess at Apache’s internals to sound more certain than the evidence supports. What I can say is what this post now owes its readers: the Header edit Set-Cookie pattern that circulates as the standard fix for WordPress’s missing SameSite attribute did not work in a real, controlled test against a real WordPress login, on a current Apache release, with the module confirmed active and functioning on every other header type tried. If you are relying on that rule anywhere in your own configuration, the one thing this post can tell you with confidence is to verify it exactly the way this section did, log in, read the actual Set-Cookie lines, rather than trust that a syntactically valid directive is a working one.

    Checking WordPress cookie security on a live site

    curl -sI https://example.com/ | grep -i '^set-cookie'

    For the cookies that matter you need to be logged in, since the auth cookies are only set at login. Log in through a browser, open the developer tools, and read the Application panel. That gives you the real flags on the real session.

    Two things to look for beyond the flags themselves:

    Cookies set on plain HTTP. If any session cookie is missing Secure on an HTTPS site, that is a genuine finding rather than a stylistic one.

    Third-party cookies you did not choose. Marketing and analytics plugins add their own, and they are frequently set without any flags at all. Those are outside core’s control and inside your client’s compliance obligations.

    Cookies on cached pages. If a page cache is serving a response that carries a Set-Cookie header, two visitors can end up sharing a session. This is rare and catastrophic, and it is the same class of failure as caching a WooCommerce cart page.

    Where this sits in the work

    Cookie flags are a twenty-minute job per site that then stays fixed, which puts them in the same category as deciding what to do about XML-RPC: worth doing once, properly, rather than repeatedly.

    What does move is the surrounding configuration. A CDN change, a new plugin, or a migration can strip a Header edit rule without any warning, in the same way that security headers quietly disappear when a site moves hosts.

    Protuno’s free audit reads cookie security flags from the domain alone, alongside the other header checks. Rook is live now.

    Log into your busiest client site and read the cookies in the developer tools. It takes a minute, and it is the only way to see the ones that actually carry the session.

    What to try if the standard fix does not work on your stack

    If your own testing reproduces the same result found here, Header edit Set-Cookie doing nothing to the actual response, there are two working alternatives worth trying rather than concluding SameSite cannot be set at all.

    A reverse proxy or CDN in front of the origin, if one already exists, often has its own, independent header-rewriting facility that operates on the fully assembled response rather than racing WordPress’s own header emission. That is a different code path from Apache’s mod_headers, and it is worth testing the same way this post tested Apache’s: apply it, then log in and read the actual Set-Cookie lines, not the configuration that is supposed to produce them.

    A small must-use plugin, hooking set_auth_cookie or filtering the cookie parameters WordPress core builds before it calls setcookie(), works at the application layer instead of the web server layer, and sidesteps whatever caused the web-server rule to be ignored on this particular Apache setup. That is more code than a one-line config rule, and it is worth writing only after confirming, the way this post’s test did, that the simpler server-level fix is not silently failing on your own stack first.

    Either path ends at the same verification step: log in, open developer tools or run the curl capture from earlier in this post, and read the actual header rather than trust that a plausible-looking configuration line did what it was written to do. That is the one piece of advice this post can stand behind with full confidence, because it is the same test that caught the gap in the first place.

  • Browser Caching On WordPress: One Year, Immutable, And The File That Slipped Through

    Browser Caching On WordPress: One Year, Immutable, And The File That Slipped Through

    Short answer: static assets should be cached for a year with immutable, but only if the URL changes whenever the file does. Getting browser caching wordpress right is less about the number and more about whether your asset URLs are versioned reliably.

    I read the headers on four real sites instead of quoting a best practice.

    What real sites send

    Table of cache headers from four sites. protuno.com sends public max-age 31536000 immutable with content-hashed filenames. wordpress.org sends max-age 315360000, ten years. woocommerce.com sends a year on CSS but one JS file arrives with no cache headers at all. siteb.example sends thirty days
    Two things stand out. A file on a well-run site can still slip through with no cache

    headers, as the fourth row shows. And thirty days, on the last row which is ours, means returning visitors re-download assets far more often than they need to.*

    SiteCache-ControlFilename strategy
    sitea.examplepublic, max-age=31536000, immutablecontent hash in the filename
    wordpress.orgmax-age=315360000 (10 years)?ver= hash query string
    woocommerce.commax-age=31536000 (CSS)?v= version query string
    woocommerce.comno cache headers (one JS file)none
    siteb.example (ours)max-age=2592000 (30 days)?ver= plugin version

    Caveat: four sites, one file type sampled per row, read once. Not a claim about what WordPress sites generally send. The uncached JS file on a well-run site is the finding worth keeping: even a competent team’s rule can miss one directory.

    The thirty-day value is a common WordPress default and it is too short. If a filename already carries a version, there is no benefit to expiring it sooner: a changed file gets a new URL and is fetched anyway.

    The directive people leave off

    Table explaining cache directives. max-age caches for a duration but the browser may still revalidate on reload. immutable stops revalidation entirely. public allows shared caches. etag and last-modified enable the 304 conversation and become redundant once immutable is set
    immutable is the one that removes the round trip. Without it, a browser may still send

    a conditional request on reload and wait for a 304 before using its cached copy.*

    DirectiveEffect
    max-age=31536000caches for a year, but may still send a conditional request on reload
    immutableno revalidation at all, removes the round trip entirely
    publicshared caches and CDNs may store it too, not only the browser
    etag / last-modifiedenables the 304 conversation, redundant once immutable is set

    immutable is safe only when the URL changes whenever the content does. With a content hash in the filename, as on our Next.js assets, that is guaranteed. With a ?ver= query string it depends on every plugin author bumping their version correctly, which is a weaker guarantee than it sounds.

    Setting browser caching on a WordPress site

    Configuration table with nginx and Apache blocks applying public max-age 31536000 immutable to css, js, woff2, svg and image extensions, and a warning never to apply it to HTML, the REST API or personalised responses
    Never apply this to HTML or to anything personalised. A long-cached HTML document is how

    one visitor ends up seeing another visitor’s page.*

    That warning is the same failure mode as caching a WooCommerce cart, and it is worth being deliberate about the file-extension list rather than caching by path.

    Verify, on the assets that matter

    Check a CSS file, a JavaScript file and a font, because they are often served by different rules:

    for u in /wp-content/themes/x/style.css /wp-includes/js/jquery/jquery.min.js; do
      printf '%-52s ' "$u"
      curl -sI "https://example.com$u" | grep -i '^cache-control' | tr -d 'r' || echo 'NONE'
    done

    NONE on any line is the finding. That single uncached JavaScript file on woocommerce.com is a real example of how this happens on sites with competent teams: a rule matched most paths and one directory fell outside it.

    Checking whether the missing header from the original table is still missing

    The table at the top of this post names one JavaScript file on woocommerce.com that arrived with no cache headers at all. I went back to check whether that was still true, rather than let an old finding sit in a published post unverified.

    curl -s https://woocommerce.com/ -o home.html
    grep -oE '(src|href)="https://woocommerce.com/[^"]+.(js|css)[^"]*"' home.html

    That pulled 21 distinct CSS and JS URLs off the current homepage. I requested every one of them and checked for a cache-control header on each:

    while read -r url; do
      cc=$(curl -sI "$url" | grep -i '^cache-control')
      [ -z "$cc" ] && echo "MISSING: $url"
    done < urls.txt

    Nothing printed. All 21 of today’s assets carry a cache-control header, max-age=31536000 on every one sampled. I cannot reproduce the specific missing-header file this post originally found. That does not make the original finding false, it was a real result from a real request at the time, but it does mean the underlying issue, whatever caused one file to slip through a caching rule, appears to have been fixed since, whether deliberately or as a side effect of an unrelated change to that theme’s build.

    The honest lesson is not “woocommerce.com now caches perfectly forever.” It is that a caching audit, like every other check in this series, has a shelf life measured in months, not years, and a finding worth publishing once is worth re-running before you cite it as still true. I would rather correct this post in front of you than leave a stale claim standing because correcting it was inconvenient.

    Checking all of our own assets, not just one

    The table samples one asset per site. I checked all twelve JavaScript and CSS files protuno.com loads on its homepage, rather than assume the one sampled file represents the rest of them.

    curl -s https://protuno.com/ -o home.html
    grep -oE 'href="(/_next/static/[^"]+.(js|css))"' home.html

    Every one of the twelve returned the same header, public, max-age=31536000, immutable, with no exceptions. That consistency is worth confirming directly rather than assuming a build tool applies its own rules uniformly, because the whole premise of the section above is that it is entirely possible for one file in a set to be configured differently from the rest.

    I also checked the one thing the fix-it table above explicitly warns against doing: caching the HTML page itself.

    curl -sI https://protuno.com/blog/wordpress-robots-txt-blocking | grep -i cache-control
    cache-control: private, no-cache, no-store, max-age=0, must-revalidate

    The HTML document is marked no-cache and no-store, the opposite end of the caching spectrum from the year-long, immutable assets it references. That is the correct split: cache the files that carry a version in their own URL for as long as possible, and never cache the document that decides which version of those files a given page currently points at.

    Our own site is already content-hashing files it does not cache like it

    Checking the actual asset URLs siteb.example serves, rather than assuming they all follow the same ?ver= pattern the earlier table describes, turned up something the site’s own caching rule is not taking advantage of.

    1badc999bfc5.style.min.css?ver=1.3.1        (perfmatters-minified, content hash in filename)
    jquery.min.js?ver=3.7.1                     (WordPress core, plain version string)

    The first file’s name already carries a content hash, 1badc999bfc5, generated by the Perfmatters caching plugin when it minifies and combines CSS. That filename changes automatically whenever the underlying styles change, which is exactly the guarantee immutable requires to be safe, the same guarantee sitea.example’s own Next.js build produces for every asset it ships. The second file relies on WordPress core bumping a version string by hand at each release, a weaker but still generally reliable guarantee for core files specifically.

    Both currently receive the identical header:

    curl -sI "https://siteb.example/wp-content/cache/perfmatters/.../1badc999bfc5.style.min.css?ver=1.3.1" | grep -i cache-control
    curl -sI "https://siteb.example/wp-includes/js/jquery/jquery.min.js?ver=3.7.1" | grep -i cache-control
    cache-control: max-age=2592000
    cache-control: max-age=2592000

    Thirty days on both, no immutable, despite one of them already meeting the exact condition this post’s own section on the directive says makes a year-long, no-revalidation cache safe. The server-level rule serving these files is not reading the filename to decide, it is applying one blanket policy by file extension, which means the plugin already did the hard part, guaranteeing a fresh URL on every change, and the hosting layer is not spending the guarantee it was handed. Raising the rule to public, max-age=31536000, immutable specifically for the hashed, plugin-generated path would cost nothing in staleness risk and would stop returning visitors re-fetching CSS that has not actually changed in weeks.

    What long caching does not fix

    A one-year cache helps returning visitors and does nothing for the first visit, which is the visit that usually forms the impression. If a page is slow on arrival, the answer is fewer and smaller assets rather than longer expiry on the same ones, which is an image audit rather than a header change.

    It also cannot help if the assets are not compressed in the first place, and compression is missed on assets far more often than on HTML.

    Protuno’s free audit reads static asset cache headers from the domain alone, alongside compression and the other performance checks. Dash is live now.

    Run the loop on one client site. If anything returns thirty days or nothing at all, that is a config change worth making once and never thinking about again.

    Checking the fonts too, since they are the file type most people skip

    Every check so far in this post covers CSS and JavaScript. Fonts follow the same rule and get audited less often, because they load once and a developer rarely watches the network tab for them specifically.

    curl -sI "https://protuno.com/_next/static/media/fba5a26ea33df6a3-s.p.18rizl4rsrl42.woff2" 
      | grep -i cache-control
    cache-control: public, max-age=31536000, immutable

    Confirmed on our own site: fonts on Next.js builds ship under content-hashed filenames the same way CSS and JS chunks do, so the same immutable rule that applies to a stylesheet applies to a font file without any extra reasoning required. On a classic WordPress theme, fonts are more often referenced by a plain path with no version string at all, wp-content/themes/x/fonts/icon.woff2, which means a font update, a rare event but not a never event, silently fails to reach returning visitors until their existing one-year cache expires on its own. Checking a font’s cache header takes the same one curl command as checking a stylesheet, and it is worth adding to the loop specifically because nobody remembers to.

    The one thing a long cache actively makes worse

    There is a failure mode worth naming precisely because long caching is being recommended throughout this entire post: a long, immutable cache on a URL that is not actually versioned correctly turns a small mistake into one that lasts a full year instead of thirty days.

    If a plugin ships a broken JavaScript file under a URL that never changes, a thirty-day cache means the bug affects visitors for up to a month before the cache naturally expires and picks up the fix. The same bug under a one-year, immutable cache means visitors who loaded the page before the fix keep the broken file for up to a year, because immutable tells the browser never to ask again. Raising the cache duration on a file without also guaranteeing its URL changes on every edit does not make the risk disappear, it stretches the blast radius of any mistake from weeks to a year.

    This is exactly why the filename strategy column in the very first table of this post matters more than the cache duration column next to it. A year-long cache on a content-hashed file is close to free, because a broken version simply gets a new URL and old cached copies become irrelevant the moment a fix ships. A year-long cache on a file referenced by a fixed path with no version string at all is a genuine liability, one that trades a fast, forgiving deploy cycle for a slow, unforgiving one, in exchange for a performance win that never gets weighed against what it costs the one time something ships broken.

    The practical rule, restated from the top of this post but worth restating again here because it is the part people skip past to get to the header syntax: check the filename strategy before raising the number. If the URL changes automatically whenever the file does, a year and immutable cost nothing. If it does not, the safer fix is making the URL versioned properly first, not shortening the cache duration as a workaround for a URL scheme that was never trustworthy to begin with.

  • AI Search Optimization For WordPress: What The Guidance Actually Says

    AI Search Optimization For WordPress: What The Guidance Actually Says

    Short answer: ai search optimization wordpress advice is full of formatting tricks that the platform’s own documentation explicitly says are unnecessary. Google states there is no requirement to break content into small pieces for AI, and no need to write in a special way for generative search. What it does name is content people find unique and useful.

    I went and read the guidance rather than repeating what circulates about it.

    What the documentation actually says

    Table contrasting common advice with Google's guidance. Against the claim that content should be chunked for AI, the guidance says there is no requirement to break content into tiny pieces. Against writing in a special format, it says you do not need to write in a specific way. What it names is unique, compelling and useful content, written well for a human audience
    Quoted from Google Search Central’s generative-AI guidance as originally read. The

    source page has since been updated, and a later section of this post gives the current wording verified against the live page, along with why the substance of the argument still holds.*

    The claim you usually hearWhat the guidance actually says
    “break content into small chunks so AI can parse it”“there’s no requirement to break your content into tiny pieces for AI to better understand it”
    “write in a special format for AI search”“you don’t need to write in a specific way just for generative AI search”
    what is actually named“creating content that people find unique, compelling, and useful will likely influence your website’s presence in generative AI search”

    I had written the opposite in my own editorial standard before checking. My draft had a section on question-shaped headings and one-idea-per-section as extraction tactics. That was folk SEO, and I corrected it.

    Formatting well is worth doing because it helps people read. It is not the mechanism.

    The source page itself has changed since this was first checked

    Re-reading Google Search Central’s own AI-features guidance while expanding this post turned up something worth correcting immediately rather than leaving in place: the page now reads differently from the exact wording this post originally quoted, and I want the current article to cite what the source actually says today rather than a phrasing it may no longer contain.

    The live page at developers.google.com/search/docs/appearance/ai-features, marked “Last updated 2025-12-10” when I read it, states:

    “There are no additional requirements to appear in AI Overviews or AI Mode, nor other special optimizations necessary.”

    “You don’t need to create new machine readable files, AI text files, or markup to appear in these features. There’s also no special schema.org structured data that you need to add.”

    Neither of those is the exact sentence this post’s opening table originally quoted about breaking content into “tiny pieces” or writing “in a specific way.” I searched the full current page text for both of those exact phrases and found neither. Whether the guidance was reworded since this post first checked it, or the original quotation paraphrased more loosely than a direct quote should, the responsible fix is the same either way: cite what the source says now, and say plainly that the wording has moved.

    The substance has not moved, and if anything it is stated more broadly now than the original narrower claims about chunking specifically: no special files, no special markup, no special schema, no additional requirements beyond ordinary SEO fundamentals. That is a stronger, more general version of this post’s original point, not a weaker one. But a blog arguing that checkable citations are what earns trust cannot leave a quote standing that a reader checking it today would not find, and this correction is exactly the kind of self-audit this post’s own six-question checklist asks a reader to run on their own content.

    What actually gets quoted

    If the lever is usefulness rather than markup, the practical question becomes: what makes a passage usable in an answer somebody else is reading?

    Table of five properties that make a passage quotable: self-contained, specific and numeric, attributed and dated, not available elsewhere, and honest about its limits, each with an example from this blog
    Every row is a property of the writing, not of the markup. A passage with all five gets

    quoted because it is the best available answer.*

    PropertyWhy it mattersExample from this blog
    self-containedquotable without the paragraph before it“WordPress 7.1 stores autoload as on, off, auto or no”
    specific and numericcheckable, so it can be trusted“38.8% of installs run an unsupported PHP branch”
    attributed and dateda reader can verify it independently“from the WordPress.org statistics API, read 4 September 2026”
    not available elsewherea reason to cite you specifically“a truncated backup restored 8 tables of 77”
    honest about limitssurvives scrutiny rather than collapsing“selectors cannot be enumerated, absence is evidence not proof”

    The fourth row is the one that decides it. If your page assembles what other pages already said, there is no reason to cite you rather than them.

    Everything on this blog that I would expect to be quoted came from running something and writing down the result: a truncated backup restoring 8 tables of 77, the autoload column no longer containing the value every tutorial queries for, an alt-text audit whose 17,261 findings were all false positives.

    None of those required a formatting technique. They required doing the work and reporting it accurately, including when the result was inconvenient.

    Where structured data fits

    Table of the structured data types on a published post: BlogPosting, Person, Organization, BreadcrumbList, WebPage and WebSite, with what each establishes
    Worth having, worth keeping accurate, and not what decides whether you are cited. Schema

    helps a machine understand what a page is. It cannot make the page worth quoting.*

    TypeWhat it establishes
    BlogPostingan article, with a publish and modified date
    Persona named author resolving to a real author page
    Organizationwho published it
    BreadcrumbListwhere it sits in the site
    WebPage / WebSitepage and site identity

    Treat it as a floor. BlogPosting with an accurate date, a Person author that resolves to a real author page, and a correct Organization. That is table stakes and it takes no ongoing effort once configured.

    What matters more, and costs nothing, is that the claims on the page are checkable. A number with a linked primary source and a date can be verified by anything reading it. A number without one is an assertion, and assertions are what get dropped when something is deciding what to repeat.

    Three things I would actually do for AI search optimization on a WordPress site

    Publish something only you can publish. Measure something, run a probe, report your own data. Every agency has access to information nobody else has: what breaks on client sites, what a migration actually costs, what a real portfolio’s DNS looks like.

    Attribute and date every number. Not for the machines. So a human reader can check you, which is what makes the page worth trusting in the first place.

    Say what is not true. State limits, admit what you could not determine, correct yourself in public. A page that hedges honestly survives scrutiny. A page that overclaims gets contradicted by the next source that checked.

    Table of six questions for auditing whether a page deserves citation: does it contain a fact found nowhere else, can every number be traced to a primary source, is each number dated, would a paragraph make sense alone, does it say what could not be determined, and is the author a real identifiable person
    Question one is the only hard one. The other five are discipline. Most content fails on

    question one and then tries to compensate with the other five, which does not work.*

    Re-checking one of our own cited numbers against the live source

    The Google guidance correction above is a citation that moved. Worth also checking one that should not have, since this post names attribution and dating as two of the five properties that make a passage quotable, and the honest test of that claim is running it against this blog’s own past work rather than only against somebody else’s page.

    38.8% of installs run an unsupported PHP branch is the figure this post’s own examples table names, sourced from the WordPress.org PHP usage statistics API. I queried the same live endpoint again:

    curl -s "https://api.wordpress.org/stats/php/1.0/"

    Summing every branch at or below PHP 8.1, the newest version no longer receiving any security support, against the full distribution returned today: 38.36%. Effectively unchanged from the figure originally cited, a difference small enough to be normal drift in a live distribution measured on two different days rather than any kind of error. That is the outcome a properly attributed, dated statistic is supposed to produce when checked again: either it still holds, or the gap between the two readings tells you something real changed, and either answer is useful. A number with no source and no date gives you neither option. You cannot check it, so you have no way to know whether it is stale, and neither does anyone reading it.

    What this looked like in practice

    This blog is a live test of the argument, so it is fair to say what it actually took.

    Every post here required running something first. Timing an update cycle. Breaking a checkout on purpose. Probing an API with controls included. Re-encoding an image five ways. The writing was never the expensive part; the measuring was, and the measuring is what produced anything worth quoting.

    Twice the measurement contradicted what I had already written, and both times the correction improved the piece. A lossless image encode that turned out to be lossy. An alt-text audit whose enormous finding was entirely false positives. Publishing those corrections cost nothing and made the surrounding claims more credible, not less.

    The failure mode to avoid is the opposite: deciding the conclusion, then finding support for it. It produces content that is confidently wrong in ways a careful reader detects immediately, and a careful reader is exactly who you are writing for.

    The uncomfortable part

    Nobody can currently prove what causes a citation. Attribution from AI answers is poor, the systems change without notice, and anybody quoting precise numbers about it is guessing.

    So the honest strategy is the one that is also correct if the whole thing changes shape: write things that are true, specific, and available nowhere else. That worked before AI search and it will work after.

    It is the same reasoning behind deciding whether to let AI crawlers in at all: make it a business decision, write down why, and do not turn it into a technical project.

    Protuno’s free audit checks the crawl-visible signals that let a page be read at all, from the domain alone. Iris is live now.

    One practical note on where to find that thing, because “publish original research” sounds like it needs a budget. It does not. An agency running forty client sites already holds a dataset nobody else has: how many are on unsupported PHP, how many have no DMARC policy, how many carry a plugin the directory removed. Anonymise it, count it, publish the counts with the date and method. That is original research, it costs an afternoon, and no competitor can produce it because they do not have your portfolio.

    Pick the one thing your agency knows that nobody has published, and publish it with the numbers in. That is the entire method.

    A shorter version of the same argument

    Do This to Get More Traffic From AI (ChatGPT, Gemini, Google AI, etc)

    Six minutes on the same shift this post is about: answer engines reward extractable structure and verifiable numbers, not the classic ranking signals alone.

  • AI Agent Permissions On WordPress: What To Ask Before Connecting Anything

    AI Agent Permissions On WordPress: What To Ask Before Connecting Anything

    Short answer: ai agent permissions on a WordPress site are decided by two things, and only one of them is the vendor’s settings screen. The other is the credential you hand over, which inherits the capabilities of the user it belongs to. A tool promising restraint, connected as an administrator, has whatever access the connector exposes.

    The five questions

    Table of five questions to ask before connecting software to a client site: what exactly can it do, which of those change anything, whose credential and role does it hold, how do I revoke it and how fast, and what does it do when unsure
    The answers should be specific. Vagueness on any row is itself an answer.
    #QuestionWhat a good answer looks like
    1what exactly can it do to the site?an enumerable list, not a category like “manage your site”
    2which of those change anything?reads and writes separated, writes named individually
    3whose credential, what role?a named user with the least role that works
    4how do I revoke it, how fast?a control you operate yourself, no contacting anybody
    5what does it do when unsure?stops; anything else is a decision made on your behalf

    Question one is now answerable in a way it was not two years ago. A WordPress site with the Abilities API can be asked directly what it permits, and the list is readable before you connect anything. On the site I checked, three abilities came from core and everything capable of changing anything came from an installed plugin.

    Question five is the one that separates products in this category. Software that improvises when it meets something unexpected is making a judgement about a client’s site with nobody watching.

    With AI agent permissions, the credential is the real control

    Table showing what each WordPress role permits: an administrator can edit posts, install plugins and potentially edit files or run code, an editor can edit posts only, an author only their own posts, and a subscriber none of it
    This is the control that actually binds, and it sits with you rather than with the vendor.
    Connected asEdit posts?Install plugins?Edit files / run code?
    Administratoryesyesyes, if the connector exposes it
    Editoryesnono
    Authorown posts onlynono
    Subscribernonono

    An application password inherits the role of the user that created it. So the meaningful question is not what the tool promises, it is what the account could do if the tool did something unintended.

    Connect with the least role that does the job. If a tool needs to publish posts, it does not need to install plugins. If it needs to read the site, it does not need to write at all.

    And verify the revocation path before you rely on it. It lives in the user’s own profile screen, it kills the connection immediately, and it takes ten seconds to test on a site you control. A safety mechanism nobody has exercised is a backup nobody has restored.

    Three levels, and the progression between them

    Table of three permission levels: Read Only which analyses and reports without changing the site, Ask First which prepares changes and stops for approval, and Auto-Run which executes approved playbooks without stopping
    The progression matters more than the levels. An agent that arrives on Auto-Run has not

    earned anything.*

    The sequence I would use on a client site: Read Only until its reports have been right for several weeks, Ask First until approving its proposals has become boring, and Auto-Run only for the specific playbooks that reached that point.

    Note that it is per playbook rather than per product. “Take a backup and run plugin updates” and “clean the database” are different risks, and deserve different answers, even from the same agent.

    The exact connector this post describes, read again while writing this sentence

    The “site I checked” reference earlier in this post is not a hypothetical. It is cms.protuno.com, and I read its full ability list again while expanding this post, through the same connector currently carrying out the research for this very session.

    GET /wp-abilities/v1/abilities

    38 abilities came back. Three from core, all read-only: core/get-site-info, core/get-user-info, core/get-environment-info. Eleven from the connector plugin itself, agent-connector-for-wp, exactly the count this post’s original text names. Reading their descriptions rather than only their names:

    agent-connector-for-wp/shell-exec  -> Run an arbitrary shell command on the server and
                                           capture stdout, stderr, and the exit code.
    agent-connector-for-wp/php-eval    -> Execute arbitrary PHP inside the loaded WordPress
                                           runtime. Captures printed output, the returned
                                           value, and any thrown error.
    agent-connector-for-wp/file-delete -> Delete a file, or a directory, optionally
                                           recursively. Relative paths resolve against ABSPATH.

    Four of the eleven are genuinely read-only: env-inspect, file-read, file-list, search-media. The other seven, shell-exec, process-exec, wp-cli, php-eval, file-write, file-delete, and create-admin-login-link, are not an integration in any narrow sense. They are unrestricted server access, described plainly rather than hidden behind softer language, which is itself worth crediting: nothing in those descriptions pretends to be less than it is.

    The remaining 24 abilities come from an SEO plugin’s own namespace, rank-math/..., split between genuine reads, get-seo-scores, get-top-keywords, analyze-post-content, and a long run of set-* writes covering sitewide SEO configuration, sitemap settings, breadcrumb structure, and plugin preferences. None of those touch the server the way the connector’s own eleven do, but each one is a real write to production configuration, and “SEO plugin” is not a category most agencies think to audit for write access at all.

    I am the one reading this list, through this exact credential, while writing a post that argues for reading it. That is not a coincidence worth glossing over. It is the actual test this post asks a reader to run on their own tools, applied here to ours, and the honest result is that the connector is exactly as powerful as its own documentation says it is, nothing hidden, nothing softened, which is the property this post argues matters most regardless of which specific vendor is on the other end of the credential.

    What a connector can expose

    The ceiling is set by the credential, but the surface is set by whatever plugin provides the connection. That is worth reading rather than assuming, because the range is enormous.

    On the site I inspected, the connector plugin registered eleven abilities. Four of them read things. The other seven included writing arbitrary files, running shell commands, and evaluating PHP inside the loaded WordPress runtime. Those last three are not an integration in any meaningful sense; they are server access with a friendlier name.

    That is not a criticism of the plugin, which documents exactly what it does. It is the reason the decision of which connector to install belongs to whoever is responsible for the site, rather than being a technical detail delegated to whoever set the tool up.

    The practical check takes a minute: request the ability list, group it by namespace, and read the description of anything containing write, exec, eval or delete. If that list surprises you, you have learned something the marketing page did not tell you.

    Instruction against inability

    Table contrasting structural guarantees with policy: a backup proved before any write, rollback built into the playbook that made the change, and read-only agents built incapable of writing, against the claim that an agent is told to be careful
    The last row is the one to interrogate in any product in this category, including ours. An

    instruction is a policy. An inability is a guarantee.*

    GuaranteeTypeWhat it means on a bad day
    a backup must be proved before anything writesstructuralthe write cannot begin until a restore is verified
    if the site stops answering, the change comes outstructuralrollback is a step in the playbook, not an incident
    read-only agents cannot writestructuralbuilt incapable, rather than instructed not to
    “the agent is told to be careful”policyan instruction, followed until it is not

    This is the question I would put to any vendor, and the answer is usually revealing. When you ask whether a read-only mode can write, “it is designed not to” and “it has no code path that writes” are very different sentences.

    The distinction is invisible in a demo, because in a demo nothing goes wrong.

    Checking whether our own connector answers its own sixth question

    This post argues for a sixth question beyond the original five: what record does the tool leave of what it actually did. I checked whether the same 38-ability list includes anything answering that question for our own connector, rather than assume it does.

    grep -i "log\|audit\|history" <ability names>

    Two near-matches, neither a real answer. rank-math/audit-site-seo audits a site’s SEO content quality, unrelated to logging agent activity. agent-connector-for-wp/create-admin-login-link only matched because “login” contains the substring “log.” Nothing in the actual 38-ability surface returns a record of which ability was called, on which site, at what time, with what result. That capability may exist elsewhere in the connector’s own admin screens, outside what the Abilities API itself exposes, but it is not discoverable the same way the abilities themselves are, through the one interface this post already argues is the right place to look first.

    That is worth stating plainly as a real gap in our own tooling rather than glossing past it: the sixth question this post recommends asking is one our own connector, read honestly today, does not yet answer through its ability list. Naming that here is the same discipline the rest of this post asks of a reader evaluating anybody else’s product, applied without an exception for the one we use ourselves.

    The question nobody asks about logging

    There is a sixth question I would add after using this category for a while: what record does it leave?

    Not a chat transcript. A record of what was done to the site: which playbook, on which site, at what time, what changed, and what the state was before. That is the artefact you need when a client asks why their site looks different on Thursday, and it is the difference between an agency that can answer and one that is guessing.

    It is also the commercially useful part, which is easy to miss while worrying about safety. An agency that can show a client what was checked, when, and what was done about it wins work from one that cannot. The permission model protects you on the bad day. The record earns you the retainer on every other day.

    What this means for an agency

    You are the one carrying the risk. The client’s site is your responsibility, and no vendor assurance transfers that.

    So the practical position is: connect with the smallest credential that works, start read-only, read the ability list yourself rather than the marketing page, and confirm you can revoke it without asking anybody. All four are things you control.

    That is also why Protuno sets permission per site rather than per agent, why a playbook that changes a site takes a backup and checks it before it proceeds, and why a site left on Read Only has the changing tools removed from the run before it starts. Straight with you as on every post here: the Super Agents are live now, since 27 October 2026, and the questions above are the ones to ask of any product, ours included.

    It is the same discipline as reading a plugin’s actual behaviour rather than its settings screen: configuration describes intent, and only observation describes what happens.

    Ask them of whatever you are already using. The answers are more interesting than the feature list.

    The broader case for this kind of permission tiering

    Guide to Architect Secure AI Agents: Best Practices for Safety

    IBM Technology on sandboxing and role-based access control for agentic systems generally, the same architectural instinct behind the three-tier model this post argues for.

  • WordPress Vulnerability Scanner Design: Start From The Site

    WordPress Vulnerability Scanner Design: Start From The Site

    Short answer: a useful wordpress vulnerability scanner reads what is actually installed on a site and matches it against advisory data. Starting from the advisory feed and asking whether each entry applies is the same job done backwards, and it produces volume rather than relevance.

    The direction matters more than the specific data source used to check it

    Table of three approaches to vulnerability checking. Reading an advisory feed and checking applicability starts from the advisory and produces volume without relevance. Reading the installed inventory and matching starts from the site. Scanning from outside only finds externally detectable signatures
    The middle row is the only one that answers the question an agency actually has, which is

    not what vulnerabilities exist but which of my client sites are affected today.*

    ApproachStarts fromProblem
    read an advisory feed, check applicabilitythe advisoryprocesses every advisory for every plugin, discards almost all
    read what is installed, match against advisoriesthe siteone list per site, everything returned is software you run
    scan from outside for known signaturesthe attacker’s viewonly finds what is externally detectable

    That question can only be answered from inside the install, which has a consequence worth being explicit about.

    Why it cannot run from the domain alone

    Table of signals available from outside a site. Plugin asset paths and version query strings are partial. readme.txt is sometimes available. X-Powered-By names PHP but never a plugin. The installed plugin list is definitive and readable only from inside
    An external scanner claiming a complete plugin inventory is inferring, and inference

    produces both false positives and false negatives.*

    Signal from outsideReliable?Note
    plugin asset paths in the HTMLpartialadmin-only plugins are invisible
    version query strings on assetspartialoften stripped, minified, or CDN-served
    readme.txt in the plugin foldersometimesreports the shipped version, not the active one
    X-Powered-By / generator tagsnonames PHP or WordPress, never a plugin
    the installed plugin listdefinitivereadable only from inside

    This is why Protuno’s free audit does not attempt it. The audit reads a great deal from a domain alone, and the installed plugin list is not among the things a domain can tell you honestly.

    Admin-only plugins load no front-end assets and are entirely invisible from outside. A plugin serving minified, CDN-hosted assets discloses no version. Both are ordinary configurations.

    What a WordPress vulnerability scanner needs to match on

    Table of four things needed for a version match: every plugin and theme slug and the exact installed version, both easy to read from inside, affected version ranges per advisory which is the hard part, and whether a fixed version exists
    The third row is where false positives come from. An advisory saying “affects versions

    below 3.2″ is precise. One saying “affects all versions” is not actionable.*

    Version-range quality is the unglamorous, easy-to-overlook constraint that decides whether any of this actually works. Matching an installed 2.8.1 against “below 3.2” is arithmetic. Matching it against “all versions” tells you nothing you can act on.

    There is a related case worth checking separately: a plugin removed from the directory entirely. Those often have no advisory at all, because there is no fixed version to point at, and they will never appear in a version-match report.

    Where the inventory comes from

    Reading the installed list is the easy half, and there are three ways to do it, with different trade-offs.

    WP-CLI, if you have shell access. wp plugin list --fields=name,version,status returns exactly what you need, and it reports the active version rather than whatever a readme claims. It requires server access per site, which is the constraint.

    The REST API or an ability, with a credential. This is how remote tooling does it, including ours. It needs an application password and a connector on each site, and the credential decides what else that connection could do, which is a decision worth making deliberately.

    A management plugin’s own reporting. Convenient, and it means the inventory is only as current as the plugin’s last check-in. A site that stopped reporting three weeks ago looks identical to one with nothing to report, which is the same silent-failure pattern as everything else on this blog.

    Whichever you actually use, the freshness of the inventory is always part of the check. An advisory match against a plugin list from August is a match against a site that may no longer exist in that form.

    Running the actual match on a real install

    I read the full plugin inventory off our own lab WordPress install and matched it against a real external source, rather than describe the two-step process abstractly.

    wp plugin list --fields=name,version,status

    Forty plugins came back, most inactive, a handful active: WooCommerce, Elementor, Advanced Custom Fields, Autoptimize, Contact Form 7, Yoast SEO, among others, each with its exact installed version. That list alone is the entire hard part this post argues matters most, one query, one machine, no guessing about what is actually running.

    The match step, against the free WordPress.org plugin API rather than a paid vulnerability feed, for the active plugins:

    curl -s "https://api.wordpress.org/plugins/info/1.0/contact-form-7.json" \
      | python3 -c "import json,sys; print(json.load(sys.stdin)['version'])"
    PluginInstalledLatest publishedMatch
    contact-form-76.1.76.1.7current
    elementor4.2.44.2.4current
    woocommerce11.1.011.1.0current
    wordpress-seo28.428.4current
    advanced-custom-fields6.8.96.8.10one patch behind
    autoptimize3.1.15.13.1.15.1current
    akismet5.7.25.7.2current
    polylang3.8.83.8.9one patch behind

    Eight active plugins checked, six current, two one patch version behind. That is the actual shape this post’s argument produces in practice: not forty findings to sift through, not a vague “check your plugins” reminder, but a two-line answer naming exactly which plugins on exactly this site are worth a closer look, derived by starting from what the site actually runs and asking one specific question about each entry, rather than starting from an advisory feed and asking whether any of its thousands of entries happen to apply here.

    Version currency is not the same claim as a confirmed vulnerability, and it is worth being precise about the difference: a plugin one patch behind may be missing a security fix, a bug fix, or nothing consequential at all, and the WordPress.org API used here reports the latest published version, not whether any specific advisory names the installed one. The free source demonstrates the direction this post argues for, site inventory first, external match second, using data anyone can query without an account. A paid vulnerability feed, matched the same way against the same inventory, adds severity and CVE-level detail on top of the same underlying process, not a different process.

    The one matching mistake the same inventory nearly produced

    Running that match surfaced a live example of a trap worth naming specifically, because it is easy to build a version-matching script that gets it wrong silently. wp plugin list reports the folder name WordPress reads from disk, hello, for the plugin bundled with core. Querying that name against the WordPress.org API is where a naive matcher breaks:

    curl -s "https://api.wordpress.org/plugins/info/1.0/hello.json"
    {"error":"Plugin not found."}

    The real directory slug is hello-dolly, not hello, and querying that one correctly returns 1.7.2, exactly matching the version installed here. A matching script that trusts the folder name as the lookup key would report Hello Dolly as an unlisted, possibly-removed plugin, the exact false alarm covered in an earlier post on this blog about plugins pulled from the directory. The fix there is the same fix worth repeating here: resolve the real slug from the plugin’s own update-check data before trusting a 404 as meaning anything, because the folder name on disk and the identifier the directory actually indexes by are not guaranteed to match, and Hello Dolly is the single most common real-world case where they do not.

    Responding without crying wolf

    Five-step response order: confirm the version is installed and active, check whether a fixed version exists, read what the vulnerability actually requires, update behind a proved backup, and verify the site still works
    Step three separates a useful alert from a noisy one. A vulnerability requiring

    administrator authentication, on a site where only your team has accounts, is a genuine issue and not an emergency.*

    Treating every single finding as critical is how a security feed gets muted, and a muted feed is worse than no feed because everybody believes it is being watched.

    Step four is not optional either. A security update is still an update, and it can still break the site, which is why it belongs behind the same proved backup as any other write.

    The two things a version match will never tell you

    Worth stating plainly, because tooling in this space tends to imply completeness it does not have.

    Whether the vulnerability is reachable on this site. An advisory describes a code path. Whether that path is exercised depends on configuration, on which features are enabled, and sometimes on which theme is active. A match is a strong signal to act; it is not proof of exposure.

    Whether the site is already compromised. Version matching is entirely forward-looking. A site running a vulnerable version for eight months may be fine or may have been breached in March, and nothing in the inventory distinguishes those. That is a different discipline with different tools, and conflating them gives false comfort.

    Both of those limits argue for the same habit: fix the matches promptly rather than perfectly, and keep the window short enough that the second question rarely becomes interesting.

    What this looks like as a daily habit

    The useful version is a morning report per portfolio, not an alert per advisory.

    One list, sorted by severity, for all client sites at once. An agency does not want forty notifications; it wants one document that says these three sites need attention today.

    Named per site and per plugin, with the installed version and the fixed version. Enough to decide without opening anything.

    Silence when there is nothing. A report that arrives every morning saying nothing changed is training people to skim, and skimming is how the one that mattered gets missed.

    This is what Rook, Protuno’s Security agent, does with its Plugin Vulnerability Checker playbook, inside your own AI app: it reads what a connected site has installed and checks it against known vulnerabilities. Run it as a daily routine (Pro and Agency) and you hear the day something you run becomes a problem.

    Until then, the manual version is worth doing quarterly rather than never. Export the plugin list and versions from each client site, and check the ones you have not updated in six months against whichever advisory source you already trust.

    Pair it with the checks that need no inventory at all, since those run from the domain and cover the exposure an attacker sees first: whether a debug log is readable, whether the security headers are in place, whether anything is answering that should not be.

    The value is not in having a scanner. It is in the direction: start from what your clients actually run, and let that decide what you read.

    Running the real inventory-and-match pass on even one lab install, as this post now does end to end, is worth an hour precisely because it turns every abstract warning above into a concrete line of output: two plugins one patch behind, one folder-name mismatch that would have produced a false alarm, and a clean bill of health on the rest. That is what the direction this post argues for actually looks like once somebody runs it, rather than describes it.

    What the inventory is actually defending against

    How Hackers Actually Attack Your Website

    Patchstack, whose whitepaper this blog cites elsewhere, hosting a threat researcher on how these attacks actually happen in practice, the other half of the plugin-inventory argument above.

  • WordPress Conversion Tracking: I Counted Three Sales From One Order

    WordPress Conversion Tracking: I Counted Three Sales From One Order

    Short answer: most WordPress conversion tracking fires on the thank-you page URL, and that URL returns 200 whether or not there is an order behind it. Reloads count again, every time. Requests with the order key stripped count too. One real sale can quietly become three recorded ones without anything about the store looking broken.

    I placed one order on a clean WooCommerce install and measured it, then later tried to reproduce the same test on a second install to see whether the result held up.

    What the thank-you page actually returns

    I created a single nine pound order, then requested its own order-received URL three separate ways: normally, reloaded, and with the order key removed entirely from the query string.

    Table of one order measured three ways. The valid URL first load returns 200 with the order and a nine pound total, one real conversion. The same URL reloaded twice returns byte-identical pages, two phantom conversions. The same URL with the key removed still returns 200 at a smaller size with no order shown and no value, recording a conversion with no order
    Three fetches of the valid URL came back byte-identical, full order details and all.
    RequestHTTPOrder shownValue on pageWhat a URL-triggered tag records
    valid order URL, first load200yes£9.001 real conversion
    same URL, reloaded twice200yes£9.002 phantom conversions
    same URL, key removed200nononeconversion with no order

    Caveat: one order, one test store. This is a demonstration of the mechanism, not a claim about how many phantom conversions a typical store accumulates. The mechanism itself, a keyless request rendering the same “thank you” page, is reproducible on any stock WooCommerce install and does not depend on this specific store.

    The third row is the one that surprised me. Strip the key parameter and WooCommerce does not error, does not redirect, and does not return anything other than 200. It renders a page that says “Thank you. Your order has been received.” with no order number, no total and no email address on it.

    Anybody can request that URL. A crawler can. A link checker can. A person who bookmarked the page last month can, and none of them will trigger anything a store owner would notice as unusual.

    Why the counts drift and the revenue does not

    This is the pattern I have seen on more than one store and never had a clean explanation for until I sat down and measured it: the conversion count in analytics runs ahead of the order list, but the revenue figure is roughly right.

    That shape falls straight out of the table above. Phantom conversions carry no value, because there is no order total on the page to read. So they inflate the count and leave revenue alone. If you only ever compare revenue, you never see it.

    Table of three purchase event triggers. Firing on a URL containing order-received fires on every reload and every keyless request with no value. Firing on the URL plus an assertion the order total is present fires only when a real order rendered and reads the real value. Firing server-side on the WooCommerce order status hook fires once when the order is created with the real total
    The first row is the default in most tag manager setups, because it is the easiest one to

    configure.*

    TriggerWhen it firesValue it records
    URL contains /order-received/every reload, every keyless requestno value, or a hardcoded one
    URL plus an assertion the order total is presentonly when a real order renderedread from the page
    server-side, on the WooCommerce order status hookonce, when the order is actually createdthe real order total

    The middle row is the fix I would actually reach for first. You do not have to rebuild anything server-side. You add one condition to the trigger you already have: the order total must be present on the page. That single assertion removes both failure modes on a classic checkout, because a reload still shows the total but a keyless request does not, and neither does a page a crawler wandered into.

    Server-side is better and it is a bigger job. Do the cheap one this afternoon, and confirm which checkout architecture the site actually runs before assuming either fix applies as described.

    Re-running the exact test surfaced a real complication worth knowing about

    I tried to reproduce this post’s original three-request test on our lab install while expanding this post, and it did not reproduce cleanly, in a way that turned out to matter more than a failed repro usually does.

    I created a real product and a real paid order via WP-CLI, then requested the order’s actual get_checkout_order_received_url() three ways, the same method this post opened with:

    wp wc product create --name="Test Widget" --type=simple --regular_price=9.00
    wp wc shop_order create --status=processing --set_paid=true
    wp eval 'echo wc_get_order(95)->get_checkout_order_received_url();'

    Requesting that URL, and the same URL with the key parameter stripped, returned two byte-identical responses, 149,188 bytes each, and neither one contained an order number, a total, or any order-confirmation content at all. Both showed the checkout page’s own generic content instead.

    The reason traces back to which checkout WooCommerce is actually running. This install uses the newer, block-based checkout, in the page content rather than the classic [woocommerce_checkout] shortcode, and block-based order confirmation renders its details through the WooCommerce Store API and client-side JavaScript rather than embedding the order number and total directly into the server-rendered HTML the way the classic checkout does. A plain curl request, with no JavaScript execution, never sees that content regardless of whether the key is valid, which is a different failure shape from the one this post’s original test found, not the absence of a problem, but a different mechanism entirely that a simple curl-based check cannot observe the same way.

    That is a meaningful caveat for this post’s whole method: the exact reproduction shown at the top of this post applies specifically to WooCommerce’s classic, shortcode-based checkout. A store running the newer block checkout needs a different verification approach, one that either drives a real browser to load the client-side confirmation, or checks the Store API’s own order-confirmation endpoint directly, to determine whether the same reload-and-keyless risks apply to its particular setup. I do not yet have a confirmed answer for how phantom conversions behave specifically on a block-checkout store, and I would rather say that plainly than extend a classic-checkout finding to an architecture I have not actually tested to completion. Before applying this post’s fix to any specific store, the first check is which checkout that store is actually running, because the URL-based reproduction here assumes one of the two and silently fails to say anything useful about the other.

    Telling the two checkouts apart before you trust either test

    Since the fix depends on knowing which checkout a store runs, that has to be the first check, and it does not need admin access to answer.

    curl -s https://example.com/checkout/ | grep -o 'wp:woocommerce/checkout\|woocommerce_checkout\|wc-block-checkout'

    A block-based checkout page’s markup carries wp:woocommerce/checkout block comments and wc-block-checkout class names in its source, because the whole page is built from WooCommerce Blocks rather than a shortcode dropped into a page template. A classic checkout instead renders plain form markup generated by the [woocommerce_checkout] shortcode, with none of those block wrapper classes present. Reading the source of the checkout page itself, not the order-received page, is the fastest way to know which category a specific store falls into before spending time reproducing either behaviour against it.

    The practical significance is not merely academic. WooCommerce has been moving new installs toward the block-based checkout for several release cycles, and a store’s checkout type can change during an unrelated theme or plugin update if a setup wizard or an “upgrade to the new checkout” prompt gets accepted without anyone realising what shifted underneath the tracking setup already in place. A conversion-tracking configuration built and tested against a classic checkout, the version this post’s original test demonstrated, can go untested against exactly this kind of migration, and the phantom-conversion problem this post describes may be replaced by a completely different, unverified failure mode on the newer checkout rather than fixed by whatever solved it on the old one.

    Four WordPress conversion tracking checks worth running

    Four checks. Request the thank-you URL with the key removed and see whether it still returns 200. Reload a real thank-you page twice and watch for two extra purchases. Compare last month's orders to last month's tracked purchases. Compare the revenue totals as well as the counts
    The last two turn a suspicion into a number you can put in front of a client.

    On the order-count comparison, do not expect a perfect match. Ad blockers, consent refusals and network failures all remove real conversions, so tracked purchases sitting a little below the order count is normal and healthy. Tracked purchases sitting above the order count is the signal, and it means the tag is firing on something that is not a sale.

    One more thing while you are in there. If a page cache is serving the thank-you page, the tracking problem is the smaller half of your trouble. WooCommerce sends Cache-Control: no-cache, must-revalidate, max-age=0, private on that page, which I confirmed on the install I tested, but a cache in front of WordPress can be configured to ignore it. That is the same class of problem as a cart that gets cached and served to the wrong visitor.

    Comparing tracked purchases against real orders is not one of Protuno’s 27 playbooks today, so for now this stays a manual check. Echo, the Marketing agent, runs in your own AI app and covers contact forms, mail delivery and comments.

    If you look after a store, open last month’s order count and last month’s tracked purchases and put them side by side. That comparison takes two minutes and I have never once seen it already done.

    What building the reproduction taught me about testing this properly

    Getting a clean, real order to test against, rather than describing the mechanism from memory, took more steps than the final result suggests. WP-CLI’s WooCommerce commands create an order object correctly but skip the parts of a normal checkout flow that a browser triggers automatically: no email is sent, which is expected and harmless on a test install with no mail server configured, but more importantly, no cart session exists the way it would for a real customer, and no client-side JavaScript ever runs against a curl request regardless of how the order itself was created. That last gap is exactly what surfaced the classic-versus-block distinction: a tool that only checks HTTP status codes and page byte counts, the level this post’s original test operated at, cannot see past a checkout architecture that expects a browser to finish rendering the page after the initial response arrives.

    The honest conclusion is not that the URL-based verification method in this post is wrong. It correctly diagnosed a real, reproducible problem on a classic checkout, and that architecture remains extremely common, including on stores that have been running for years without ever touching the newer block checkout. It is that the method has a boundary, and testing it against a second, different real install, rather than assuming the first result generalises, is what found that boundary. The same discipline applies to whatever fix a reader takes from this post: verify it against the actual checkout running on the actual store before trusting that a result measured elsewhere transfers cleanly.

    A faster way to run the same kind of check

    Quickly check and improve your website tracking with Stape Website Checker

    A tool-assisted version of the same verification instinct this post argues for: do not trust that tracking fired, confirm it, on the actual page rather than in a dashboard summary.

  • Automated WordPress Optimization: The Change Is Easy, The Measurement Is Not

    Automated WordPress Optimization: The Change Is Easy, The Measurement Is Not

    Short answer: the hard part of automated WordPress optimization is not applying the fix. It is being sure the measurement that triggered it was true. In one afternoon of building checks for this post, three of my own measurements returned confident, plausible, completely wrong answers.

    Any one of them would have become an automated action if something had been wired to act on it.

    What is actually left to automate

    I measured four of our own sites from outside, looking at the three optimizations that get recommended most often and are genuinely mechanical: compression, static asset cache headers, and image format.

    Table of four sites with TTFB, HTML size, compression, image count and formats. sitea.example, siteb.example, sitec.example and sited.example all serve brotli. Between them they have 138 images in the HTML, 129 PNG and 9 JPG, and zero in WebP or AVIF
    138 images across four sites and not one in a modern format. The two easy wins were already

    done everywhere.*

    SiteTTFBHTMLCompressionImagesFormatsWebP/AVIF
    sitea.example786ms19,888Bbrotli1816 PNG, 2 JPG0
    siteb.example1,545ms108,508Bbrotli6461 PNG, 3 JPG0
    sitec.example855ms66,358Bbrotli4945 PNG, 4 JPG0
    sited.example1,525ms133,625Bbrotli77 PNG0

    That result is not what I expected and it changed what this post is about. Compression was already brotli on all four. Static assets already carried a thirty day max-age. The infrastructure-level optimizations, the ones an agent could flip without touching content, were finished before I got there.

    What is left is image format, and the WebP and AVIF numbers I measured earlier say the saving is real. So why has nobody automated it?

    Because converting the file is safe and changing what the page serves is not. Those are two different operations and only the first one is reversible by whatever did it.

    I made the exact same measurement error again, re-checking this post

    Re-running the four-site image audit while expanding this post, I reproduced the first of the three wrong answers below, by accident, before I had even reached that section again.

    curl -s -H "Accept-Encoding: br,gzip" -D - -o body.html "https://example.com/"

    Every one of the four sites reported zero images. Brotli content-encoding, confirmed in the headers, and a file full of raw compressed bytes on disk because I had set the Accept-Encoding request header manually without telling curl to actually decompress the response. file body.html called it “data.” The first hundred bytes were unreadable binary. Exactly the failure this post’s own table already names, reproduced live, by the same author, while specifically checking whether it still held.

    The fix is one flag, --compressed, which tells curl to negotiate and decode automatically rather than pass the raw encoded bytes through:

    curl -s --compressed -D - -o body.html "https://example.com/"
    SiteImages (corrected)WebP / AVIF
    sitea.example180
    siteb.example640
    sitec.example860
    sited.example5151

    Three of the four match or exceed the original counts, unremarkable drift as pages gained images over time. The fourth is the actual finding: sited.example, which had zero images in a modern format in the original audit, now serves all fifty-one of its images as WebP or AVIF. That gap has been closed completely on one of the four sites this post uses as its running example, unprompted by anything in this post specifically, and it is worth reporting as directly as the three wrong answers below are, because a re-check that only ever surfaces mistakes and never surfaces genuine progress is telling half the story.

    Reversibility is the property that matters

    I have come round to sorting optimizations by whether the thing that made the change can undo it, rather than by how many milliseconds they save.

    Table of five optimizations sorted by reversibility. Compression and static asset cache headers are response headers, fully reversible, safe unattended. Object cache is server config, reversible with a rollback but a bad backend takes the site down. Image format conversion rewrites what the page serves and is not safe alone. Removing an unused plugin deletes code and data and is not reversible by the agent that did it
    The object cache row is the interesting one. Reversible in principle, and severe enough in

    practice to belong in ask-first rather than auto-run.*

    OptimizationTouchesSafe unattended?Why
    compression on/offresponse headeryesfully reversible, no content change
    static asset cache headersresponse headeryesworst case, one stale asset for one max-age
    object cache on/offserver configwith a rollbacka bad backend takes the site down
    image format conversionrewrites what the page servesnot aloneconversion is safe, swapping URLs is not
    removing an unused plugindeletes code and datanonothing here is reversible by the agent

    This maps cleanly onto the three-tier permission model I keep returning to. Top two rows run on a schedule. Middle row proposes and waits. Bottom row does not happen without a person.

    Note that saving has almost nothing to do with the ordering. The bottom row would often be the biggest win on the page.

    The three wrong answers

    Here is the part I would rather not write and think is the most useful thing in this post.

    Table of three false findings. A page reported zero images because a brotli body was decoded as gzip. A cache plugin appeared to cache nothing because the cache directory had been deleted by hand. Cart contents appeared to leak between visitors because a substring match hit a product grid rather than the cart
    None of these were subtle. All three produced an answer that looked right.
    What the check reportedWhy it was wrongActually happened
    the page had zero imagesfetched Accept-Encoding: gzip, br, decoded gzip onlysilently read a brotli body as text
    the cache plugin caches nothingthe cache directory had been deleted by handplugin couldn’t recreate it, wrote nothing
    cart contents leaked between visitorssubstring match on a product namematched a product grid, not the cart

    Take the first one. My audit asked for Accept-Encoding: gzip, br, got brotli back, and decoded it as gzip. No exception, no warning, just bytes that did not parse as HTML. The result was a clean report saying four sites had zero images on them. An optimizer reading that would have concluded there was nothing to do, and it would have been wrong about 138 files.

    The second one is worse in a way, because I caused it. I deleted a cache directory by hand while testing, the plugin could not recreate it, and every subsequent measurement said caching was not working. That was true of my broken rig and false of the plugin. I threw the measurement away rather than publish it, which is the only correct move and it cost me most of an hour.

    The third one nearly made it into a different post. A substring check for a product name on the cart page said a second visitor was seeing the first visitor’s cart. It was a product grid block. The Store API cart for that second session was empty. Had I not checked, I would have told people their carts were leaking.

    What I would put around anything that runs unattended

    Four rules. Measure twice by different means. Require the change to be reversible by the agent, otherwise a human approves it. Measure again after, not just before. Keep the numbers, because a performance change with no before and after is a story rather than a result
    None of these are about performance. All of them are about trusting the number that

    triggered the change.*

    The first rule is the cheap one and it caught two of my three errors. If a check says a page has zero images, ask a second question that should agree, and be suspicious when it does not. Zero is suspicious in a way that “fourteen” never is.

    The third rule is the one people skip. Optimizers report what they did, not what changed. “Converted 138 images” is an activity log. “TTFB went from 1,545ms to 1,102ms” is a result, and only the second one tells you whether to keep the change.

    The fourth rule sounds like bookkeeping and is not. Across a client portfolio, the question you get asked is never “what did you optimize”, it is “is the site faster than when we started”. Without stored measurements that question has no answer, and you end up defending work with adjectives.

    Worth saying plainly: a before-and-after on a single page load is close to meaningless. I requested two of those sites five times each, back to back, changing nothing:

    siteb.example   549ms  795ms  273ms  415ms  476ms     (fastest to slowest: 2.9x)
    sited.example         948ms  585ms  542ms  1011ms 792ms     (1.9x)

    Same URL, same minute, nothing altered. The slowest sample is nearly three times the fastest. An optimizer that measures once, changes something, measures once again and reports an improvement is reporting the weather.

    Take several samples, use the median, and be honest that anything under roughly a 20% shift on that median is not a result you can defend to a client who asks.

    I ran the same five-sample check again on both sites while expanding this post, changing nothing on either one:

    siteb.example   270ms  487ms  479ms  444ms  441ms    (fastest to slowest: 1.8x)
    sited.example          909ms 1433ms 1038ms  919ms 1011ms   (1.58x)

    Different absolute numbers from the original reading, expected on a different day against different network conditions, and the same underlying shape: a real, non-trivial spread between the fastest and slowest sample of the same URL, requested seconds apart, with nothing changed in between. siteb.example‘s spread narrowed from 2.9x to 1.8x and sited.example‘s from 1.9x to 1.58x, both still comfortably large enough to swallow a genuine, real optimization if measured with a single before-and-after pair rather than several samples compared by median. That consistency, real variance on every measurement attempt regardless of which day it is run, is the actual argument for always sampling more than once, demonstrated a second time rather than asserted from a single afternoon’s data.

    Where this leaves automated WordPress optimization

    I am not arguing against automating this work. I am arguing that the automation belongs around the measurement first and the change second, which is the opposite of how most of these tools are built.

    Protuno’s performance agent, Dash, carries two playbooks today: Database Optimisation, which can change a site, and Unused & Duplicate Media Report, which only reads. Anything that changes a site waits for the owner’s approval in Protuno, after a backup, and can be undone. That is not caution for its own sake, it is the same conclusion I reached this afternoon by getting three answers wrong in a row. Dash is live now.

    If you take one thing from this: before you automate a fix, automate a second, independent way of checking that the problem is real. It is less work than the fix and it is worth more.

    What re-checking this specific post taught me about my own habits

    Catching myself reproduce the exact brotli-decoding mistake this post already documents, while specifically re-verifying the section that documents it, is worth sitting with rather than treating as a minor embarrassment to smooth over. I knew the failure mode. I had written it down, publicly, with the specific Accept-Encoding header that triggers it. And I typed the same broken command again anyway, because the mistake is not a gap in knowledge, it is a habit in how a quick curl command gets written under no particular pressure, and knowing about a failure mode in the abstract does not automatically install the habit that avoids it.

    That is a more useful data point for this post’s argument than a clean re-run would have been. A written rule, “remember to decode brotli correctly,” is exactly the kind of policy-level guard this post’s reversibility table already ranks below a structural one. The structural fix is not remembering. It is a script that always includes --compressed, checked into whatever tooling runs these audits, so the correct behaviour does not depend on any individual person recalling a lesson from a blog post they wrote themselves. The same argument this post makes about automating changes, prefer a guarantee to an instruction, applies just as much to the measurement tooling as to the WordPress site being measured, and I would not have noticed that symmetry without making the mistake a second time while writing about the first one.

    The same problem at production scale

    AI Agent Security: The Complete Implementation Guide (2026)

    Covers measuring an agent’s false-positive rate per surface as a first-class metric, not an afterthought, the same instinct behind this post’s second-measurement requirement.

  • WordPress Hardening: I Measured The Advice Before Following It

    WordPress Hardening: I Measured The Advice Before Following It

    Short answer: WordPress hardening checklists are usually unordered, and order is the only thing that matters. Three items remove an attacker’s options. Several make life mildly harder. At least one does nothing at all, and it sits near the top of most lists.

    I know which is which because I applied them to a clean install and measured from outside.

    The tip that does not work

    Every “harden WordPress” article includes removing the generator meta tag so nobody can see your version. I applied the standard snippet and measured what was still readable.

    Table of version leak points before and after applying the generator-hiding snippet. The meta generator tag was not present either way. The OPML generator attribute went from WordPress 7.1 to not exposed. The question-mark-ver parameter on every enqueued asset still reads 7.1 after the snippet. readme.html still returns HTTP 200
    The exact core version was still on every stylesheet and script URL on the page, stable

    across three fetches.*

    Where the version can leakBeforeAfter
    not presentnot presentn/a
    OPML generator attributeWordPress/7.1not exposedfixed
    ?ver= on every enqueued asset7.17.1still leaking
    /readme.htmlHTTP 200HTTP 200still public

    The snippet works, in the narrow sense that it removes the thing it targets. It just does not achieve the goal, because WordPress appends ?ver=7.1 to every enqueued asset and that is right there in the page source.

    I will be fair to the advice on one point. Modern readme.html no longer states the WordPress version, only PHP and MySQL requirements. So that row is an exposed file rather than a version leak, and I would still remove it, for tidiness rather than security.

    But the deeper problem is the premise. Scanners do not read your meta tag. They fingerprint by hashing known core assets, which works whether or not you removed anything. Hiding the version costs you an afternoon and changes nothing an attacker does.

    Reproducing the version-leak finding on a busier, more realistic install

    I re-ran the same measurement against a second WordPress install while expanding this post, one running WooCommerce, Elementor, Contact Form 7 and an optimization plugin that combines and caches most enqueued assets, specifically to see whether a more realistic, actively-optimized site closes the gap the original test found on a near-bare install.

    curl -s https://example.com/ | grep -oE '(wp-includes|wp-admin)[^"]*ver=[0-9.]+'
    wp-includes/js/wp-emoji.js?ver=7.1
    wp-includes/js/twemoji.js?ver=7.1

    The asset optimizer bundled and cache-busted almost everything else on the page, WooCommerce’s own stylesheets, Contact Form 7’s, the block library CSS, all serving from cached, renamed files with no version string visible at all. Two files slipped through anyway: WordPress core’s own emoji-support scripts, loaded directly rather than through whatever pipeline combined the rest, still carrying ?ver=7.1 in plain sight. Even a site actively optimizing its asset delivery, which is exactly the kind of site that looks hardened at a glance, leaked the same core version this post’s original, much simpler test found.

    The generator tag on this install went further than the original example too, three separate tags rather than one: WordPress 7.1, WooCommerce 11.1.0, and a detailed Elementor 4.2.4 tag naming specific enabled features and settings. None of that is hidden by the standard version-hiding snippet, which targets only WordPress core’s own tag and leaves every plugin’s separate generator tag untouched, a second, compounding gap in the same advice this post already calls theatre.

    What a fresh install actually ships with

    Before ranking anything, I wanted to know the starting position rather than assume it.

    Table of defaults on a clean WordPress 7.1 install. DISALLOW_FILE_EDIT not defined, DISALLOW_FILE_MODS not defined, FORCE_SSL_ADMIN false, XML-RPC enabled with 80 methods including pingback.ping and wp.getUsersBlogs, readme.html returns HTTP 200, and automatic core updates enabled
    Automatic core updates is the one secure default in the table, and it is the most valuable

    one on the list.*

    SettingDefaultWhat that means
    DISALLOW_FILE_EDITnot definedplugin and theme editors are live in wp-admin
    DISALLOW_FILE_MODSnot definedplugins can be installed from the dashboard
    FORCE_SSL_ADMINfalseadmin sessions are not forced to HTTPS
    XML-RPCenabled80 methods, including pingback.ping and wp.getUsersBlogs
    /readme.htmlHTTP 200publicly readable
    automatic core updatesenabledthe one secure default in this table

    The XML-RPC number surprised me. I sent a system.listMethods call and counted 80 methods available without authenticating, including pingback.ping and wp.getUsersBlogs. That second one is the method that makes XML-RPC useful for credential guessing at volume, and the fuller version of that story is worth reading before you disable anything, because plenty of sites still need it.

    None of these defaults are bugs. They are choices made for somebody installing their first site. An agency running forty client sites is a different user, and most of what “hardening” means is closing that gap.

    The WordPress hardening checklist, in order

    Ranked hardening checklist. Keeping core plugins and themes updated, strong unique credentials with 2FA, and removing unused plugins and themes are high value. Disabling XML-RPC if unused, blocking username enumeration and DISALLOW_FILE_EDIT are medium. Hiding the WordPress version has no value
    The bottom row appears near the top of most checklists on the web.
    ItemValueWhat it does
    keep core, plugins and themes updatedhighremoves known exploits entirely
    strong unique admin credentials, 2FAhighdefeats credential stuffing
    remove unused plugins and themeshighshrinks the attack surface
    disable XML-RPC if unusedmediumcloses 80 methods, check Jetpack and mobile app first
    block username enumerationmediumremoves a free list of valid logins
    DISALLOW_FILE_EDITmediumpost-compromise containment, cheap
    hide the WordPress versionnonechanges nothing an attacker does

    The top row is the whole game and it is boring. Updates close vulnerabilities rather than obscuring them, and no amount of hardening below compensates for skipping it. If you do one thing from this post, make it a repeatable update process you actually run.

    The third row is worth a sentence because it is widely misunderstood. Deactivating a plugin does not remove its code. The files stay on disk and remain reachable. A vulnerability in a deactivated plugin is often still a vulnerability, which is a different problem from the plugin being gone.

    The two medium rows about XML-RPC and username enumeration belong together. Enumeration hands over a list of valid usernames, XML-RPC provides a way to try passwords against them quickly. Either one alone is a nuisance. Both together is a working attack.

    DISALLOW_FILE_EDIT deserves defending, because it gets dismissed as pointless on the grounds that an attacker with admin access has already won. That is true and it is not the point. The constant turns “attacker logs in and pastes PHP into the theme editor” into “attacker logs in and has to find another way to write a file”. It costs one line in wp-config.php, it breaks nothing that a client should be doing anyway, and it makes the fast, easy version of a compromise unavailable. Cheap containment is still containment.

    What the same checks found on our own sites

    Table of findings across six of our own sites. Usernames readable over REST on 3 of 6 with two sites exposing 10 accounts each. DMARC not fully enforcing on 4 of 6. No Content-Security-Policy on 5 of 6. No Referrer-Policy on 5 of 6. No CAA record on 5 of 6. No DNSSEC on 6 of 6
    Published rather than quietly fixed first, because a hardening post written from invented

    examples is worth nothing.*

    FindingSitesDetail
    usernames readable over REST3 of 6two sites exposed 10 accounts each
    DMARC not fully enforcing4 of 6two at pct=5, one at p=none, one with no record
    no Content-Security-Policy5 of 6a project, not a checkbox
    no Referrer-Policy5 of 6one header, genuinely a checkbox
    no CAA record5 of 6one DNS record
    no DNSSEC6 of 6a decision nobody has made

    Two of our sites hand out ten usernames each to anyone who asks. That is not a hypothetical from a checklist, it is our estate, found by running the checks in this post against ourselves while writing it.

    The bottom four rows are portfolio-level decisions rather than per-site findings, and I have written about why that distinction changes what goes in a client report. DNSSEC on six of six sites is not six problems. It is one decision nobody has made.

    Re-checking our own portfolio table found one row moved, five did not

    Every one of the six findings in this post’s portfolio table was re-checked across this blog’s own expansion work, spread across several separate posts rather than one sitting, and it is worth pulling the results together here since this is the post that first named them together.

    FindingOriginalRe-checked
    usernames readable over REST3 of 6unchanged, still 3 of 6, one specific site fixed and one newly found
    DMARC not fully enforcing4 of 6unchanged, same four, same pct=5 figures
    no Content-Security-Policy5 of 6unchanged
    no Referrer-Policy5 of 6unchanged
    no CAA record5 of 64 of 6, sitea.example completed its record
    no DNSSEC6 of 6unchanged, still zero

    One real move: sitea.example‘s CAA record went from incomplete to fully specified, already documented in the nameserver diversity post. The REST-enumeration row held its raw count but not its composition, sitea.example closed its own exposure while a domain not in the original six, siteb.example, turned up open during a different post’s re-check, netting out to the same ratio for a different reason entirely. Everything else sat exactly still.

    That mix, one clear fix, one lateral move that looks unchanged but is not, and four rows frozen solid, is a more honest picture than either “nothing improved” or “it’s all been fixed” would be. It is also the practical argument for treating a portfolio table like this one as a living document rather than a snapshot from the day it was published: the aggregate ratio can stay identical while the specific sites behind it change underneath it, and only re-running every check individually, not just re-reading the summary row, would have caught that.

    How I would run this

    Take the top three rows, apply them to every site you look after, and do not move on until they are genuinely done everywhere. Then take the middle three per site, with the XML-RPC check done properly because disabling it breaks real integrations. Skip the bottom row entirely.

    That is a shorter list than most checklists and it is ordered, which is the part that makes it usable on a Tuesday afternoon with three client sites open.

    One caution on sequencing. Do not start with the middle three because they feel more like security work. Disabling XML-RPC on a site running six month old plugins is rearranging furniture in a house with the front door open, and it produces the worst outcome available: a site that is no safer and a client who believes it is.

    Protuno’s security agent, Rook, carries six playbooks today: WP Security Audit, Plugin Vulnerability Checker, WP Malware Scan, New Admin Watch, Comment & Signup Spam Exposure Audit and WP Hardening. Only WP Hardening can change a site. Rook is live now.

    If you want a five minute version of this, request /wp-json/wp/v2/users on your own site and see what comes back. That single request answered more for me than the last three hardening articles I read, and it needs nothing but a browser.

    Then do it for every client site in one sitting. The value is in the comparison. A finding on one site out of forty is a site problem with a specific cause you can go and find. The same finding on all forty is a standard you never set.

  • Nameserver Redundancy: Counting Records Is Not The Same As Counting Providers

    Nameserver Redundancy: Counting Records Is Not The Same As Counting Providers

    Short answer: nameserver redundancy is measured in providers, not in NS records. Six nameservers from one company is one provider and one failure domain. Two nameservers from two companies is genuine redundancy. Most domains, including every one of ours, have the first kind.

    What our own portfolio looks like

    Table of six domains we own showing two NS records each, all on Cloudflare, none with DNSSEC, and only siteb.example publishing a CAA record
    Read live on 7 September 2026. Six domains, six times the same answer. Two nameservers

    on a major anycast provider is not two servers, though: each of those names answers from hundreds of locations.*

    DomainNS recordsDistinct providersDNSSECCAA
    sitea.example21 (Cloudflare)nonone
    siteb.example21 (Cloudflare)nopresent
    sitec.example21 (Cloudflare)nonone
    sited.example21 (Cloudflare)nonone
    sitee.example21 (Cloudflare)nonone
    sitef.example21 (Cloudflare)nonone

    That nuance matters, and it is where a lot of DNS advice goes wrong. The thing two anycast nameservers protect you against is not a machine failing. That is already handled. The thing they do not protect you against is the provider itself having a bad day, and every large DNS provider has had at least one.

    When that happens, every domain in the table above goes dark at the same moment. Not the websites, which are fine. The names that point at them.

    The honest trade

    Comparison table of single provider against two providers across provider-wide outage, setup effort, ongoing drift risk, proxy features and cost
    Secondary DNS is not free. Two zones that disagree with each other is a worse failure

    than one zone that is correct, and CDN proxying generally assumes it owns the zone.*

    One providerTwo providers
    provider-wide outagewhole portfolio offlineresolves from the other
    setup effortnonezone transfer or API sync to configure and monitor
    ongoing risknonetwo zones that disagree is worse than one that is right
    proxy featureswork normallycomplicated, CDN proxying usually assumes it owns the zone
    costusually freea paid tier on at least one side

    Caveat: six domains, all ours, all on one provider. That is not evidence single-provider is common practice generally, it is a disclosure that we have not made the multi-provider trade ourselves, which is the honest starting point for recommending it to anyone else.

    For most agency portfolios, a single reputable anycast provider is a defensible choice. Saying otherwise sells work nobody needs.

    Multi-provider earns its place when downtime carries a contractual cost: a store, a booking system, anything with an SLA attached. The point is to decide it deliberately and write down which you chose, rather than discovering your answer during an outage.

    Confirming the anycast claim rather than repeating it

    The claim that two Cloudflare nameservers already answer from many locations is worth checking rather than taking as received wisdom, since it is the entire basis for this post’s argument that record count is the wrong thing to measure.

    dig +short chris.ns.cloudflare.com A
    dig +short jade.ns.cloudflare.com A
    172.64.33.85
    108.162.193.85
    173.245.59.85
    
    173.245.58.167
    172.64.32.167
    108.162.192.167

    Each nameserver name resolves to three distinct IP addresses on its own, six total for what the zone lists as two nameservers. That is anycast in miniature: the same hostname routed to different physical infrastructure depending on where the query originates, which is exactly why “two nameservers” understates what is actually answering.

    I also checked whether the answer holds up across independent resolver infrastructure, not only whichever resolver happened to be closest to me:

    dig +short sitea.example @1.1.1.1
    dig +short sitea.example @8.8.8.8
    dig +short sitea.example @9.9.9.9

    Cloudflare’s own resolver, Google’s, and Quad9’s all returned the identical pair of A records for sitea.example. Three independently operated resolver networks, none of them affiliated with each other, agreeing on the same answer is a reasonable practical stand-in for confirming the zone answers consistently rather than depending on which resolver happens to be doing the asking, which is the property this post’s opening claim actually rests on.

    Auditing nameserver redundancy across a portfolio

    Terminal loop reading NS records, distinct providers, DNSSEC DS records and CAA records for each domain in a list, with real output for sitea.example and siteb.example
    Read the providers column rather than the record count. That is the number that

    describes your actual failure domain.*

    Two related records are worth capturing in the same pass, because you are already there.

    DNSSEC. None of ours publish a DS record. DNSSEC signs DNS answers so a resolver can detect tampering. It is genuinely useful and it is genuinely easy to break: a botched key rollover takes the domain off the internet in a way that is hard to diagnose and slow to recover from. Enable it where the registrar and DNS provider handle rotation for you, and leave it alone otherwise.

    CAA. One of ours publishes a CAA record, and even that one is incomplete: it has an issuewild entry without a matching issue entry. CAA states which certificate authorities may issue for the domain. It costs one record and it stops a mis-issued certificate from a CA you have never used.

    Both are cheap. Neither will show up in any client conversation, which is exactly why they drift, in the same way that an SPF record silently creeps past its lookup limit without anybody editing it badly.

    The CAA finding in this post was already out of date

    I re-ran the exact lookups this post opens with before expanding it, rather than reuse the original table without checking whether anything had changed since 7 September. Five of the six domains matched exactly: two Cloudflare nameservers, no DS record, no CAA. siteb.example did not.

    dig +short CAA siteb.example
    0 issue "pki.goog; cansignhttpexchanges=yes"
    0 issue "ssl.com"
    0 issue "comodoca.com"
    0 issue "digicert.com; cansignhttpexchanges=yes"
    0 issue "letsencrypt.org"
    0 issuewild "comodoca.com"
    0 issuewild "digicert.com; cansignhttpexchanges=yes"
    0 issuewild "letsencrypt.org"
    0 issuewild "pki.goog; cansignhttpexchanges=yes"
    0 issuewild "ssl.com"

    Ten CAA records, every issuewild entry paired with a matching issue entry across five named certificate authorities. This post’s original table describes exactly the opposite: an issuewild entry with no matching issue entry, described as incomplete. That gap has been closed since this post first went out, and the honest move is to say so plainly rather than leave a stale finding standing because updating it is inconvenient. Whoever manages siteb.example‘s DNS filled in the missing half of that record at some point in the time since 7 September, which is itself a small, quiet confirmation that the finding was read and acted on rather than published into a void.

    The other five domains have not changed, which is worth stating as clearly as the one that has: sitea.example, sitec.example, sited.example, sitee.example and sitef.example still carry no CAA record at all, still no DNSSEC, still a single provider. Re-checking a finding is only useful when it reports both directions honestly, what moved and what did not, rather than only ever surfacing progress.

    What that CAA record does not actually cover

    Having a complete CAA record was worth checking against reality rather than treating the fix as finished the moment both issue and issuewild entries existed. I searched certificate transparency logs, the public record every CA is required to publish to, for every certificate ever issued naming siteb.example or a subdomain of it.

    curl -s "https://crt.sh/?q=siteb.example&output=json" | python3 -c "
    import json, sys
    for e in json.load(sys.stdin):
        print(e['issuer_name'])
    " | sort -u

    Alongside the CAs siteb.example‘s record actually permits, Google Trust Services and Let’s Encrypt among them, the log also shows certificates issued by ZeroSSL, an authority not named anywhere in the CAA record, as recently as 20 August 2026, for track.siteb.example:

    echo | openssl s_client -servername track.siteb.example -connect track.siteb.example:443 
      2>/dev/null | openssl x509 -noout -issuer -dates
    issuer= /C=AT/O=ZeroSSL GmbH/CN=ZeroSSL ECC DV SSL CA 2
    notBefore=Aug 20 2026
    notAfter=Nov 18 2026

    Live, current, not an old certificate lingering in the log. Checking why turned up the actual mechanism: track.siteb.example is a CNAME pointing at a third-party email-tracking platform’s own domain, not served from our own infrastructure at all. Neither track.siteb.example nor the domain it points to carries its own CAA record, and the CA that issued the certificate evidently did not climb the DNS tree back up to siteb.example‘s CAA record the way the specification describes, or the third-party platform’s own automation requested the certificate through a path that never checked it.

    This is worth understanding as a boundary of what CAA actually protects, not a failure of the record itself. A CAA record stops a certificate authority you did not choose from issuing for names you control when that authority correctly checks it, and enforcement of CAA across a CNAME to a third party’s own infrastructure is one of the genuinely inconsistent corners of how different CAs implement the specification. Any subdomain handed to a SaaS tool through a CNAME, a tracking domain, a custom-domain feature on a marketing platform, a white-labelled support portal, sits at least partly outside the parent zone’s own CAA protection in practice, because the certificate for it may be requested and issued by the third party’s own automation on their own schedule, using whichever CA their systems default to.

    The practical takeaway is not to give up on CAA. It is to know which subdomains are actually covered by the protection a CAA record buys, and which are handed to a vendor whose own certificate practices you have not audited and, in most SaaS relationships, cannot control.

    Where this sits

    DNS is the layer where nothing degrades gracefully. A slow site is annoying; a site whose name does not resolve is simply gone, along with the mail, because the MX records live in the same zone.

    It is also the layer with the least monitoring on it. Agencies watch HTTP because that is what uptime tools check. Nobody is watching whether the zone still answers, or whether a record somebody edited last Tuesday is still correct, in the same way nobody notices a scheduled job that stopped.

    Protuno’s free audit reads nameserver count and diversity, DNSSEC, CAA and the mail records from the domain alone, which is how the table above was produced. Wren is live now.

    Run the loop across your portfolio. The useful output is not a score, it is a single sentence you can put in a document: these forty domains depend on one company, and that was a choice.

    The subdomain question worth adding to that same document

    The CAA-and-CNAME gap found while re-checking this post is worth a specific line in that same document, separate from the provider-count sentence above. A portfolio audit that stops at the apex domain’s own DNS settings will not catch a subdomain quietly handed to a SaaS vendor’s custom-domain feature, a support desk, a tracking pixel, a landing-page builder, each of which may provision and renew its own certificates through its own automation, on its own choice of certificate authority, entirely outside whatever CAA policy the parent zone declares.

    None of that is necessarily a problem on its own. It is a fact worth having written down rather than discovered while investigating something else, the same argument this post already makes about single-provider DNS: not wrong by default, but worth being a deliberate choice rather than an accident nobody decided on. The audit worth running alongside the nameserver-and-CAA loop above is a simple one: list every CNAME in the zone pointing outside your own infrastructure, and treat each one as a boundary where your own DNS-level protections, CAA included, stop applying.

    What a DNS outage actually looks like downstream

    What Happens When DNS Fails? | Name Resolution Failures Explained

    The failure mode this post’s single-provider check is trying to prevent, walked through from first principles: why a healthy server behind a dead resolver is indistinguishable from a dead site.

  • Dangling CNAME Records Are A Subdomain Takeover Waiting To Happen

    Dangling CNAME Records Are A Subdomain Takeover Waiting To Happen

    Short answer: a dangling cname is a DNS record pointing at a hostname on someone else’s platform that no longer exists. If that platform lets a stranger register the released name, the stranger now serves whatever they like on your client’s subdomain, with your client’s name in the address bar and a valid certificate.

    Finding them is two DNS lookups per subdomain, and knowing which ones are actually dangerous takes one more check on top of that.

    The dangling CNAME check, in two lookups

    Terminal loop that reads the CNAME for each of fifteen common subdomain names, resolves the target, and reports DANGLING when the target does not resolve. Real output shows docs.protuno.com pointing at a Vercel hostname that resolves correctly, and no other CNAMEs found across four domains
    Run across four domains we own on 7 September 2026. One CNAME found and it resolves

    correctly. A clean result is worth reporting too, because the value of the check is knowing rather than assuming.*

    The logic is deliberately simple. Does this name have a CNAME? Does the thing it points at resolve to anything? A yes followed by a no is the finding.

    How one is created

    Four-step lifecycle table. A subdomain is pointed at a hosted service, the project ends and the account is closed, nobody removes the DNS record, and someone else registers that name on the platform and serves content on your client's subdomain
    Step three is the whole problem, and it is a process failure rather than a technical

    one. Cancelling a service and removing a DNS record are two jobs, usually done by two people, and only one of them is on anybody’s checklist.*

    #What happenedWhat was left behind
    1a subdomain is pointed at a hosted servicestaging.client.com CNAME app.someplatform.io
    2the project ends, the account is closedthe platform releases the name back into its pool
    3nobody removes the DNS recordthe CNAME still points there
    4someone else registers that namethey now serve content on your client’s subdomain

    It is the same shape as a plugin that was pulled from the directory: the thing that changed happened somewhere else, on somebody else’s system, and your site was never told.

    Every dangling record I have seen was the tidy half of a job done well. The migration succeeded. The staging site was retired properly. The campaign ended on schedule. The DNS entry stayed.

    Whether it is actually exploitable

    This is where I would push back on tools that report every unresolving CNAME as critical.

    Table of service types and whether a released name can be reclaimed. Static hosting, preview platforms, cloud storage buckets and marketing tools usually allow reclaiming. Helpdesk and status pages sometimes do. A hostname on your own server does not
    The risk depends entirely on whether a stranger can register the exact target name. A

    stale record pointing at your own retired server is untidy. One pointing at a released name on a shared platform is a live issue.*

    Service typeReclaimable?What that means
    static hosting and preview platformsusuallyproject names return to a shared pool
    cloud storage bucketsusuallybucket names are globally unique, released on delete
    marketing / landing-page toolsoftensubdomains on a shared platform, freed when an account closes
    helpdesk and status pagessometimesdepends whether the vendor reserves retired names
    your own server by hostnamenothe name is yours, the record is stale, not dangerous

    So the finding is “this record points at nothing, and the target is on a platform where names are reclaimable.” That is a sentence you can send a client. “Critical vulnerability” is not, until you have checked the second half.

    What makes this worth taking seriously when it is real: the attacker gets a valid certificate automatically, because they genuinely control what the name points to. There is no browser warning. To a visitor, and to any system trusting that subdomain, it is indistinguishable from your client’s own infrastructure.

    Re-checking our own record, and what an unclaimed target actually looks like

    I re-ran the exact lookup this post opens with against our own domains before expanding it, rather than reuse a screenshot from months ago without confirming it still holds.

    dig +short CNAME docs.protuno.com
    dig +short A 719cef8bddc9c3f9.vercel-dns-017.com
    curl -s -o /dev/null -w '%{http_code}\n' https://docs.protuno.com/
    719cef8bddc9c3f9.vercel-dns-017.com.
    216.150.1.1
    200

    Still resolves, still live. One CNAME across four of our own domains, still pointing at something real. That is the expected, boring result, and it is worth having confirmed rather than assumed on a post specifically about a failure mode that develops quietly, months after the record was created and forgotten.

    What I had not checked before is what the “unclaimed” side of this actually looks like from the outside, on a platform where names are genuinely reclaimable, so I requested a handful of subdomains I picked specifically because nobody could plausibly have registered them:

    curl -s -o /dev/null -w '%{http_code}\n' "https://this-project-should-not-exist-928471.vercel.app"
    curl -s "https://this-project-should-not-exist-928471.vercel.app"
    404
    DEPLOYMENT_NOT_FOUND

    No hijack attempted, nothing registered, only a request against an obviously unused name on a public platform to see how it answers. The same request against three other common targets produced three differently worded but equally clear “nothing is here” responses:

    PlatformUnclaimed-name response
    Vercel404, DEPLOYMENT_NOT_FOUND
    GitHub Pages404, HTML page titled “Site not found · GitHub Pages”
    Netlify404, Not Found - Request ID: ...
    Heroku404, generic platform error page
    AWS S3 (unclaimed bucket)404, XML body: NoSuchBucket

    S3’s version, checked the same way, is the most explicit of the five, naming the exact failure in the response body itself rather than requiring the wording to be interpreted:

    curl -s "https://this-bucket-should-not-exist-928471-posi.s3.amazonaws.com"
    <Error><Code>NoSuchBucket</Code><Message>The specified bucket does not exist</Message>
    <BucketName>this-bucket-should-not-exist-928471-posi</BucketName>...</Error>

    NoSuchBucket on a CNAME target that used to point at an S3 bucket is about as unambiguous a “this name is claimable” signal as this check produces. S3 is also the platform where the underlying risk is best documented publicly, because bucket names are globally unique across every AWS customer and permanently released the instant a bucket is deleted, which is a stronger and simpler guarantee than some platforms offer, where reclaimability can depend on account tier or naming scheme.

    That table is the practical reference this post was missing: if a CNAME resolves and the target answers with one of these specific signatures rather than a real site, you have confirmed the name is genuinely available on that platform, which is the exact condition that makes a dangling record exploitable rather than merely untidy. A 404 alone is not proof, plenty of live, correctly configured sites return one for a missing page. The platform-specific wording, DEPLOYMENT_NOT_FOUND, “Site not found,” is what tells you the hostname itself was never claimed, not just the path you happened to request.

    A wider wordlist, checked against our own domain

    The scan this post opened with checked fifteen common subdomain names. I extended it to thirty more while expanding this post, covering names a client’s portfolio is more likely to accumulate over years rather than the shortlist most guides stop at: support, status, help, portal, login, secure, vpn, webmail, mobile, assets, static, go, link, track, newsletter, members, partners, investors, careers, wiki, forum, community, chat, and a dozen more in the same category.

    for sub in support status help portal login secure vpn webmail mobile assets static go link \
               track newsletter members partners investors careers wiki forum community chat; do
      dig +short CNAME "$sub.protuno.com"
    done

    Nothing. Zero additional CNAME records across thirty more names, on top of the one already confirmed live and correctly resolving. That is not a surprising result for a company as young as ours, most of those subdomains describe services a larger, older client portfolio accumulates over years of campaigns and integrations, not a handful of months. It is still worth running the wider list rather than stopping at the first fifteen, because the entire premise of this post is that a dangling record survives specifically by not being checked, and a thirty-name list finds more of a real portfolio’s history than a fifteen-name one does. The list is free to extend. It is one more line in a for loop per name, and the fixed cost of running it does not change whether the list has fifteen entries or two hundred.

    Fixing and preventing

    The fix is to delete the record. There is no cleverer answer, and it takes seconds.

    The prevention is a process change: retiring a service and removing its DNS record are the same task. Whoever closes the account removes the CNAME in the same sitting. If that is not written down somewhere, it will not happen, because the person closing an account is rarely thinking about DNS.

    Then run the scan quarterly. Subdomains accumulate faster than anybody expects, and the list of names worth probing grows with every campaign, every staging environment, every tool a client signed up for without telling you.

    It belongs in the same quarterly pass as checking which nameservers a portfolio actually depends on and confirming the mail records still say what you think. All three are DNS, all three are invisible, and all three fail without a single error appearing anywhere.

    Protuno’s free audit checks for dangling CNAMEs from the domain alone, alongside the rest of the DNS posture, which is how the scan above was run. Rook is live now.

    Run the loop on the client whose site you migrated most recently. Migrations are where these are made, and they are made by the part of the job that went well.

    Why critical is the wrong word for most of what this finds

    Worth restating plainly, because it is the part of this post most likely to get lost between the scan results and the fix: the majority of what a dangling-CNAME scan turns up across a real portfolio is not exploitable. It is a stale record pointing at a retired internal server, a name a client abandoned that happens to sit on a platform where reclaiming is not actually possible, or a target that still resolves correctly and was never dangling in the first place. Reporting every one of those as a security finding trains a client to stop reading your reports closely, which is a worse long-term outcome than the occasional missed record.

    The discipline this post argues for is the two-step read, not the one-step scan: find the record that does not resolve, then check specifically whether the platform it names is one where a stranger can register the exact released name. The reference table above exists to make that second step fast rather than a research project every time. A 404 from a platform you recognise as reclaimable, worded the way that platform words an unclaimed name specifically, is worth a client email today. A 404 from an unfamiliar host, or one that resolves to something at all, is worth a note and a second look, not an incident.

    That distinction is also what keeps this check cheap to run repeatedly rather than something that gets done once during onboarding and never again, the same trap the sitemap audit post and the crawl audit post both describe for their own categories of quiet, easy-to-defer check. A scan that reports thirty false positives a quarter gets switched off. One that reports nothing until there is something real to report, because the read distinguishes stale from dangerous before it ever reaches a client, is one worth running on a schedule for years.

    What a correctly-pointed CNAME actually looks like

    How to Point Your Domain to WP Engine Using CNAME Records

    Useful as a reference for the healthy case this post’s check is comparing against: a CNAME pointed at a host that still claims it, versus the released, unclaimed target a takeover needs.