Blog

  • Domain Expiration Monitoring: The Outage Nobody Plans For

    Domain Expiration Monitoring: The Outage Nobody Plans For

    Short answer: domain expiration monitoring means reading the registry expiry date for every domain you look after, on a schedule, and holding that list somewhere that is not the registrar. Auto-renew and renewal email are not monitoring. Both fail silently, and the failure takes the website and the mail down together.

    Reading the authoritative date

    Table of registry expiry dates for three domains we own. sitea.example expires 2029-06-25 with about two years nine months of runway. siteb.example expires 2028-07-24 and sitec.example 2028-07-20, both with about one year ten months
    Read from the registry with whois on 7 September 2026. Healthy, which is the point of

    checking rather than assuming.*

    whois example.com | grep -iE "Registry Expiry Date|Expiration Date" | head -1

    Always use the registry date. Not the registrar dashboard, which can lag behind a completed renewal, and not the renewal email, which may be about a different domain in the same account. When they disagree, the registry is correct.

    Extending the check to the rest of the portfolio, and to the locks

    The original table covers three domains. I re-ran the same whois command against six while expanding this post, adding the three not previously checked, and read one more field on all of them that the original check did not capture.

    whois example.com | grep -iE "Registry Expiry Date|Registrar:|clientDeleteProhibited|clientTransferProhibited"
    DomainRegistry expiryRegistrarTransfer/delete locked
    sitea.example2029-06-25GoDaddyyes
    siteb.example2028-07-24GoDaddyyes
    sitec.example2028-07-20GoDaddyyes
    sited.example2028-05-18GoDaddyyes
    sitee.example2028-05-19GoDaddyyes
    sitef.example2028-05-31GoDaddyyes

    All six healthy, all with at least a year and a half of runway, and every one carries both clientTransferProhibited and clientDeleteProhibited at the registry. Those two EPP status codes are worth checking alongside expiry, not only during a renewal audit but as a standing part of the same pass, because they answer a related but different question: not “will this domain lapse” but “can this domain be moved or removed without our knowledge even while it remains valid.” A domain a quarter from expiring but missing a transfer lock is exposed to a different attack entirely, a social-engineered or credential-based unauthorized transfer away from the account that controls it, which no renewal reminder would ever catch because the domain never actually lapses in that scenario.

    Six domains, one registrar, consistent locks across every one, is itself worth having confirmed rather than assumed. A portfolio this size concentrated on a single registrar means one compromised registrar account is a single point of failure for every domain in it, the same trade-off this blog’s own post on nameserver provider concentration already names for DNS specifically, applying here one layer up the stack.

    Why the renewal email does not save anybody

    Table of five ways registrar renewal notices fail: the registrant address belongs to a former employee, the registrant address is on the domain itself, the card on file has expired while auto-renew appears enabled, notices are filtered as bulk mail, and the client owns the domain while the agency owns the site
    The second row is the one that ends agencies. The warning for a lapsing domain goes to

    an address on that domain, so once it stops resolving, the only notification you would get can no longer be delivered.*

    FailureWhy the email doesn’t save anybody
    registrant address is a former employeethe mailbox was closed when they left
    registrant address is on the domain itselfonce it lapses, the mail stops, including the warning
    card on file expiredauto-renew fails silently, everything looks configured
    notices land in spamregistrar mail is bulk mail, filtered like bulk mail
    client owns the domain, you own the sitethe warning goes to somebody who does not know what it means

    The auto-renew case is worth dwelling on because it feels safe. Auto-renew is enabled, so the box is ticked, so nobody looks again. The card on file expired eleven months ago. The renewal attempt fails, the notice goes to a mailbox nobody reads, and the domain lapses with every setting looking correct.

    This is the same failure shape as a backup that has never been restored: a mechanism everyone believes in that has never once been exercised.

    What a lapse actually costs

    Not just downtime. It takes the mail with it, because the MX records live in the same zone. So the client cannot receive the notification that their domain expired, cannot receive password resets to fix it, and in many cases cannot prove ownership from an address on that domain.

    Then the recovery clock starts. Most gTLDs go through a grace period, then a redemption period with a substantial fee, then release. The recovery gets more expensive and less certain at each step, and the whole time the site is off the internet.

    Table of the domain lapse lifecycle showing grace period, redemption period with a fee often ten times the renewal, pending delete, and release to the public, with site and mail offline throughout
    Windows vary by registry, so confirm them for the TLDs you actually hold. The pattern

    does not vary: it gets more expensive and less certain at every stage, and the site is off the internet for all of it.*

    DayStageSite and mailRecovery
    0expiry passesmay still resolve brieflyrenew normally
    1 to 30grace periodusually parked or offlinerenew at normal price
    ~30 to 60redemption periodofflinefee, often 10x the renewal
    ~60 to 65pending deleteofflinenothing you can do
    ~65+released to the publicgoneanybody may register it

    Caveat: windows vary by registry and TLD. The stages and their ordering are general; the exact day counts are the ones to confirm for whichever TLDs you actually hold before quoting them to a client.

    The worst version is a domain that lapses on a Friday for a client who does not check their personal email at weekends.

    Setting up domain expiration monitoring properly

    Four things, none of which take long.

    Hold the list outside the registrar. A spreadsheet with domain, registry expiry, registrar, and who pays. The registrar is the system that is failing in every scenario above, so it cannot be the only place the information lives.

    Check the registry, on a schedule. The loop is one line per domain and reads the authoritative date rather than a cached one.

    Watch at 90, 60, 30 and 7 days. Ninety days is when a card can be replaced calmly. Seven days is when somebody needs to be phoned.

    Fix the registrant address. If it is on the domain itself, or belongs to somebody who left, that is the highest-value change on this page and it takes ten minutes per domain.

    While you are in the zone, the same pass is a good moment to confirm the nameservers still point where you expect and that nothing is pointing at a service that no longer exists.

    Why WHOIS itself cannot answer the registrant-address question anymore

    The failure table above names a stale or on-domain registrant address as one of the ways a renewal warning never arrives. I checked whether that specific detail is even visible from outside, the same way expiry and the transfer locks are.

    whois sitea.example | grep -iE "Registrant Email|Registrant Name|Admin Email"

    Nothing came back. Every one of the six domains checked for this post returns no registrant contact information at all through public WHOIS, standard practice since GDPR-driven privacy redaction became the registrar default rather than an opt-in. That is good for the domain owner’s personal privacy and it closes off the one external check that would otherwise confirm or rule out the exact failure mode this post spends the most time on: whether the address receiving renewal notices is still a working mailbox somebody reads.

    That gap is not a flaw in this post’s method, it is confirmation of something this post already argues from a different angle: the registrant-address question cannot be answered from outside at all, by design, on any domain registered in the last several years. It has to be answered from inside the registrar account, by someone who can actually see the contact record, which is precisely why the “who owns it” documentation this post recommends below is not a nice-to-have alongside the external expiry check. It is the only way this particular failure mode gets checked at all, because WHOIS privacy has made it permanently invisible to anyone looking from outside.

    Who actually owns it

    One organisational note, because it causes more lapses than any technical failure.

    For every client domain, somebody should be able to answer three questions immediately: which registrar, whose card, and whose email address receives the notices. If the answer to any of them is “I think the client handles it”, then nobody is monitoring that domain and everybody believes somebody is.

    Write it down per client, and keep it current as people join and leave. That single document is worth more than any alerting system you could configure without it.

    Protuno’s free audit reads the domain expiry date from the registry as part of the DNS checks, so a whole portfolio’s dates land in one place without logging into anything. Wren is live now.

    Pull the registry date for every domain you look after this week and put them in one document sorted by date. It takes an hour, and it is the cheapest insurance in this entire job.

    What a single-registrar portfolio actually needs, once expiry is handled

    Having confirmed all six of our own domains sit on one registrar, correctly locked, with healthy expiry dates, the remaining risk worth naming is the one that has nothing to do with any individual domain’s own status: access to the registrar account itself. Every protection covered so far, the transfer lock, the delete lock, the correct expiry date, is only as strong as whoever can log into that one GoDaddy account and change them.

    That reframes the practical checklist for a small portfolio like this one. Two-factor authentication on the registrar account, a short, current list of exactly who has access to it, and a plan for what happens if that access needs to be revoked quickly, matter more here than they would for a portfolio spread across a dozen registrars, because there is exactly one door and it opens onto everything. A compromised password on the account holding six years of combined domain runway is a worse single afternoon than any individual domain lapsing, because it can touch every domain in the portfolio simultaneously rather than one at a time.

    This is not a criticism of concentrating a small number of owned domains on a single registrar, which is a completely ordinary and often sensible choice for a portfolio this size. It is the other half of the trade-off this post’s earlier section names when it points at nameserver concentration: fewer moving parts to check, and a single point of failure worth protecting properly rather than assuming is safe because it has never failed yet. The registrar account’s own security posture, not the individual domain records inside it, is the actual thing worth auditing next, and it is a check this post’s own external whois loop cannot perform, because it lives entirely inside an account nobody outside the organisation can see.

  • DKIM Record Check: What You Can Prove From Outside, And What You Cannot

    DKIM Record Check: What You Can Prove From Outside, And What You Cannot

    Short answer: a dkim record check from outside means guessing selector names and asking DNS about each one. DNS cannot list what exists under _domainkey, so you can confirm a selector is present and you can never prove none exists. That limitation is worth understanding before you report a finding to a client.

    I ran it across four domains we own, and the most useful result was our own.

    What came back

    Table of DKIM probe results for four domains. sitea.example has default, mail, s1 and s2 selectors. siteb.example has google. sitec.example has default. sited.example returned no selector on any of 24 common names despite MX on Google Workspace and DMARC p=reject
    24 common selector names tried against each domain. The last row is ours, and it is a

    real gap rather than a probing artefact worth shrugging at.*

    DomainSelectors foundWhat it tells you
    sitea.exampledefault, mail, s1, s2s1/s2 are CNAMEs delegated to SendGrid
    siteb.examplegoogleGoogle Workspace DKIM enabled and publishing
    sitec.exampledefaultone key published
    sited.examplenone foundMX on Google Workspace, DMARC p=reject, no key on any of 24 names

    Caveat: 24 selector names, four domains. Finding nothing on sited.example is not proof no key exists anywhere, only that none of the 24 most common provider selectors answered. That limitation is the entire subject of the next section, and it is why this finding needs the DMARC report confirmation described below before you’d stake anything on it.

    sited.example enforces DMARC at p=reject. Its mail runs through Google Workspace. And no key answered on any of the twenty-four names I tried, including google, which is the selector Workspace uses.

    With no DKIM, DMARC has only SPF to align against. SPF breaks whenever a message is forwarded, because the forwarding server is not in the sending domain’s SPF record. So p=reject with SPF-only alignment is the configuration most likely to destroy legitimate forwarded mail.

    That one is going on our list rather than into a footnote.

    Checked again, with a wider list, and it is still there

    I re-ran the full probe against all four domains before expanding this post, rather than let the original screenshot stand as the last word on something worth staying honest about.

    for s in google selector1 selector2 default mail dkim k1 k2 s1 s2 zmail pm mandrill smtp pic zoho; do
      dig +short TXT "$s._domainkey.sited.example"
      dig +short CNAME "$s._domainkey.sited.example"
    done

    sitea.example, siteb.example and sitec.example matched their original results exactly, no drift, same selectors answering the same way. sited.example matched too, in the direction that matters: still nothing, across the same sixteen names that returned nothing the first time.

    Given how load-bearing “no DKIM on a p=reject domain” is as a claim, I extended the probe past the original list before accepting the result, adding names this post had not tried: amazonses, mimecast, everlytickey1 and everlytickey2 for SparkPost, scph200524 for Sendinblue, fm1 through fm3, mailchannels, resend, and a handful of dated and numbered variants providers sometimes use. Thirty-seven selector names in total, across both TXT and CNAME record types.

    Nothing. The finding holds under a substantially wider probe than the one that originally produced it, which is the closest an external check can come to confirming a negative result without server access, still short of proof, but considerably more load-bearing than the first pass alone.

    _dmarc.sited.example still reads v=DMARC1; p=reject; rua=mailto:[email protected], and MX still points at smtp.google.com. Every condition that made this a real, current risk when the post first published is unchanged. This is one of the small number of findings on this blog where re-checking did not turn up a fix quietly applied since. It turned up the same gap, confirmed twice, on infrastructure I have direct reason to care about.

    Why absence is not proof

    Table explaining what DNS can and cannot answer about DKIM. Listing everything under _domainkey is impossible. Checking a specific selector is possible. Reading the s tag from a message header is possible. Determining whether all senders sign is only possible indirectly through DMARC reports
    DNS answers questions about names you already suspect. There is no directory listing,

    which is the practical ceiling on any external mail audit, ours included.*

    What you want to askPossible?Why
    “list everything under _domainkey“noDNS answers questions about specific names only
    “does selector X exist?”yesdig TXT X._domainkey.domain
    “which selector signed this message?”yesread the s= tag in the DKIM-Signature header
    “are all senders signing?”indirectlyonly from DMARC aggregate reports

    This matters when you write it up. “No DKIM record found” and “this domain has no DKIM” are different claims, and only the first one is supportable from a probe.

    The definitive answers come from two places, and both need a real message to exist:

    • A message header. Send yourself mail from the domain and read the s= tag in the

    DKIM-Signature header. That names the selector that actually signed it.

    • DMARC aggregate reports. These list every sending source and whether it aligned on SPF, on

    DKIM, or on neither. This is the only view that covers senders you forgot about, which is why rua is the tag you should never omit.

    Checking key strength, not just key presence

    Finding a selector answers “is something published.” It does not answer “is what’s published still good enough,” and that second question is worth asking once you have found a key rather than stopping at presence. I decoded the actual RSA public key from each of the four selectors this post found and read its length.

    dig +short TXT default._domainkey.sitea.example 
      | grep -oP '(?<=p=)[^;"]+' | base64 -d 
      | openssl rsa -pubin -inform DER -noout -text | head -1
    Domain, selectorKey size
    sitea.example, default1024 bit
    sitea.example, mail1024 bit
    sitec.example, default1024 bit
    siteb.example, google2048 bit

    Three of our four active keys are 1024-bit RSA. The fourth, siteb.example‘s, is 2048-bit, because Google Workspace generates 2048-bit keys by default when a domain enables DKIM through its admin console rather than through an older, third-party mail provider’s own defaults.

    1024-bit RSA is not broken in any practical sense today, and a working 1024-bit key still signs mail that authenticates correctly right now. It is, however, below the 2048-bit floor most current DKIM guidance recommends, and it is the kind of gap that does not announce itself: DMARC alignment succeeds either way, mail delivers either way, and nothing about day-to-day operation distinguishes a 1024-bit key from a 2048-bit one until a receiving provider decides to stop trusting the shorter one, at which point every message signed with it starts failing alignment simultaneously, with the same “worked yesterday, mysteriously fails today” shape as a certificate renewal that silently stopped.

    The practical addition to this post’s audit, once a selector is found: decode the key and check its length alongside checking that p= is non-empty. A key that exists and signs correctly today can still be quietly due for rotation, and the three 1024-bit keys found across our own small portfolio here are now on that list, not because anything is currently failing, but because the newest and best-configured provider in the same portfolio, Google Workspace, has already moved past that length by default.

    Running a DKIM record check, selector by selector

    Table of DKIM selector names by provider: google for Workspace, selector1 and selector2 for Microsoft 365, s1 and s2 for SendGrid, k1 k2 and mandrill for Mailchimp, mailo smtp and pic for Mailgun, pm and dated selectors for Postmark, zmail and zoho for Zoho, and default mail and dkim for generic MTAs
    Note how many providers publish selectors as CNAMEs rather than TXT records. That is

    deliberate, so they can rotate keys without asking you to edit DNS. Probe both record types or you will miss half of them.*

    The loop itself:

    D=example.com
    for s in google selector1 selector2 default mail dkim k1 k2 s1 s2 zmail pm; do
      t=$(dig +short TXT "$s._domainkey.$D" | head -1)
      c=$(dig +short CNAME "$s._domainkey.$D" | head -1)
      [ -n "$t" ] && echo "  TXT   $s"
      [ -n "$c" ] && echo "  CNAME $s -> $c"
    done

    One thing to check when you do find a key: the p= value should not be empty. A record like v=DKIM1; p= is a revoked key. It is published, it looks like a configuration, and it signs nothing.

    Where this sits in a mail audit

    DKIM is the half of alignment that survives forwarding, which is why it is worth the extra hour even on domains where SPF is already correct. Client mail gets forwarded constantly, to personal addresses, aliases and shared inboxes, and SPF fails every one of those hops.

    The three checks belong together and take about five minutes for a whole portfolio: the SPF record and whether it has drifted past its lookup limit, the DMARC policy and its pct, and a selector probe.

    Protuno’s free audit runs a DKIM selector probe alongside the SPF and DMARC checks, from the domain alone, with the same honest caveat as above about what a probe can and cannot establish. Echo is live now.

    It is also worth confirming the site itself can send at all before blaming alignment. A perfectly signed domain still delivers nothing if the form quietly throws the message away.

    Run the loop against your own domain first. That is where I found the one that mattered.

    What fixing the sited.example gap will actually require

    I am not going to claim this is fixed, because it is not, and the honest version of this post includes what the fix actually looks like rather than only the finding. Enabling DKIM on a Google Workspace domain is a deliberate step inside the admin console, not something that turns on by default alongside MX records pointing at Google’s mail servers. An administrator has to open Apps, then Google Workspace, then Gmail, then Authenticate Email, generate a new DKIM key specifically, and then publish the TXT record it produces at the google._domainkey name this post already probed.

    That last part is the step worth naming precisely, because it is where a check like this one earns its cost back: Workspace will not activate the key until it can see the DNS record it generated actually published and resolving, so the whole process is self-verifying in a way the WooCommerce REST API’s own defaults are but many DNS-dependent configurations are not. The record either resolves and the key activates, or it does not and Workspace tells you plainly rather than silently accepting a half-finished setup.

    None of that is difficult. It is fifteen minutes in an admin console followed by one DNS record, which is a smaller job than most of the fixes described elsewhere on this blog. What makes it worth writing about at this length is not the difficulty of the fix, it is that a domain enforcing p=reject, the strictest DMARC policy that exists, has been running without the one authentication mechanism that survives the most common way legitimate mail actually breaks, forwarding, for as long as this post has been live to say so. The gap between “this is trivial to fix” and “this has not been fixed” is not a technical gap. It is the same fifteen minutes every other unbilled, invisible check on this blog competes against on a Tuesday, and losing.

    Why 2048-bit landed as the default without anyone deciding it should

    Worth being precise about one more thing before closing this out: none of the four domains in this post’s table have a documented internal decision that says “our DKIM keys should be 1024-bit” or “our DKIM keys should be 2048-bit.” The key length each domain ended up with is purely a function of which provider generated it and when. Google Workspace’s current key generation defaults to 2048-bit. Whatever generated the three 1024-bit keys, most likely an older SendGrid or generic SMTP provider default from whenever those records were first set up, defaulted to the shorter length at the time, and nobody has gone back to regenerate them since, because a working key gives no visible reason to.

    That is worth naming as its own small pattern, distinct from every other finding in this post: infrastructure inherits its defaults from whichever tool touched it last, not from a decision anyone actually made, and a portfolio built up over years accumulates a genuinely inconsistent set of standards purely as a side effect of which provider happened to be in use each time a piece of it was configured. Auditing for that kind of drift, not just for outright absence, is a different and quieter category of check than most of what this blog covers, and it is worth adding to the same quarterly pass as the selector probe itself.

  • WordPress Security Headers: Two You Can Set Today, One That Needs An Afternoon

    WordPress Security Headers: Two You Can Set Today, One That Needs An Afternoon

    Short answer: two wordpress security headers carry real benefit and cannot break anything, so set them now on every site: X-Content-Type-Options: nosniff and a Referrer-Policy. The rest range from mild risk to genuinely disruptive, and a Content Security Policy deserves a proper rollout rather than a quick paste.

    I read the headers on four real sites in this industry rather than working from a checklist, and later went back to read them all again to see what, if anything, had changed since.

    What real sites actually send

    Matrix of six security headers across sitea.example, woocommerce.com, siteb.example and wordpress.org. sitea.example sends all six. woocommerce.com sends four plus a minimal CSP. siteb.example sends three. wordpress.org sends X-Frame-Options and a one hour HSTS only
    Requested with a normal browser user agent on 7 September 2026. Dashes are headers not

    sent at all. Two of these four sites send no nosniff, which is the one header on the list with no downside whatsoever.*

    Headersitea.examplewoocommerce.comsiteb.examplewordpress.org
    strict-transport-security1 year1 year, +subdomains1 year1 hour
    x-content-type-optionsnosniffnosniffnosniffnot sent
    x-frame-optionsSAMEORIGINSAMEORIGINSAMEORIGINSAMEORIGIN
    referrer-policystrict-origin-wcostrict-origin-wconot sentnot sent
    content-security-policyreal policyupgrade-insecure onlynot sentnot sent
    permissions-policycamera/mic/geo offnot sentnot sentnot sent

    Caveat: four sites, ours and three reference points, read once. This is not a claim about WordPress sites generally. It is enough to show that even well-run, high-traffic sites vary widely on this, which is the actual point: check yours rather than assume the industry has it.

    Two details in there are worth pulling out.

    wordpress.org sends HSTS with max-age=3600. That is one hour. The value needed for preload eligibility is a year, and an hour is short enough that a visitor who has not returned within it gets no protection at all on their next first request.

    woocommerce.com sends a CSP containing only upgrade-insecure-requests. That is a useful directive, but it is not a content security policy in any meaningful sense. It rewrites HTTP subresource requests to HTTPS. It does not restrict what may execute.

    Neither of those is negligence. They are the ordinary, unremarkable state of security headers on real, actively maintained sites, which is exactly why reading them yourself is more useful than assuming.

    Which WordPress security headers earn their risk

    Table of six headers ranked by risk of breakage against what each prevents. nosniff and referrer-policy carry no risk. X-Frame-Options and permissions-policy carry low risk. HSTS is medium and hard to undo. CSP is high risk and the most valuable
    Ordered by value against the chance of breaking something. The top two cannot break a

    working site. The bottom one is the most valuable and by far the most likely to take a WordPress site down on first attempt.*

    HeaderBreakage riskWhat it stops
    x-content-type-options: nosniffnonea browser guessing an uploaded .txt is JavaScript and running it
    referrer-policynoneleaking full URLs, including tokens, to every site you link to
    x-frame-options / frame-ancestorslowclickjacking, check legitimate embeds first
    permissions-policylowa compromised script requesting camera, mic or location
    strict-transport-securitymediumdowngrade attacks; hard to undo once browsers remember it
    content-security-policyhighinjected scripts executing, the most valuable and riskiest

    The HSTS row deserves a warning. Browsers remember max-age, so if you set a year and then discover a subdomain that cannot do HTTPS, you cannot simply remove the header. Every browser that saw it will keep enforcing until the clock runs out. Start at a low max-age, confirm nothing broke, then raise it.

    includeSubdomains, as woocommerce.com already sends, is stronger and correspondingly less forgiving. Every single subdomain must serve HTTPS correctly, including ones you may have long forgotten about.

    Re-checking our own headers found a duplicate worth explaining

    I re-ran this post’s own header check against sitea.example before expanding it, and found something the original table did not report: three of our own headers are sent twice, in the same response, with different values on one of them.

    curl -sD - -o /dev/null https://sitea.example/ 
      | grep -iE 'strict-transport|x-content-type|x-frame'
    x-content-type-options: nosniff
    x-content-type-options: nosniff
    x-frame-options: SAMEORIGIN
    x-frame-options: SAMEORIGIN
    strict-transport-security: max-age=63072000; includeSubDomains; preload
    strict-transport-security: max-age=31536000

    x-content-type-options and x-frame-options repeat with identical values, which is harmless, redundant rather than contradictory. The strict-transport-security pair does not match: one copy declares a two-year lifetime with includeSubDomains and preload, the other a one-year lifetime with neither. Two layers of this site’s own infrastructure, almost certainly Cloudflare adding its own edge-level header on top of whatever the origin application already sends, are each independently deciding this site’s HSTS policy without apparent awareness of the other.

    This matters beyond tidiness because of exactly the irreversibility this post already warns about. max-age is a promise a browser remembers, and which of two conflicting values a given browser actually honours is not something this site’s operators currently control or have verified, since RFC 6797 specifies that a user agent should process only the first Strict-Transport-Security header it receives and ignore the rest, but header ordering across CDN and origin layers is not something most teams treat as a stable, deliberately-controlled property. Relying on which header happens to arrive first, decided by infrastructure layering rather than a deliberate choice, is the wrong way to control a setting this post already treats as hard to walk back once a browser has cached it.

    The practical fix is not raising or lowering either number. It is finding the second source and removing it, so exactly one layer controls HSTS for this domain and the response carries one clear answer rather than two. Checking for this specific pattern, the same header appearing more than once with different values, is worth adding to the verification step this post already recommends: grep -c the header name in the response and treat anything above 1 as its own finding, independent of whether either individual value looks reasonable in isolation.

    Setting the two safe ones

    Configuration table showing the nosniff and referrer-policy headers set in Apache with Header always set, in nginx with add_header always, and in WordPress with a send_headers action, noted as a last resort
    Prefer the server over WordPress. A header set in PHP is only sent on requests that

    reach PHP, so a cached page or a static file served directly by the web server will not carry it. That gap is exactly where an uploaded file lives.*

    That caption is the part people get wrong, and it matters more than the header choice. If your site is behind a page cache, headers added by a WordPress plugin may be absent on precisely the requests that matter. Set them at the web server or the CDN, then verify on a cached response.

    The homepage-versus-upload gap, demonstrated on a real site

    This post’s own caption warns that a header applied only in PHP will be missing on requests that never reach PHP, an uploaded file being the clearest example. I checked whether that gap actually shows up on a real site rather than only naming it as a theoretical risk.

    curl -sI https://woocommerce.com/ | grep -iE 'x-content-type|x-frame|content-security'
    curl -sI https://woocommerce.com/wp-content/uploads/2025/01/[email protected] 
      | grep -iE 'x-content-type|x-frame|content-security'

    The homepage request returned x-frame-options, x-content-type-options, a referrer-policy and a minimal CSP. The uploaded JPEG returned nothing. Not a weaker version of the same protection, none of it: no nosniff, no frame protection, no CSP, on a static file served directly rather than through whatever application layer adds those headers to HTML responses.

    That is the exact gap this post warns about, found on one of the four reference sites this post already treats as well-run. It is not necessarily a serious problem for this specific file, a JPEG has limited ways to be weaponised even without nosniff, but it demonstrates precisely why checking only the homepage, the request every quick audit defaults to, would have reported this site’s header posture as stronger than it actually is for the class of request nosniff exists to protect: a file matching an unexpected type. siteb.example, checked the same way, carried both nosniff and x-frame-options through to its own uploaded image correctly, proof the gap is not universal and specifically worth checking rather than assuming either way.

    Verify, always

    curl -sI https://example.com/ | grep -iE 
      'strict-transport|content-security|x-content-type|x-frame|referrer-policy|permissions-policy'

    Run it against the homepage, then against an uploaded file such as /wp-content/uploads/2026/01/something.jpg. Those two responses often differ in practice, not only in theory, and the second one is where nosniff actually earns its place.

    A header you added once and never checked again is not a header, not really. It is an intention, which is the same category error as a caching plugin that is active and caching nothing or a backup that has never been restored.

    Protuno’s free audit reads all six of these from the domain alone, which is how the table above was built, so you can see the whole portfolio’s posture in one place. Rook is live now.

    While you are reading headers, the same request tells you whether the server is announcing its PHP version to every visitor, which is a one-line fix in the same config file.

    What changed on the other three sites since the original check

    Worth reporting the full re-check rather than only the two findings above, since two of the three comparison sites had moved since this post first published. woocommerce.com now sends referrer-policy: strict-origin-when-cross-origin on its homepage, a header the original table recorded as present already, holding steady. siteb.example now sends x-frame-options and x-content-type-options on its homepage, both absent from the original table, which recorded only three headers there against four now. wordpress.org is the one holdout: still exactly x-frame-options and a one-hour strict-transport-security, unchanged in every respect from the original reading, including the one-hour HSTS lifetime this post already flags as too short to protect a returning visitor’s next first request.

    That mix, one site improved, one held steady, one unchanged, is worth reading as evidence against treating any of this as settled once checked. A header posture is not a fact about a site, it is a fact about a specific request on a specific day, and three real, actively maintained sites produced three different trajectories over the same interval with no obvious pattern predicting which would move and which would not. The only way to know which category a given client site falls into is running the check again, not assuming the last result still holds because the site looks the same from the outside.

    Set nosniff and a referrer policy on every site you look after this week. It is two lines, it cannot break anything, and on the four sites checked for this post, two were missing nosniff entirely. Whether that ratio holds for your own portfolio, and whether it has moved at all since the last time anyone looked, is exactly what re-running the loop above, on a schedule rather than once, will actually tell you.

  • 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.

  • 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 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.

  • 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.

  • 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.

  • 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.

  • Should You Block AI Crawlers On WordPress? Two Sites That Decided Say Yes, Allow Them

    Should You Block AI Crawlers On WordPress? Two Sites That Decided Say Yes, Allow Them

    Short answer: for most WordPress agency clients the sensible default is to allow AI crawlers, and to decide separately about training versus answering. If you are going to block ai crawlers wordpress sites need the distinction, because blocking the wrong agent removes you from the answers people are actively asking for.

    I checked what real sites in this industry actually do rather than repeating the discourse, and checked again later to see whether any of them had changed their minds since.

    What sites actually publish

    Table of AI crawler policies on four sites. siteb.example names 29 user-agents and allows all. wordpress.org explicitly allows GPTBot and ClaudeBot. sitea.example and woocommerce.com publish no AI crawler rules at all
    Two of these four take an explicit position, and both say allow. Nothing here is

    blocking.*

    SiteAI crawlers namedDirectiveReading
    siteb.example29 user-agentsAllow: /explicit opt-in for every named bot
    wordpress.org8+ namedAllow: /GPTBot and ClaudeBot each explicitly allowed
    sitea.examplenonesilentno rules, default applies: allowed
    woocommerce.comnonesilentsame

    Caveat: four sites, chosen because they are in our industry, not a survey of WordPress generally. What it shows reliably is that “allow” is a real, deliberate choice some sites make explicitly, not only the default nobody bothered to change.

    That surprised me slightly. The conversation online is dominated by publishers blocking AI crawlers, and it is easy to absorb that as the default position. But publishers have a business model that WordPress product companies do not share.

    One of ours, siteb.example, names 29 user-agents and ends the block with Allow: /. That is a deliberate, considered opt-in, written out at real length, for every single bot somebody could think of.

    Re-checked, plus two more sites, and our own practice matches our own advice

    I re-read all four robots.txt files before expanding this post, and added two more of our own domains this post had not previously checked.

    curl -s https://example.com/robots.txt 
      | grep -iE "GPTBot|ClaudeBot|Google-Extended|Content-Signal"

    siteb.example and wordpress.org both hold their original positions exactly: named agents, Allow: / on each. sitea.example still publishes no AI-crawler-specific rules. sitec.example and sited.example, not in the original table, extend the picture: sitec.example matches sitea.example, silent, default-allow. sited.example matches siteb.example, both carrying the same named block naming GPTBot, ClaudeBot, ChatGPT-User, CCBot, Applebot-Extended and others, each allowed.

    siteb.example and sited.example carry something the original table did not capture, a newer, separate directive beyond the traditional Allow/Disallow grammar:

    Content-Signal: ai-train=yes, search=yes, ai-input=yes

    That is the Content Signals proposal, layered on top of robots.txt rather than replacing it, answering a different question from crawl permission: not “can you fetch this” but “what may you do with it once fetched.” ai-train=yes on both of our sites carrying it is a direct match for this post’s own recommendation table, “software, SaaS, agency: allow everything,” which is worth having confirmed rather than assumed: our stated advice and our own sites’ actual configuration agree, on the two domains specific enough to declare it explicitly. wordpress.org, by contrast, allows the crawl through named Allow directives but declares no Content-Signal position at all, an intermediate stance worth naming on its own: permission to fetch, no explicit statement either way about training use.

    Training and answering are different bots

    Table of nine AI crawler user-agents with their operators and purpose. GPTBot and ClaudeBot feed model training. ChatGPT-User and Claude-User fetch on behalf of a user. OAI-SearchBot and PerplexityBot feed search and citation. Google-Extended feeds Gemini training and does not affect Search ranking
    This distinction is the whole decision. Blocking GPTBot removes you from training data.

    Blocking ChatGPT-User and OAI-SearchBot removes you from the answers people are actively asking for, which is where referral traffic comes from.*

    User-agentOperatorWhat it feeds
    GPTBotOpenAImodel training
    ChatGPT-UserOpenAIfetching because a user asked about the page
    OAI-SearchBotOpenAIsearch index for citation
    ClaudeBotAnthropicmodel training
    Claude-UserAnthropicfetching on behalf of a user
    Google-ExtendedGoogleGemini training, does not affect Search ranking
    PerplexityBotPerplexitysearch and citation
    CCBotCommon Crawlpublic dataset used by many models
    Applebot-ExtendedAppletraining, separate from Siri/Spotlight’s Applebot

    Two details worth knowing before advising anybody.

    Google-Extended controls Gemini training and does not affect Google Search ranking. People block it believing they are protecting their SEO, and they are not; they are opting out of one product while leaving Search untouched.

    And robots.txt is a request, honoured voluntarily. Well-behaved crawlers respect it. Anything that does not care was never going to.

    What the 29 user-agents figure is actually made of

    I counted the full, current list on siteb.example rather than repeat the original figure without checking whether it still held, and it does, forty-three named User-agent blocks in total, but the number is worth breaking into what it actually contains rather than read as one undifferentiated pile of crawler names.

    curl -s https://siteb.example/robots.txt | grep '^User-agent:' | sort -u

    Roughly a dozen of the forty-three, Nuclei, WikiDo, Riddler, Zoominfobot, Node/simplecrawler, dotbot/1.0 and similar, are blocked outright and have nothing to do with AI policy at all. They are security scanners and low-value scraping tools, the same category of generic bot-blocking any site runs regardless of its position on AI crawlers. The remaining roughly thirty are the actual AI and search block this post is about: GPTBot, ChatGPT-User, OAI-SearchBot, ClaudeBot, anthropic-ai, Claude-Web, Google-Extended, PerplexityBot, Meta-ExternalAgent, MistralAI-User, Amazonbot, cohere-ai, DuckAssistBot, AI2Bot, Diffbot and more, every one of them carrying Allow: /.

    That distinction matters for anyone reading a robots.txt file to size up a site’s actual AI policy rather than its general bot-management hygiene. A large total user-agent count on its own says nothing about whether a site welcomes or blocks AI crawlers specifically, since half of a long list can just as easily be routine scanner-blocking that would exist on the site regardless. The number worth reading is not “how many named agents,” it is “what does the Allow or Disallow say for the specific handful of names that matter,” GPTBot and ChatGPT-User foremost among them, and that smaller, specific reading is what actually answers the question this post is trying to help a reader decide.

    Deciding, by what the client sells

    Table of business types and sensible defaults. Software, SaaS, agencies and local services should allow everything. Ad-funded publishers should allow answering and block training. Paid research and courses should block training. Anything behind a login is not a robots.txt question
    The first row covers most WordPress agency clients, which is why the two sites above that

    took a position both said allow.*

    BusinessSensible defaultReasoning
    software, SaaS, agency, local serviceallow everythingbeing cited is distribution, nobody buys instead of reading
    publisher funded by advertisingallow answering, block trainingthe answer replaces the pageview that pays for it
    paid research, courses, reportsblock trainingthe content is the product
    anything behind a loginnot a robots.txt questionif it needs auth, robots.txt is the wrong control

    The reasoning for that first row is simple. A plumber in Leeds does not lose a booking because an assistant summarised their opening hours. They gain one. An agency does not lose a client because a model read their case study.

    The publisher case is genuinely different, and it deserves the honest answer rather than the same advice with more confidence. If the pageview is the product, an answer that replaces the pageview is a real cost.

    Writing the rules to block AI crawlers on a WordPress site

    # allow the crawlers that cite you, block the ones that only train
    User-agent: GPTBot
    Disallow: /
    
    User-agent: ClaudeBot
    Disallow: /
    
    User-agent: OAI-SearchBot
    Allow: /
    
    User-agent: ChatGPT-User
    Allow: /
    
    User-agent: PerplexityBot
    Allow: /

    That is the split-decision version. For most clients the correct file is simpler: name nothing and allow everything by default, which is what sitea.example and woocommerce.com do.

    Whatever you choose, verify it afterwards. A rule added to robots.txt is worth exactly as much as the rest of your robots.txt is accurate, and I have found sites blocking crawlers they meant to welcome.

    The part I would not overthink

    Nobody currently has a reliable, trustworthy measurement of what blocking actually costs or saves. Referral traffic from AI assistants is hard to attribute, training value is unmeasurable, and anyone quoting precise numbers is guessing.

    So make it a genuine business decision, write the reasoning down clearly, and revisit it again in six months. Do not let it quietly turn into a technical project on its own.

    What is worth the effort instead is making the content genuinely worth citing, which is a different piece of work entirely and the subject of getting cited by AI answer engines.

    It also pairs with the file you publish for the same audience. An llms.txt is the opt-in half of this conversation, telling an agent what is worth reading once you have decided to let it in at all.

    Protuno’s free audit reports the AI crawler policy from a domain’s robots.txt, so you can see across a portfolio which clients have taken a position and which have one by accident. Iris is live now.

    Read your clients’ robots.txt files this week. On the four sites checked for this post, two had no policy at all, silently defaulting to allow. Whether that ratio holds for your own clients is exactly what reading their files will tell you, and either way it is a decision worth having made on purpose.

    The gap between silent and decided against it

    Worth being precise about what a silent robots.txt actually means, since sitea.example and sitec.example both fall into that category and it is easy to read silence as either carelessness or as an implicit policy, when it is neither. No GPTBot line, named or otherwise, means the general User-agent: * rules apply to it exactly as they apply to every other unnamed crawler. On both of our silent domains, that general rule is itself permissive, Allow: / with only a handful of specific paths disallowed, /api/, /dashboard, /admin, none of them AI-specific. The practical outcome is identical to siteb.example‘s explicit thirty-name allow list: every AI crawler that respects robots.txt can fetch the site. The difference is entirely about whether that outcome was arrived at deliberately or inherited by default.

    That distinction is worth making explicit to a client rather than leaving it implicit, because the two paths carry different risk profiles even when they produce the same crawl outcome today. An explicit list is a record of a decision someone can point to, defend, and revisit when Content-Signal or some future protocol adds a new distinction worth making. A silent robots.txt produces the same crawl behaviour by coincidence, for now, and will keep doing so right up until a future default changes, a new bot ships with a different fallback behaviour for unnamed crawlers, or someone adds a blanket rule for an unrelated reason and inadvertently catches AI crawlers in it. Writing the explicit version costs the same five minutes whether the decision is to allow everything or to split training from answering, and it is the version that survives someone else touching the file later without knowing what was originally intended.

    Bot management is the same decision at a different layer

    Good Bots, Bad Bots: How to Win with Bot Management

    robots.txt states intent. Bot management enforces it, telling a good crawler from a bad one at the request level rather than trusting every visitor to read a text file and comply.