Blog

  • Duplicate Google Analytics Tags: You Cannot Find Them In The Page Source

    Duplicate Google Analytics Tags: You Cannot Find Them In The Page Source

    Short answer: a duplicate google analytics tag inflates sessions, halves your bounce rate and double-counts conversions, and you will not find it by reading the HTML. A tag manager is one line in the source that loads an unknown number of tags at runtime. The only reliable audit is observing what the browser actually requests.

    The gap between source and runtime

    Table comparing two sites. sitea.example HTML declares one GTM container and loads one self-hosted analytics request at runtime. woocommerce.com HTML declares one GTM container and loads 22 analytics requests across 8 vendors
    Both sites declare a single tag manager container in their HTML. One loads one analytics

    request; the other loads twenty-two.*

    SiteFound in the served HTMLLoaded at runtime
    sitea.example1 GTM container (GTM-KS8DH75)1 request: a self-hosted script; the GTM container did not fire
    woocommerce.com1 GTM container (GTM-W64W8Q)22 requests across 8 vendors

    That first row is worth noting too. The GTM container is present in the markup and did not fire during my page load. Whether that is consent gating or something else, it is the mirror image of the same problem: what is in the source tells you nothing about what runs.

    What twenty-two requests looks like

    Table of every analytics request made by the woocommerce.com homepage, including the GTM container, the GA4 library, two GA4 collect endpoints for the same measurement id, four Google Ads endpoints, the Meta pixel, two LinkedIn insight scripts, Hotjar, Klaviyo and WordPress.com stats
    The GA4 pair is expected and does not double-count. The LinkedIn pair is not: two builds

    of the same insight tag, the stable one and the beta one, on the same page load.*

    RequestVendor / purpose
    gtm.js?id=GTM-W64W8Qtag manager container
    gtag/js?id=G-98K30SHWB2GA4 library
    analytics.google.com/g/collectGA4 hit
    stats.g.doubleclick.net/g/collectsame GA4 id, second endpoint, normal
    google.com/ccm/collect + 3 moreGoogle Ads conversion, four endpoints, one account
    connect.facebook.net/.../fbevents.jsMeta pixel
    insight.min.js and insight.beta.min.jsLinkedIn Insight, loaded twice, this is the duplicate
    static.hotjar.com/...Hotjar
    static-tracking.klaviyo.com/...Klaviyo
    stats.wp.com/...WordPress.com stats via Jetpack

    Caveat: one page load on one site. The exact vendor list will differ everywhere; the pattern worth taking from it is general: a genuine duplicate looks like two builds of the same script, not two different vendors doing similar things.

    I want to be careful about what counts as a duplicate here, because it is easy to raise a false alarm.

    Not a duplicate: one GA4 measurement id posting to both analytics.google.com/g/collect and stats.g.doubleclick.net/g/collect. That is how GA4 works.

    Not a duplicate: several Google Ads endpoints for one conversion id. Different mechanisms, one account.

    A genuine duplicate: insight.min.js and insight.beta.min.js from LinkedIn on the same page. That is the classic signature of a tag added twice, once through the tag manager and once hardcoded into the theme, which is exactly how it happens on WordPress sites during a handover.

    Re-checking both findings, using the browser this time rather than a proxy

    I went back and re-ran this post’s own audit through an actual browser, rather than only re-reading the original screenshots, using the Performance Resource Timing API this post already recommends as the reliable method:

    performance.getEntriesByType('resource')
      .filter(e => hosts.some(h => e.name.includes(h)))
      .map(e => e.name)

    woocommerce.com still loads both insight.min.js and insight.beta.min.js from snap.licdn.com, the exact duplicate this post’s original audit found. That finding has not been fixed. I want to be as straightforward about that as this blog is about everything else: a real, findable duplicate, confirmed live again, sitting unresolved on the same page since the first check.

    sitea.example still shows the mirror-image problem too. window.google_tag_manager is undefined, dataLayer is empty, and zero requests to any analytics host fired on page load, while the GTM-KS8DH75 container tag is still present in the page’s own HTML. Whatever gates that container, most likely a consent-management layer waiting for a decision that never arrives in an automated check, is still gating it.

    Re-running both checks turned up one additional detail the original pass did not capture: the full resource list from woocommerce.com shows the same Google Ads remarketing pixel firing to two different domains, google.com and google.co.in, within the same page load.

    https://www.google.com/pagead/1p-user-list/878432221/...
    https://www.google.co.in/pagead/1p-user-list/878432221/...

    Same account ID, 878432221, same parameters, two country-specific endpoints. This is worth naming as a third category alongside the “not a duplicate” and “genuine duplicate” rows earlier in this post: a geographic or infrastructure redundancy inside a single vendor’s own tag, firing what looks like a duplicate request but represents one logged event handled by two regional endpoints rather than two separate recordings of the same conversion. Distinguishing this from a genuine duplicate takes the same discipline as the GA4 dual-endpoint case already covered: read what each request is actually reporting, not just how many requests share a similar shape.

    The audit

    JavaScript snippet to paste into the browser console that reads the performance resource timeline, filters analytics hosts, groups by host, path and measurement id, and prints a table of counts
    Group by host plus path plus measurement id. Any key with a count above one is a genuine

    duplicate.*

    Run it on the homepage, a blog post and a conversion page, because tags are often injected by rules that only fire on some templates. A duplicate that exists only on the checkout is the most expensive version and the least likely to be found by testing the homepage.

    A vendor with a dozen requests is not automatically a dozen problems

    The full resource list from the live re-check turned up something worth addressing directly, because it is exactly the kind of number that produces a false alarm if read naively: Klaviyo alone accounted for roughly twenty separate requests to static.klaviyo.com and static-tracking.klaviyo.com on a single page load.

    Reading those filenames explains why immediately: runtime.js, sharedUtils.js, vendors~in_app_forms~signup_forms.js, event_adapter.js, telemetry.js, each one a separately hashed, separately cacheable chunk of one JavaScript application, split apart by a modern bundler rather than shipped as a single file. That is code-splitting, a performance technique, not twelve copies of a tracking tag each independently recording the same event. Counting Klaviyo’s request volume as “twelve tracking calls” would be exactly the kind of naive count this post already warns against for the GA4 dual-endpoint case, applied to a vendor whose architecture happens to produce a much larger number of individual files.

    The practical rule this adds to the audit: group by what a request actually does, not by domain or by raw count. A vendor with one measurement ID posting to twenty different code-chunk URLs and one conversion event is doing less tracking than a vendor posting to two URLs that both record the same purchase. Counting domains, or counting requests, without reading what each one reports is the same mistake in a different shape.

    Two different LinkedIn products, easy to conflate

    The genuine duplicate in this post, insight.min.js next to insight.beta.min.js, sits alongside a separate LinkedIn request that is not part of that duplicate at all, and the two are worth telling apart precisely rather than lumping together as “LinkedIn stuff.”

    snap.licdn.com/li.lms-analytics/insight.min.js         <- the Insight Tag (page-level tracking)
    snap.licdn.com/li.lms-analytics/insight.beta.min.js    <- the same tag, a second build
    px.ads.linkedin.com/collect                             <- a conversion event, fired by the tag above

    The Insight Tag is the script; px.ads.linkedin.com/collect is one event that script sends once it has loaded. Finding two copies of the script is the duplicate worth fixing, because each copy independently initialises its own tracking session and can independently fire its own version of the same conversion event, doubling what LinkedIn’s ad platform believes happened. Finding one collect request downstream of it is not itself evidence of doubling; it would only become evidence of doubling if two separate collect calls for the same event appeared, which is a check worth running specifically rather than assuming from the presence of two script tags. On this page, only one collect request appeared despite the two scripts, which suggests the duplication has not yet produced a doubled conversion count on this specific page, though a different page, especially the actual checkout, is the one this post already argues is the one that matters most and the one least likely to have been checked.

    What a duplicate Google Analytics tag does to the numbers

    Two pageviews per visit, so sessions inflate. The second hit arrives moments after the first, so bounce rate collapses toward zero because every session looks like it had two interactions. And conversions double, which means the reported cost per acquisition halves.

    That last one is the expensive part. A client making budget decisions on a halved cost per acquisition will spend more on a channel that is performing worse than the dashboard says.

    It is the same class of quiet wrongness as a form that reports success and delivers nothing: the report is confident, internally consistent, and describing something that did not happen.

    Where it comes from on WordPress

    Almost always a handover. The previous developer hardcoded the tag into the theme. The new agency adds it through the tag manager, because that is the tidy way. Nobody removes the first one, because nobody knows it is there.

    The other common source is a plugin. Several SEO and marketing plugins offer an analytics field, and filling it in adds a tag that coexists happily with whatever else is installed.

    So the audit is worth running specifically after any site you inherit, alongside the crawl and the other onboarding checks. It is also worth re-running after a theme change, since a hardcoded tag disappears when the theme does, for the same reason cache and header rules need re-checking after a migration: configuration living in one layer vanishes when that layer is replaced.

    Protuno’s free audit flags duplicate analytics tags detectable from the domain.

    Paste the snippet into the console on your busiest client’s site. If any row has a count above one, their reporting has been wrong for as long as it has been there.

    What re-running this check taught me about how long these things live

    The LinkedIn duplicate on woocommerce.com was real when this post first published, and it is still there now, unfixed, on a site with a professional marketing team and a security posture good enough to make it into three other posts on this blog as a reference point. That is not a criticism of the people running that site. It is the clearest evidence available that this category of problem does not get found by accident, because nothing about it produces an error, a broken page, or a support ticket. The dashboard keeps reporting numbers. They are simply the wrong numbers, quietly, for as long as nobody runs the specific check this post describes.

    The same applies to the gated GTM container on sitea.example. Whatever decision produced that state, a consent platform waiting for an interaction an automated check never provides, a misconfigured trigger, something else entirely, has held for months without anyone treating it as urgent enough to investigate. Both of these are small technical facts with a real cost attached, and both would take a working afternoon to properly diagnose and fix. Neither has been, which is the argument this whole post is making, restated as two live examples rather than a general claim: the audit is cheap, and the absence of anyone running it is the actual reason these problems last as long as they do.

    The wider category this mistake belongs to

    10 common mistakes in Google Analytics (2026 update)

    A duplicate tag is one entry on a longer list of quiet Analytics failures that produce numbers without producing an error, walked through by a dedicated Google Tag Manager and GA channel.

  • Orphan Pages In SEO: The Ones Your Own Site Refuses To Link To

    Orphan Pages In SEO: The Ones Your Own Site Refuses To Link To

    Short answer: an orphan page exists on the site and has zero internal links pointing at it. Finding them for orphan pages seo work needs two separate sources, a page inventory and a crawl, because a crawl by definition cannot report a page it never reached.

    What the distribution actually looks like

    I crawled our own site and counted every single internal link pointing at every page.

    Table of inbound internal link counts on protuno.com. The homepage has 325, contact 48, login 40, blog 33 and the launch post 14, while a docs category page and the signup page have one each
    No orphans on this site, which is the good news. The rows worth attention are the two

    with a single inbound link.*

    URLInboundWhat that means
    /325linked from every header and footer
    /contact48footer link, every page
    /auth/login40header link, every page
    /blog33footer link
    /blog/introducing-protuno14blog index, categories, author page, other posts
    /docs/category/getting-started1reachable, but only just
    /auth/signup1one inbound link, for a commercially important page

    Caveat: one site, one crawl, one day. The distribution shape (a handful of huge numbers, a long tail near one) is a general pattern of site link graphs, not a claim about what your own numbers will be. Run the crawl on your own site rather than assume this one transfers.

    That kind of distribution is entirely normal and it is far more useful than a binary orphan check. Header and footer links produce genuinely enormous counts. Content pages sit somewhere in the middle. And a small tail of pages are reachable from exactly one place.

    A page with one inbound link is one navigation change away from being an orphan, and nothing will tell you when that happens. On our site, the signup page is in that tail, which is worth knowing given what it is for.

    Re-crawling turned up the exact trap this post warns about, from the crawl tool itself

    I ran a fresh crawl of protuno.com while expanding this post and asked the tool’s own orphan-detection feature for its summary before reading anything else. It reported 194 of 209 pages as orphans, every single page except fifteen.

    That is not a plausible number for a working site with real internal navigation, and reading the same crawl’s raw per-page data rather than trusting the summary confirmed why:

    pages = {p['url']: p for p in export['pages']}
    for u in ['/blog', '/contact', '/privacy', '/blog/llms-txt-wordpress']:
        print(u, len(pages[u]['linked_from']))
    Page the summary called an orphanReal inbound links, same crawl
    /blog17
    /contact6
    /privacy4
    /blog/llms-txt-wordpress2

    Every one of them has real, recorded inbound links sitting in the same crawl’s own raw data. The orphan-detection summary and the underlying link graph disagreed with each other inside a single crawl run, and the raw data was the one telling the truth, exactly the same failure this blog already documented on a different crawl of this same site months earlier. Finding it again, independently, on a fresh crawl ID, is stronger evidence than the original catch alone: this is not a one-off fluke in the tool’s summary logic, it is a repeatable characteristic of how it aggregates orphan status, and anyone using this tool’s own orphan report as the final word would be told, wrongly, that this site’s navigation is almost entirely broken.

    The only page in the flagged list that is a genuine orphan by the raw data is the homepage itself, linked_from: 0, which is the expected, harmless seed-URL convention: a crawl starting at a URL does not count arriving there as a followed link. Every other name on that 194-page list is a false positive, caught by doing exactly what this post’s own two-source method already argues for, not trusting a single automated summary as the final answer.

    What orphan pages actually cost, in SEO terms and otherwise

    Table of four consequences of an orphan page ranked by severity. A person cannot find it by browsing is high. The site not vouching for it and slower crawler discovery are medium. Never being indexed at all is low and frequently overstated
    Lead with the first row when explaining this to a client, because it is the one that is

    unambiguously true. The ranking arguments are real but softer, and overstating them is how SEO advice loses credibility.*

    ConsequenceSeverityDetail
    a person cannot find it by browsinghighthe real cost, no visitor can reach it without the direct URL
    your own site does not vouch for itmediuminternal links are how a site says which pages matter
    slower to be discovered by crawlersmediumbeing crawled is not the same as being valued
    never indexed at alllowrare in practice if the page is in the sitemap, often overstated

    I want to be careful here, because orphan pages get oversold. A page in your sitemap will usually be crawled and can rank perfectly well with no internal links at all.

    The honest problem is simpler and worse: somebody wrote that page, and no visitor browsing the site can reach it. That is a wasted asset regardless of what any search engine does.

    The single-link page named as fragile is still exactly that

    This post’s opening table flags /auth/signup specifically: one inbound link, for a commercially important page, one navigation change away from becoming an orphan outright. The fresh crawl’s raw data shows it sitting at exactly the same count, still one, unchanged since the original reading.

    That is worth taking as confirmation rather than a non-event. A page flagged as fragile that is still exactly as fragile months later means nobody has acted on the original finding, and it also means nothing has accidentally made it worse in the meantime, which is its own small piece of good news. /auth/login, the equivalent page in the signup flow, moved only slightly, 40 to 42 inbound links, essentially stable. /contact moved considerably more, from 48 down to 6, which is worth investigating rather than reading as decline on its face: a real drop that large usually traces to a specific structural change, a footer redesign, a navigation simplification, or a template that used to link to it from every post and no longer does, and finding out which is exactly the kind of question this post’s two-source method is built to answer rather than leave as a number that moved for unknown reasons.

    Finding them without fooling yourself

    Three-step method: extract the page inventory from the sitemap, crawl the site for linked pages, and diff the two lists, with a warning that crawling only from the homepage produces false orphans and crawling logged in hides real ones
    A crawl cannot tell you about a page it never found, which is exactly the population you

    are trying to measure. The inventory has to come from somewhere else.*

    Two mistakes make this check useless.

    Taking the inventory from the crawl. If you only know about pages the crawl reached, every orphan is invisible by construction. Use the sitemap, or a database export of published URLs.

    Crawling while logged in. Admin navigation, draft previews and role-specific menus all appear, so pages look linked when no visitor can see the link. Crawl with no cookies, as a stranger would.

    What to do with the list

    Not every orphan should be fixed. Three outcomes, and two of them are not “add a link”.

    Link to it, if the page still earns its place. From a relevant page in the same cluster, with an anchor that describes the destination rather than saying “click here”.

    Redirect it, if it has been superseded. An old service page that a newer one replaced should point at the replacement rather than sitting in the sitemap competing with it.

    Remove it, if it should not exist. Test pages, duplicate landing pages from an old campaign, a staging artefact that got published. Deleting these is a genuine improvement, and it is the outcome people resist because deleting feels like loss.

    Table of three outcomes for an orphan page: link to it from a relevant page with a descriptive anchor, redirect it to a replacement, or remove it deliberately, each with what to do to the sitemap afterwards
    Resist the instinct to solve the whole list by adding footer links. A link from every page

    is a signal of navigation, not of importance, and it buries the pages that genuinely deserve prominence.*

    OutcomeWhenAfterwards
    link to itstill earns its place, belongs to a clusterlink from a relevant page, descriptive anchor, never the footer
    redirect itsuperseded by a newer page301 to the replacement, remove the old URL from the sitemap
    remove ittest pages, old duplicates, staging artefacts410 or 404 deliberately, remove from the sitemap

    Whichever you choose, keep the sitemap honest afterwards. A redirected or deleted URL that stays in the sitemap is exactly the contradiction that makes a sitemap useless.

    The WordPress-specific sources

    Four things generate orphans on WordPress specifically, and knowing them shortens the investigation.

    Retired taxonomies. Delete a category and its posts survive, but the archive that linked them disappears. If those posts were only reachable through that archive, they are now orphaned.

    Page builder templates. A template that generated a listing or a loop can be replaced during a redesign, taking a whole set of internal links with it while every target page remains published.

    Attachment pages. WordPress creates a page per uploaded media item by default. They are almost never linked, almost never useful, and they can quietly form the largest group in an orphan report. Disabling them is usually the right call.

    Old campaign landing pages. Published outside the navigation on purpose, linked only from an ad that stopped running two years ago. Technically orphans, and often genuinely finished work that somebody should decide about.

    That last group is the reason to review an orphan list with the client rather than acting on it alone. Some of those pages exist deliberately.

    Why this drifts

    Orphans are almost always created by a change somewhere else. A navigation redesign drops a menu item. A category is retired. A plugin that generated a listing page is removed. In every case the page survives and its only route in does not.

    So this is not a one-time audit. It is a check to run after every navigation or template change, and it is the same class of quiet, structural drift as a link that starts 404ing on every page at once.

    Protuno’s free audit reads the sitemap and the crawl-visible structure of a site from the domain alone. Iris is live now.

    There is a related check worth running in the same pass, and it finds more on most sites than the orphan list does: pages with exactly one inbound link. They are not orphans today and they are the population that becomes orphaned next. Sorting the crawl output by inbound count ascending gives you both lists at once, and the second one is where you can act before there is a problem rather than after.

    Run the two-source diff on your largest client site. The list is usually short, and about half of it is content somebody forgot they had. That half is the return on the exercise: pages already written, already paid for, and one internal link away from being useful again.

    The content that survives but stops earning its place

    3 Ways to Solve Content Decay on Your Website (Quick and Easy)

    An orphan page has lost every internal link. Decayed content is the earlier stage of the same problem: still linked, still crawlable, quietly losing relevance while nobody notices until the traffic is already gone.

  • WordPress Broken Link Checker: Why A 404 Is The Easy Half

    WordPress Broken Link Checker: Why A 404 Is The Easy Half

    Short answer: a wordpress broken link checker that only looks for 404s finds the least interesting failure. Soft 404s return 200, redirect chains return 200, and a page whose entire content has since changed still returns 200. All three break the reader’s experience and none of them appear in a status-code report.

    Starting with our own work

    Before ever auditing anybody else’s site I checked mine first, carefully.

    Table showing 17 distinct external URLs checked across three published posts, all returning 200, spanning patchstack.com, wordpress.org, php.net, theadminbar.com, make.wordpress.org, github.com and sitea.example, with zero broken
    A clean result, reported as such. The value of the check is not that it finds something

    every time. It is that a citation which quietly 404s makes every other claim on the page harder to believe.*

    That matters more on a blog like this one than on a brochure site. Every post here leans on primary sources, and a dead citation is a claim the reader cannot verify.

    Extending the check to every citation across this blog, not just three posts

    The original check covered three posts and seventeen links. I extended it while expanding this post, pulling every genuine external citation from every post published so far, not the placeholder example.com URLs used inside code samples, and checking each one the same way: status, redirect count, and byte size together.

    grep -ohE "https?://[a-zA-Z0-9./_?=#-]+" posts/*.md | grep -v "protuno.com|example.com"
    for url in $(cat citations.txt); do
      curl -s -o /dev/null -w '%{http_code} redirects:%{num_redirects} size:%{size_download}n' -L "$url"
    done

    Twelve genuine citations across a different, later set of posts than the original check: Action Scheduler’s own admin and FAQ pages, two developer.wordpress.org cron references, the Content Signals and llms.txt specification pages, a WordPress core AI-team page, the Patchstack whitepaper this blog cites for its removed-plugin statistic, a survey page, a WordPress.tv talk, PHP’s own supported-versions page, and one YouTube video. All twelve returned 200, zero redirect hops, plausible byte sizes matching real content rather than a small styled error page.

    Combined with the original seventeen, that is twenty-nine real citations checked across this blog’s own published output, all clean. Worth stating precisely what that does and does not prove: it is not evidence that link rot is rare in general, twenty-nine links checked days to weeks after publication is far too soon for the kind of drift this post describes to have had time to happen. It is evidence that the citation discipline this blog has tried to hold itself to, quoting the specific claim relied on rather than a bare link, linking to stable specification and documentation pages rather than a blog post likely to be rewritten, has not yet produced a single broken reference. Whether that holds up in a year is exactly the kind of thing worth re-checking again, on a schedule, rather than assuming from a clean result today.

    The three failures a 404 check misses

    Table of four link problems. A hard 404 or 410 is caught by every checker. A soft 404 returns 200 with an error page. A redirect chain returns 200 after several hops. Content that changed entirely returns 200 and no tool detects it
    The soft 404 is the one that undermines automated link checking. A styled “page not

    found” served with a success status is invisible to a status-code check.*

    ProblemStatus returnedWhy it matters
    hard 404 or 410404 / 410the obvious one, every checker finds these
    soft 404200a styled “page not found” served with success
    redirect chain200 after N hopseach hop costs time and dilutes the link
    content changed entirely200resolves and now says something else, no tool detects it

    I hit a live example of the soft 404 while researching a different post. Checking several sites for an llms.txt file, four returned 200 with bodies of 399 KB, 146 KB and similar. Those were not files. They were the sites’ normal HTML error pages, served with a success status.

    The lesson generalises: read the status, the size and the content type together. Any one of them alone will tell you something confidently wrong.

    The fourth row deserves honesty too. No tool can tell you that a URL you cited two years ago now points at a rewritten page saying something different. That is a human job, and the practical mitigation is to quote the specific sentence you relied on, so a reader can tell whether the source still says it.

    The soft-404 example in this post no longer reproduces, and that is worth saying

    This post’s own soft-404 illustration comes from checking several sites for an llms.txt file and finding styled HTML error pages served with 200. I re-ran that specific check against the same category of site while expanding this post, and it no longer reproduces the same way.

    curl -s -D - -o /dev/null https://siteb.example/llms.txt | grep -i content-type
    curl -s -D - -o /dev/null https://sitec.example/llms.txt | grep -i content-type
    curl -sI https://woocommerce.com/llms.txt | grep -i location
    content-type: text/plain; charset=utf-8      (siteb.example, 120,105 bytes)
    content-type: text/plain; charset=utf-8      (sitec.example, 212,742 bytes)
    location: https://woocommerce.com/wp-content/uploads/llms.txt   (301, then a real 10,148-byte file)

    All three sites now serve genuine text/plain content at that path, woocommerce.com via a correct 301 redirect to the actual file rather than a soft 200. Whatever produced the styled error pages this post originally caught, most likely the file simply not existing yet on sites that had not adopted the format when this post first checked, has been resolved, and llms.txt adoption itself has clearly moved forward in the meantime.

    That is a genuinely good outcome, and it belongs in this post for the same reason every other re-check on this blog reports both directions: a soft-404 example that quietly stopped being true would otherwise sit in a published post indefinitely, describing a problem that no longer exists as though it still does. The mechanism this post teaches, checking content-type and size alongside the status code, is exactly what caught both the original finding and its later resolution, which is the strongest argument this post can make for running the check again rather than trusting either a stale finding or a stale all-clear.

    Redirect chains

    Terminal command checking every external link on a page for status, redirect count and size together, filtering out clean results, with measured output showing our own http to https redirects completing in a single hop
    Filtering out the clean lines leaves only rows that need a human. On our own domains,

    http://protuno.com and http://www.protuno.com both reach the canonical HTTPS URL in a single hop, which is what you want.*

    One hop is correct and unavoidable: HTTP to HTTPS, or www to apex. Two or more means somebody added a rule without checking what already existed, usually during a migration.

    Chains are worth fixing not because they break anything but because each hop is a round trip the visitor waits through, and because the chain tends to grow. Three hops today becomes five after the next redesign.

    Running a WordPress broken link checker across a portfolio

    Internal links and external links are different jobs.

    Internal links are your responsibility and should never break, because you control both ends. A crawl finds them, and it is how I found a 404 linked from every page of our own site.

    External links rot on somebody else’s schedule. A quarterly pass is enough for most sites, and the highest-value target is your most-trafficked content rather than everything.

    Table of link types with recommended check frequency: internal links and navigation on every crawl, external links on money pages monthly, cited research quarterly, and archive content annually
    Checking everything on the same schedule is how link auditing gets abandoned. The last

    row is why the 404 on our own site mattered more than usual: it sat in the footer, so one broken link appeared on every page at once.*

    Link typeFrequencyReasoning
    internal links, site-wideevery crawlyou control both ends, any finding is real
    external links on money pagesmonthlyhighest traffic, highest cost when a reader hits a dead end
    external links in cited researchquarterlya dead citation undermines the argument
    external links in archive contentannuallyreal rot, low readership
    links in navigation and footersevery crawlappear on every page, one break is site-wide

    Two practical notes. Rate-limit the checker, or you will get blocked and record false failures on perfectly healthy sites. And send a real user agent, because plenty of hosts refuse unknown ones, which produces the same false result.

    That second point is worth taking seriously: a link checker that reports failures on sites that are actually fine is the same false-positive problem as an alt-text audit counting lazy-load placeholders. A report full of noise is a report nobody reads, and then the real finding arrives and is ignored with the rest.

    The one that costs money

    There is a category of broken link that deserves separate treatment, and it rarely appears in an SEO report because it is not on a page at all.

    Links inside transactional email. The order confirmation pointing at a tracking page that moved. The password reset URL built from a stale site address. The invoice link to a document store that was migrated. These break exactly like page links and nobody crawls them, because they live in templates rather than in HTML anybody visits.

    The check is manual and worth doing once per client: trigger each transactional email on a staging copy and click every link in it. On a store that is the order confirmation, the shipping notification and the password reset at minimum. It takes half an hour and it is the only way these are ever found, short of a customer reporting one.

    The same reasoning applies to anything a form sends after submission: the reply nobody reads is also the reply nobody has checked the links in.

    What to do about a broken link

    Fix, redirect, or remove, in that order of preference.

    If the target moved, update the link. If it is gone but the point still stands, find an equivalent source rather than deleting the sentence. If neither is possible, remove the link and keep the claim only if you can support it another way.

    What not to do is leave it. A broken link in an old post is a small thing; a page full of them tells a reader the content is unmaintained, which is a judgement about everything else on the site too.

    Protuno’s free audit checks the crawl-visible link health of a site from the domain alone. Iris is live now.

    One habit that removes most of this work permanently: link to canonical, stable URLs in the first place. A link to a documentation homepage outlives a link to a specific anchored heading on it. A link to a specification outlives a link to somebody’s summary of the specification. You cannot stop the web from changing, but you can choose the targets least likely to move.

    Run the loop above on your three most valuable pages. Not the whole site, just the three that earn money. That is twenty minutes and it covers the links that actually matter.

    Then run it again next quarter, on the same pages, and compare the result to this one rather than treating either a clean pass or a finding as the final word. Link rot and its opposite, a broken destination quietly getting fixed, both happen on somebody else’s schedule, and the only way to know which direction a given page has moved is to have checked it more than once.

    The same check, walked through end to end

    Broken Links on Your Site: How to Fix Them for Better SEO

    A recent, practical run-through of finding and fixing broken links, covering the same ground as the loop above from the SEO-impact side rather than the raw HTTP-status side.

  • WordPress Sitemap Not Updating: Published, Valid, And Quietly Wrong

    WordPress Sitemap Not Updating: Published, Valid, And Quietly Wrong

    Short answer: a wordpress sitemap not updating still returns 200 and still validates. The check is not whether it exists, it is whether its newest lastmod is more recent than your last publish, and whether the URLs inside it actually resolve.

    What a healthy one looks like

    Terminal output listing seven URLs from protuno.com/sitemap-blog.xml, all published blog posts, with the newest lastmod in the index dated today
    Read hours after publishing. Every published post is present, and the newest lastmod

    is today rather than a date months in the past.*

    That comparison is the entire health check. Newest lastmod against your most recent publish date. If the sitemap is older, something between WordPress and the served file has stopped.

    Why a WordPress sitemap stops updating

    Table of five ways a sitemap goes stale while remaining valid: cached by a CDN, generation running on a stopped WP-Cron, two conflicting sitemaps from core and an SEO plugin, lastmod set to build time rather than edit time, and noindexed or redirecting URLs listed inside
    Every one of these leaves a well-formed sitemap returning 200. The XML is not the

    problem; what it says is.*

    FailureStill valid XML?Effect
    cached by a page cache or CDNyesnew posts never appear, the XML is a month old and looks perfect
    generation runs on WP-Cron, which stoppedyesfrozen at whatever date the scheduler died
    two sitemaps published, core and a pluginyesboth answer, disagreeing
    lastmod set to build time, not edit timeyesevery URL claims to have changed today, meaningless
    contains noindexed or redirecting URLsyescrawling pages you told search engines to ignore

    The second row is the one I hit most often. Plenty of SEO plugins regenerate their sitemap on a scheduled event, so a site whose WP-Cron has stopped has a sitemap frozen at whatever date the scheduler died. The site publishes normally. The sitemap simply stops changing, and nothing anywhere reports it.

    The third row is worth a specific check on any site with an SEO plugin installed. Core publishes /wp-sitemap.xml and most SEO plugins publish their own index. If both answer with different contents, you are sending search engines two contradictory maps.

    The audit

    Three shell commands: checking which sitemap paths answer and whether more than one does, extracting the newest lastmod, and requesting every listed URL to find any that do not return 200
    Command three is the one that finds real problems. Anything it prints is a URL you are

    actively asking search engines to crawl that does not return 200.*

    Two things worth knowing about lastmod.

    It should reflect when the content changed, not when the file was generated. Some setups stamp every entry with the build time, which makes every URL claim to have changed today and destroys the signal’s usefulness entirely.

    And it is a hint, not an instruction. A correct lastmod helps a crawler prioritise; it does not compel anything. Sites that game it by touching every date achieve nothing except making their own diagnostics useless.

    What should not be in it

    A sitemap is a list of pages you want indexed. So it should not contain:

    • URLs carrying a noindex, which is a direct contradiction and the same confusion behind

    the discourage-search-engines setting

    • Redirecting URLs, which waste crawl budget pointing at their own replacements
    • Paginated archives and tag pages nobody intends to rank
    • Anything disallowed in robots.txt, since a crawler cannot fetch it to read the sitemap’s advice

    That last combination appears constantly on WordPress sites and is entirely self-inflicted.

    The noindex row is not a hypothetical example picked for tidiness. It is the exact finding further down this post, caught on our own infrastructure rather than borrowed from somebody else’s audit: a sitemap that lists pages correctly marked not to be indexed, generated by a plugin doing exactly what it was configured to do, on a domain the sitemap check itself was never pointed at until writing this section made it worth checking.

    I checked our own sitemap against the actual publishes of today

    The freshness check earlier in this post is easy to state and easy to skip verifying, so I ran it against protuno.com’s own sitemap while several posts were being published today, rather than trust that the comparison works the way I described it.

    curl -s https://protuno.com/sitemap-blog.xml | grep -o "<loc>[^<]*</loc>\|<lastmod>[^<]*</lastmod>"
    URLlastmod
    /blog/wordpress-robots-txt-blocking2026-09-11T16:06:47Z
    /blog/wordpress-crawl-audit2026-09-11T16:01:06Z
    /blog/wordpress-page-cache-header2026-09-11T15:57:05Z
    /blog/wordpress-ttfb-explained2026-09-11T15:53:24Z
    /blog/wp-config-exposed-check2026-09-11T15:50:52Z
    /blog/wordpress-removed-plugin-risk2026-09-11T15:44:39Z
    /blog/introducing-protuno (untouched today)2026-09-01T11:17:38Z

    Six posts republished within roughly twenty-two minutes of each other show six distinct lastmod timestamps, each within a minute or two of the actual edit, while a post nobody touched sits at a date ten days old. That is what a sitemap doing its job looks like: the timestamp tracks the edit, not the last time someone regenerated the file wholesale, and a stale entry stands out immediately next to fresh ones instead of blending in with a site-wide regeneration date that moves for every URL at once.

    That last distinction is worth being specific about, because it is the difference between a useful lastmod and the meaningless kind described earlier in this post. If every URL in a sitemap shared the same timestamp, down to the second, that would be a signal the file was regenerated on a schedule rather than updated per edit, and it would tell a crawler nothing about which pages actually changed. Six different timestamps, each matching a real edit, is the opposite of that failure mode, confirmed on the file this post itself just caused to update.

    The noindexed pages sitting in our own sitemap

    The earlier section on what should not be in a sitemap names noindexed URLs first. I went looking for one on our own infrastructure rather than assume the rule only applies to other people’s sites, and found one within a few requests.

    Protuno’s frontend at protuno.com is headless, served by Next.js, with its own sitemap system entirely separate from WordPress. The WordPress install behind it, at cms.protuno.com, is a second, independent web server, and it runs Rank Math, which generates its own sitemap the moment the plugin is active, whether or not anyone intends the CMS domain itself to be crawled:

    curl -s https://cms.protuno.com/robots.txt
    # Sitemap: https://cms.protuno.com/sitemap_index.xml
    
    curl -s https://cms.protuno.com/sitemap_index.xml

    That index lists a post-sitemap.xml containing 23 URLs, all on the cms.protuno.com domain rather than protuno.com, one per published post, plus the CMS homepage. I requested one of them directly:

    CheckResult
    GET https://cms.protuno.com/wordpress-robots-txt-blocking/200
    on that pagenoindex, nofollow
    pointing back to protuno.comabsent
    Is /sitemap_index.xml itself disallowed in robots.txt?no

    Every individual page correctly declares noindex, nofollow, which is right: the CMS domain is infrastructure, not a page meant to rank, and the actual public copy lives at protuno.com/blog/.... But the sitemap listing those same URLs is not blocked, is not marked in any way as a duplicate, and carries no canonical tag pointing a crawler at the real, indexable version. It is, byte for byte, the exact contradiction named earlier in this post: a sitemap advertising URLs that explicitly ask not to be indexed, on a domain search engines can reach freely because nothing in robots.txt stops them from requesting the sitemap file itself.

    The practical risk is narrower than it sounds, because noindex is respected on every one of those 23 pages individually, so none of them should actually enter a search index. What the finding shows is not “our content is at risk of appearing twice.” It is that a plugin turned on for one purpose, in this case Rank Math handling on-page SEO fields for the editorial team, ships a sitemap generator nobody asked for as a side effect, and that side effect produces the exact anti-pattern this post has been describing the whole way through, on infrastructure I did not think to check until writing this section. A Disallow on /sitemap_index.xml and /*-sitemap.xml in cms.protuno.com‘s robots.txt, or turning the sitemap module off entirely since the CMS domain is not the canonical source for anything, closes it. Neither has happened yet, so this is a finding, not a fix, exactly in the spirit of the still-open items in the crawl audit post.

    The check worth taking away, if a site of yours runs a similar headless setup: the sitemap to audit is not only the one your visitors’ browsers ever see. Any WordPress SEO plugin running on a backend that a frontend has replaced is still generating its own sitemap unless someone has explicitly told it to stop, and that sitemap answers requests on its own domain whether or not anyone remembers it exists.

    The sitemap file itself can be cached, on top of everything above

    One more layer worth checking, because it interacts with the freshness comparison rather than replacing it: the sitemap XML file is itself a URL, and a URL can sit behind a CDN like any other page.

    curl -sI https://protuno.com/sitemap-blog.xml | grep -iE "cache-control|cf-cache-status"
    cache-control: public, max-age=0, s-maxage=3600, stale-while-revalidate=86400
    cf-cache-status: DYNAMIC

    The header declares up to an hour of edge caching, s-maxage=3600. DYNAMIC on this particular request means Cloudflare answered from the origin rather than a cached copy, which is why the lastmod values in the earlier table lined up with the actual edit times to within a minute or two rather than lagging by up to an hour. Had that request come back HIT instead, the freshness comparison earlier in this post would still run and still look sensible, just against whatever snapshot of the file the edge happened to be holding, which could be anywhere up to an hour stale relative to the newest publish.

    This is the same gap the page cache header post describes for ordinary pages, applied to one specific URL that happens to be your sitemap rather than a landing page: a passing signal on its own, 200 and well-formed XML, sitting on top of a caching layer that can silently be serving something older than what WordPress or your frontend actually generated most recently. Checking cf-cache-status or the equivalent header on the sitemap URL itself, alongside the lastmod comparison, is the difference between trusting a number and trusting a number you have confirmed was not served from an hour-old edge copy.

    Where it fits

    Sitemaps are a low-effort, low-drama check that fails silently and gets audited once at launch. The failure is invisible because the file is still there and still valid, which is the same category as a caching plugin that caches nothing.

    Protuno’s free audit checks whether a sitemap is published and reachable from the domain alone, alongside robots.txt and the other crawl signals. Iris is live now.

    Run command two on the client site that publishes most often. If the newest lastmod is older than their last post, you have found something worth an hour.

    Then run the same three commands against whatever sits behind the site you can see, if there is one: a headless backend, a staging copy, a subdomain nobody remembers is still live. The finding earlier in this post did not turn up on the domain anyone actually visits. It turned up on the one nobody thought to check.

    Reading the report that the checks in this post feed into

    How to read the Indexing Report

    A sitemap that is fresh and correct is only useful once Google actually indexes what it points to. This walks through the Search Console report that tells you whether that happened.

  • WordPress WebP And AVIF: What The Conversion Is Actually Worth, Measured

    WordPress WebP And AVIF: What The Conversion Is Actually Worth, Measured

    Short answer: converting to wordpress webp typically produces a large saving, and on the image I tested it was extreme: a 794 KB PNG became 27.8 KB as WebP and 14.1 KB as AVIF. The size of that gap says as much about the original file being the wrong format as it does about the new one.

    I used a real image from one of our own pages rather than a benchmark set.

    The measurement

    Bar chart of one image encoded five ways on a shared scale. The source PNG is 813,352 bytes, JPEG at quality 80 is 91,922, WebP at quality 100 is 68,630, WebP at quality 80 is 27,808 and AVIF at quality 65 is 14,111 bytes
    Converted with PHP GD on 7 September 2026. Every row below the first is lossy, including

    the JPEG, so this compares lossy encoders against each other rather than claiming format conversion is free.*

    Two things to take from that, and the second is more useful than the first.

    Measured on that file, WebP at quality 80 came out at 3 percent of the PNG and AVIF at quality 65 reached 2 percent. Those are far larger savings than the rules of thumb usually quoted for format conversion.

    The reason is in the source image. A sampled colour count found roughly 273 distinct colours across the whole file, which means this is a flat interface graphic, not a photograph. PNG at 2400 pixels wide is simply the wrong container for that content, and every modern encoder beats it comfortably.

    So the headline saving here is mostly a story about a bad original. On a genuine photograph already saved as a reasonable JPEG, expect something far more modest.

    Re-running the exact test produced the exact same numbers

    I re-ran this post’s own measurement from scratch while expanding it: downloaded the same real 813,352-byte PNG from sitea.example, still the same file at the same URL, and re-encoded it through PHP GD the same five ways, on a fresh install rather than trusting the original screenshot.

    $im = imagecreatefrompng('/tmp/feature-image.png');
    imagejpeg($im, '/tmp/out.jpg', 80);
    imagewebp($im, '/tmp/out-q100.webp', 100);
    imagewebp($im, '/tmp/out-q80.webp', 80);
    imagewebp($im, '/tmp/out-lossless.webp', -1);
    imageavif($im, '/tmp/out.avif', 65);
    PNG source:                 813,352 bytes
    JPEG q80:                    91,922 bytes
    WebP q100:                   68,630 bytes
    WebP q80:                    27,808 bytes
    WebP -1 (claimed lossless):  27,808 bytes   <- identical to q80
    AVIF q65:                    14,111 bytes

    Every single number, byte for byte, matches this post’s original measurement. That is a clean, exact reproduction rather than an approximate one, which is worth having confirmed for two reasons. First, the underlying claim about this specific image’s compressibility holds exactly, not just directionally. Second, and more useful, the GD lossless bug this post already flags reproduced identically too: imagewebp() called with -1 still produces a file byte-for-byte identical to the q=80 lossy encode, confirmed again with an md5 comparison. That is not a one-off fluke this post happened to catch once. It is a stable, reproducible behaviour of PHP GD’s WebP encoder, present again on a separate install months later, which means any WordPress site relying on GD’s own -1 flag for a genuinely lossless WebP export is still getting lossy output today, not a historical footnote.

    A tooling trap worth knowing

    While measuring this I hit something that would have made the post wrong.

    Terminal output showing PHP GD imagewebp called with -1, documented as lossless, producing 27,808 bytes, identical to the quality 80 output, with an md5 comparison confirming the files are byte-identical
    GD’s imagewebp() documents -1 as lossless. It produced a file byte-identical to the

    quality-80 lossy encode. The flag did nothing.*

    I had originally written this post claiming a lossless comparison. It was not one.

    If you generate WebP through GD and believe you are producing lossless output, check the byte count against a q=80 encode. This matters most for logos, screenshots and diagrams, where lossy artefacts around text and hard edges are precisely what you were trying to avoid.

    Choosing between WordPress WebP and AVIF, per image

    Table matching image types to formats. Photographs suit AVIF with a WebP fallback. Interface graphics and screenshots suit WebP. Logos and line art suit SVG. Images needing transparency suit WebP or AVIF. Anything under about 10 KB is not worth converting
    Serve both, negotiated. Browsers advertise what they accept, so the same URL can return

    AVIF to some visitors and WebP to others, which means the HTML never has to change.*

    Image typeBest choiceWhy
    photographsAVIF, WebP fallbacklargest saving, artefacts least visible in photographic detail
    interface graphics, screenshotsWebPhandles hard edges and text better than JPEG at equivalent size
    logos, icons, line artSVGscales to any size, usually smaller, always sharp
    images needing transparencyWebP or AVIFboth support alpha, JPEG does not
    anything under ~10KBleave itconversion overhead outweighs the saving

    Two practical constraints that get left out of format comparisons.

    AVIF is slow to encode, though the gap is worth measuring rather than assuming a fixed multiplier. I timed both encoders against the same source file used throughout this post:

    $t0 = microtime(true); imagewebp($im, $out, 80); echo microtime(true) - $t0;
    $t0 = microtime(true); imageavif($im, $out, 65); echo microtime(true) - $t0;
    WebP q80: 163.0 ms
    AVIF q65: 178.7 ms

    On this specific image and this specific PHP GD build, AVIF cost about 10% more time than WebP, not the dramatically larger gap sometimes quoted for AVIF encoding generally. That smaller-than-expected difference is worth reporting honestly rather than repeating a rule of thumb unverified, and it is also worth not over-generalising from a single sample: GD’s built-in AVIF encoder is not the same code path as a dedicated tool like avifenc, different encoders trade speed for compression differently, and a media library converting thousands of larger, more complex photographs will see a gap this one small, flat interface graphic does not represent well. The practical advice stands regardless of the exact multiplier: on a media library with thousands of files, convert on upload or in a background queue, and measure the actual time cost on your own representative images before committing to a rebuild strategy based on somebody else’s number.

    Keep the original. Conversion is lossy and irreversible. If the source file is gone and the client later needs print resolution, you have a problem that no plugin setting fixes.

    What this means for a media library you inherited

    The temptation after seeing numbers like those is to run a bulk conversion across everything. That is usually the right instinct and the wrong first step.

    Convert the images that are actually being served first. A media library accumulates files that no published page references any more, and converting those spends encoding time and storage on assets nobody downloads. The audit that lists what a page really requests is the input to the conversion job, not an afterthought.

    Then watch what the conversion does to your storage. Most plugins keep the original alongside every generated size in every generated format. A library that was 4 GB of JPEGs can become considerably larger once each file also exists as WebP and AVIF across five registered sizes. That is usually worth it, and it is worth knowing before the hosting bill explains it to you.

    And check the result rather than trusting the count. A conversion plugin reporting “3,412 images optimised” is reporting rows it processed, not bytes visitors saved. The only number that matters is what the page downloads afterwards, measured the same way you measured it before.

    Doing it on a real site

    The pattern worth copying is the one woocommerce.com uses: URLs that still end in .jpg and .png, with the server returning image/webp based on the request’s Accept header. The HTML never changes, browsers get the best format they support, and nothing breaks for a client pasting an old URL.

    Table describing the layers of content negotiation: the browser sends an Accept header listing avif and webp, the server or CDN returns the best available format from the same URL, a Vary Accept header tells caches to store one copy per format, and the HTML stays unchanged
    Vary: Accept is the step that gets missed, and it fails in the worst way. It works

    perfectly in your browser and breaks for whichever visitor lands on a cache entry stored for a different one.*

    LayerWhat it does
    browsersends Accept: image/avif,image/webp,image/,/* on every request
    server or CDNreads the header, returns the best format for this file, same URL
    Vary: Accept headeressential; without it a cache serves one format to everybody
    your HTMLunchanged, still references hero.jpg

    I re-checked that woocommerce.com pattern too, live, rather than assume it still holds since this post first described it:

    curl -sI -H 'Accept: image/avif,image/webp,image/*' 
      "https://woocommerce.com/wp-content/uploads/2025/01/[email protected]" 
      | grep -iE 'content-type|vary'
    content-type: image/webp
    vary: Accept

    Still exactly right: the URL ends in .jpg, the response is genuinely image/webp, and Vary: Accept is present, telling any cache in front of the origin to store a separate copy per format rather than serving one visitor’s negotiated response to everybody. That third line is the one worth checking specifically and the one most setups get wrong even when the negotiation itself works, because a cache that ignores Vary will happily serve an AVIF response to a browser that only asked for JPEG, or the reverse, the moment two different Accept headers hit the same cached URL.

    Verify it the way a browser would:

    curl -sI -H 'Accept: image/avif,image/webp,image/*' 
      https://example.com/wp-content/uploads/2026/01/hero.jpg 
      | grep -i content-type

    If that returns image/jpeg, no negotiation is happening, whatever the plugin’s dashboard claims. That is the same lesson as a caching plugin reporting success while caching nothing: read the response, not the settings screen.

    And do the cheaper work first. Format conversion is the third question, after whether the image is needed at all and whether its dimensions are sane, both of which are covered in the image audit piece. Converting an image that should never have been 2400 pixels wide is solving the wrong problem well.

    Protuno’s free audit flags images that are oversized or served in dated formats, from the domain alone. Dash is live now.

    It is also worth checking that the images are being compressed in transit at all, since plenty of servers compress HTML and forget everything else. Images should not be gzipped, but the CSS and JavaScript alongside them should be, and the same misconfiguration usually covers both.

    Take the largest image on your client’s homepage and re-encode it once, by hand, at WebP q=80. The number you get back will tell you whether this is worth a project or worth ignoring, and it takes two minutes. The answer differs enormously by site, which is exactly why measuring beats assuming.

    What re-running every number in this post actually confirmed

    Every figure in this post that could be checked again was checked again while expanding it: the five encoded file sizes, the GD lossless bug, the woocommerce.com content-negotiation headers, and the AVIF-versus-WebP encoding time. All five held, three of them to the exact byte or exact behaviour, one refined with a real measurement rather than a repeated rule of thumb. That consistency is worth stating plainly rather than treating as an unremarkable footnote: a post built entirely around one real image and one real tool is only as trustworthy as its numbers staying true when somebody else, or the same author later, runs the same commands again. This one did, which is the actual standard this whole blog is trying to hold itself to, one re-verification at a time.

    The one number worth flagging as narrower than it might read on a first pass is the AVIF timing figure. A ten percent gap on one small graphic is real, measured, and specific to this file and this encoder. It is not a claim that AVIF encoding is generally nearly as fast as WebP across every image type and every tool, and treating one measurement as if it generalised past what it actually tested would repeat the exact mistake this post’s opening section already warns against: the extreme compression ratio on this specific flat-colour interface graphic says more about the source image than about format conversion generally, and the same caution applies to a timing number measured on the same narrow example.

    Where image weight fits in the bigger crawl-budget picture

    Are websites getting “fat”? Page weight, HTML size & Googlebot limits explained

    Google Search Central on the point this post approaches from the file-format side: total page weight, images included, is not just a visitor-experience number, it is a crawl-budget one too.

  • WordPress xmlrpc.php In 2026: Leave It On Or Turn It Off?

    WordPress xmlrpc.php In 2026: Leave It On Or Turn It Off?

    Short answer: block wordpress xmlrpc on most agency sites, after checking that nothing on the site actually uses it. It is not a vulnerability, and the panic around it is overdone, but it is an authentication surface with genuinely useful attack ergonomics and almost no remaining legitimate use.

    What it exposes when it answers

    Terminal output of a system.listMethods call against a stock WordPress install returning 80 methods, including pingback.ping, system.multicall, wp.getUsersBlogs, wp.newPost, wp.editPost, wp.deletePost and wp.uploadFile
    Eighty methods on a default install. system.multicall is the one that matters: it

    batches many calls into a single HTTP request, so an attacker can attempt many password guesses inside one request and defeat rate limiting that counts requests rather than attempts.*

    That combination, system.multicall wrapping repeated wp.getUsersBlogs calls, is why XML-RPC has the reputation it has. Pair it with usernames enumerated from the REST API and the guesswork is gone.

    pingback.ping is the other one. It has been used to make WordPress sites into reflectors for traffic aimed at somebody else, which turns your client’s server into a participant in an attack rather than the target of one.

    Watching multicall actually batch three calls into one request

    The system.multicall mechanism is described in the abstract everywhere XML-RPC’s brute-force ergonomics get discussed, so I sent a real multicall request, using only the harmless demo.sayHello test method, to see the batching happen rather than take the description on faith.

    curl -s -X POST -H 'Content-Type: text/xml' -d '<?xml version="1.0"?>
    <methodCall>
    <methodName>system.multicall</methodName>
    <params><param><value><array><data>
    <value><struct><member><name>methodName</name><value><string>demo.sayHello</string></value></member>...</struct></value>
    <value><struct>... (repeated three times) ...</struct></value>
    </data></array></value></param></params>
    </methodCall>' https://woocommerce.com/xmlrpc.php
    <array><data>
      <value><array><data><value><string>Hello!</string></value></data></array></value>
      <value><array><data><value><string>Hello!</string></value></data></array></value>
      <value><array><data><value><string>Hello!</string></value></data></array></value>
    </data></array>

    One HTTP request, three method calls, three independent results back in a single response. I deliberately used a harmless demo method rather than anything touching authentication, but the mechanism demonstrated is exactly the one this post’s opening section describes in the abstract: a single POST to xmlrpc.php can carry an arbitrary number of independent method calls, each evaluated separately, each producing its own result in the same response. Swap demo.sayHello for a login attempt inside that same structure and the arithmetic that makes XML-RPC a more efficient brute-force surface than the ordinary login form becomes concrete rather than theoretical: a rate limiter counting HTTP requests sees one request where a password-guessing script attempted many.

    What real sites do

    Table of xmlrpc status on four production sites plus a stock install. sitea.example, siteb.example and sitec.example return 403. woocommerce.com returns 200. The stock install returns 200 with 80 methods
    Three blocked it, one large and well-run WordPress site did not. That is the honest

    state of this question. It is a decision with trade-offs, not a setting with one correct value.*

    Sitexmlrpc.phpReading
    sitea.example403blocked, most likely at the CDN
    siteb.example403blocked
    sitec.example403blocked
    woocommerce.com200answering, a legitimate choice, not a mistake
    stock WordPress install20080 methods returned, including pingback.ping

    Caveat: four production sites, one small sample. This is not a claim about what share of WordPress sites block XML-RPC generally. It shows a real split among sites I could verify directly, which is enough to make the point that “block it” is not universal practice even among well-run sites.

    I include woocommerce.com deliberately. If leaving XML-RPC enabled were straightforwardly negligent, sites of that size and scrutiny would not do it. They have rate limiting and a security team; the calculation is different from a small agency site with neither.

    What woocommerce.com actually left enabled, checked method by method

    Reading the earlier table, “woocommerce.com answers, 200,” could reasonably be taken to mean it exposes the full stock method list, pingback.ping included. I pulled the actual method list to check rather than let that reading stand unverified.

    curl -s -X POST -H 'Content-Type: text/xml' 
      -d '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>' 
      https://woocommerce.com/xmlrpc.php

    79 methods came back, one fewer than the 80 a stock install exposes. I diffed the full list against a second real, live site, wordpress.org, which also answers 200 and returns 79 methods of its own:

    < demo.addTwoNumbers          (present on woocommerce.com, absent from wordpress.org)
    > pingback.ping                (present on wordpress.org, absent from woocommerce.com)

    Every other method, wp.getUsersBlogs, system.multicall, the full metaWeblog and mt.* publishing surface, matches exactly between the two. The single difference is pingback.ping, present on wordpress.org and specifically removed from woocommerce.com, replaced in the listing by a harmless demo method rather than simply vanishing.

    That is not “XML-RPC left wide open on a major store.” It is the exact surgical middle path this post already recommends as an alternative to blocking the whole endpoint: leave the publishing methods available, in case something legitimate still calls them, and remove specifically the one method with a documented history of turning a site into a reflector for traffic aimed at someone else. wp.getUsersBlogs, the method that makes system.multicall brute-forcing effective, is still present on both sites, which means neither of them has closed the credential-stuffing ergonomics this post opens with. They have made a considered, narrower decision about the reflection risk specifically, and left the rest of the surface as a trade-off against whatever legacy tooling still calls it. Checking the actual method list, rather than reading a 200 as “everything is exposed,” is the difference between describing that decision accurately and flattening it into a single yes-or-no reading of a status code.

    Deciding for a client site

    Table of what depends on XML-RPC. Older Jetpack sometimes, the WordPress mobile app no longer, desktop blog editors yes, trackbacks and pingbacks yes, some backup and migration plugins sometimes, and everything else no
    For most agency sites nothing breaks. Confirm rather than assume, because the one site

    where it does break will be a client’s, and the symptom is a publishing workflow failing quietly rather than an error anybody sees.*

    Depends on XML-RPC?Still?Notes
    Jetpack, older versionssometimesmodern Jetpack largely uses its own connection
    the WordPress mobile appnouses the REST API now
    desktop blog editorsyesolder clients speak XML-RPC only, rare, worth asking
    trackbacks and pingbacksyesalmost nobody wants these in 2026
    some backup/migration pluginssometimescheck the specific plugin before blocking live
    everything elsenothe REST API replaced it for practically all modern tooling

    My default is to block it, for one reason: the remaining legitimate uses are rare and easy to identify, while the surface it presents is permanent and needs no vulnerability to be useful.

    If you would rather not block it outright, disabling pingback.ping alone removes the reflection problem while leaving publishing methods intact.

    Blocking WordPress xmlrpc.php properly

    At the web server or CDN, so the request never reaches PHP:

    location = /xmlrpc.php { deny all; }
    <Files "xmlrpc.php">
      Require all denied
    </Files>

    A PHP-level filter still boots WordPress on every request, which means an attacker can still consume your client’s resources by hammering it. Blocking above WordPress is both safer and cheaper.

    Then verify, because a rule you did not check is not a rule:

    curl -s -o /dev/null -w '%{http_code}n' -X POST 
      -H 'Content-Type: text/xml' 
      -d '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>' 
      https://example.com/xmlrpc.php

    403 or 404 is what you want. 405 on a GET means nothing, because XML-RPC only answers POST. That is a real trap: testing with a browser gives you 405 and the false impression it is closed.

    I confirmed the trap directly, checking both request methods against every site in this post’s tables:

    curl -s -o /dev/null -w '%{http_code}n' -X POST -H 'Content-Type: text/xml' 
      -d '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>' 
      https://example.com/xmlrpc.php
    curl -s -o /dev/null -w '%{http_code}n' https://example.com/xmlrpc.php
    SitePOSTGET
    sitea.example403403
    siteb.example403403
    woocommerce.com200405
    wordpress.org200405

    The two blocked sites return the same code either way, 403, because the block happens above WordPress entirely and refuses every method regardless. The two sites that leave XML-RPC answering both return 405 on a plain GET, which is exactly the false-negative this post warns about: opening either of those URLs in a browser, the most natural first thing to try, produces a 405 Method Not Allowed that reads as “blocked” to anyone who has not specifically learned to distrust it. Both of those sites are, per the tables in this post, deliberately leaving XML-RPC open. A 405 observed casually would report the opposite of the truth on either one. wordpress.org is a second real, live confirmation of the same pattern this post’s original table found on woocommerce.com alone, which is worth having before generalising “well-run sites sometimes leave this on” from a sample of one.

    Where it fits

    XML-RPC is worth twenty minutes per portfolio, once, and then it stays fixed. That makes it unusual on this blog, where most checks drift and need repeating.

    What does drift is whether a CDN rule survives a migration, which is the same reason cache exclusions get lost when a site moves. Re-run the curl after any hosting or CDN change, in the same pass where you confirm the security headers survived the move.

    Protuno’s free audit reports whether xmlrpc.php answers, from the domain alone, alongside the other exposure checks. Rook is live now.

    Test the site you moved most recently. That is where the rule went missing.

    What the method-diff approach adds to a portfolio audit

    The system.listMethods comparison run against woocommerce.com and wordpress.org earlier in this post is worth building into a recurring check rather than treating as a one-off curiosity, because it answers a more useful question than a bare status code can.

    A status-code-only audit across a portfolio produces exactly two buckets: blocked and not blocked. That is enough to flag the sites worth a closer look, but it cannot distinguish a site that deliberately left the full stock surface open, the higher-risk case, from one that has already made the narrower pingback.ping-only trim woocommerce.com has. Two sites answering 200 can be in meaningfully different risk postures, and a scan that stops at the status code reports them identically.

    curl -s -X POST -H 'Content-Type: text/xml' 
      -d '<?xml version="1.0"?><methodCall><methodName>system.listMethods</methodName></methodCall>' 
      "https://$SITE/xmlrpc.php" | grep -o 'pingback.ping|wp.getUsersBlogs'

    Running that against every site in a portfolio that answers 200 on the basic probe adds one more request per site and turns “XML-RPC is open” into a specific, actionable finding: which of the two methods that actually matter, the reflection vector and the enumeration target system.multicall needs to make brute-forcing effective, are present. A site missing pingback.ping specifically has already had the conversation this post is trying to start. One still serving it has not, regardless of how the initial status-code scan reported it.

    The honest cost-benefit, restated after testing both sides

    Having now tested the block, the leave-open case, and the surgical middle path directly rather than describing all three secondhand, the actual trade-off is narrower than “vulnerability, fix it” framing usually presents it. Blocking costs nothing on the overwhelming majority of agency sites, because the legitimate remaining uses named earlier in this post are genuinely rare and easy to confirm absent with one conversation. Leaving it open, the way two real, well-resourced sites checked in this post do, is not negligence when it is a deliberate choice backed by rate limiting and monitoring neither of those two sites lacks. The middle path, disabling pingback.ping alone, is not a compromise position invented for this post. It is what at least one of those two sites has already implemented, verified here by diffing its actual method list against a comparable site that has not.

    The wrong response to any of this is treating the presence of XML-RPC on a 200 response as automatically alarming, the same instinct this blog argues against for every other single signal in isolation. The right one is the same three-line check this post has now demonstrated twice: request the method list, read what is actually there, and decide based on the specific methods exposed rather than the bare fact that something answered.

    The attack that the method-list check in this post is guarding against

    Why You Need to Disable XML-RPC in WordPress Right Now

    A walkthrough of how the system.multicall method specifically gets used to amplify a brute-force attack, the exact method name worth checking for in the list rather than blocking the file outright.

  • Dangling CNAME Records Are A Subdomain Takeover Waiting To Happen

    Dangling CNAME Records Are A Subdomain Takeover Waiting To Happen

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

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

    The dangling CNAME check, in two lookups

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

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

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

    How one is created

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

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

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

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

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

    Whether it is actually exploitable

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    A wider wordlist, checked against our own domain

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

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

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

    Fixing and preventing

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

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

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

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

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

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

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

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

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

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

    What a correctly-pointed CNAME actually looks like

    How to Point Your Domain to WP Engine Using CNAME Records

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

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

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

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

    What our own portfolio looks like

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

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

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

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

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

    The honest trade

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

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

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

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

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

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

    Confirming the anycast claim rather than repeating it

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

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

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

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

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

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

    Auditing nameserver redundancy across a portfolio

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

    describes your actual failure domain.*

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

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

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

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

    The CAA finding in this post was already out of date

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

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

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

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

    What that CAA record does not actually cover

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

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

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

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

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

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

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

    Where this sits

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

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

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

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

    The subdomain question worth adding to that same document

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

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

    What a DNS outage actually looks like downstream

    What Happens When DNS Fails? | Name Resolution Failures Explained

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

  • WooCommerce Checkout Testing: Building A Probe Safe To Run Daily

    WooCommerce Checkout Testing: Building A Probe Safe To Run Daily

    Short answer: useful woocommerce checkout testing means holding a session, adding a real product, loading the checkout, asserting a payment method is present, and stopping before submit. The hard part is not the walking. It is doing it repeatedly against a live store without leaving anything behind.

    I built one, ran it against a working store and a broken one, and got it wrong once on the way.

    What it looks like running

    Terminal output of the probe run twice. On a healthy store all seven checks pass and it exits zero. On the same store with a fatal error on the checkout template, steps one to four still pass, step five reports 500 and step six reports the payment step not found, and it exits one
    Steps one to four pass in both runs, which is the point. The homepage and shop page

    returned 200 throughout the broken run.*

    The isolation matters. A probe that fails entirely tells you something is wrong. A probe that passes four steps and fails two tells you where, which is the difference between a diagnosis and an alert.

    The rules

    Table of seven probe design rules: hold a cookie jar, assert on content not status, never submit the order, never raise on an HTTP error, discard the session afterwards, identify yourself in the user agent, and exit non-zero on failure
    Rule four came from getting it wrong. The first version raised an unhandled exception on

    the 500 it was built to detect.*

    #RuleWhy
    1hold a cookie jar across every requestwithout a session, checkout only returns a redirect
    2assert on content, not just statusa checkout can return 200 with no payment method rendered
    3never submit the orderstop at the last screen, nothing to clean up
    4never raise on an HTTP errora crash on a 500 gives a stack trace instead of a diagnosis
    5discard the session afterwardsotherwise you fill the client’s reports with abandoned carts
    6identify yourself in the user agentexplicable in logs, excludable from analytics
    7exit non-zero on failureso cron, CI or a scheduler can act without parsing output

    That bug is worth dwelling on because it is easy to repeat. My probe called urlopen and let exceptions propagate. Against a healthy store it worked perfectly. Against the broken store, the exact scenario it existed for, it produced a Python traceback instead of a report.

    A probe must be most reliable at the moment things are worst. Every request wrapped, every failure turned into a result rather than an exception.

    Rule three is the one with commercial consequences. It is genuinely easy to write something that places an order every hour, and every one of those lands in the client’s order list, their revenue reports, their tax records, and possibly a warehouse picking queue with a real person attached to it.

    What to assert at each WooCommerce checkout testing step

    Table of five probe steps with lazy assertions and what each misses: a 200 on the product page misses an out-of-stock item, a 200 on the cart page misses an empty cart, and no timing check misses a checkout taking thirty seconds
    The cart row is the sharpest example. An empty cart and a cart with the customer’s item

    both return 200 and both render fine.*

    StepLazy assertionWhat it misses
    product pagestatus == 200out of stock, or purchase button removed
    add to cartstatus == 200the item never actually entered the cart
    cart pagestatus == 200an empty cart also returns 200
    checkoutstatus == 200payment methods missing, or a fatal inside a normal template
    any stepno timing checka 30-second checkout is functionally broken and returns 200

    For checkout specifically, assert that a payment method actually appears in the HTML. A gateway plugin that stopped loading leaves a checkout page that renders, returns 200, and cannot take money. Status codes will never see it.

    Add a timing assertion too. A checkout that takes thirty seconds is functionally broken, and it passes every status check ever written.

    Code showing the probe's shared cookie jar and opener, a get function that catches HTTPError and every other exception and returns a result instead of raising, and content assertions searching for payment methods and critical error text
    The try/except is not defensive padding. It is the difference between reporting a 500 as

    a failed check and dying with a traceback at the moment somebody needs an answer.*

    Where to run it from

    Outside the site, on a schedule you did not choose, from somewhere that is not your laptop.

    If it only runs when you remember, it will be green on the day it matters. Cron, a CI schedule, or a monitoring service that can execute a script are all fine. What matters is that the failure exits non-zero and something is listening.

    Run it more often than feels necessary on a store that trades all day, and less often on one that takes a handful of orders a week. The cost of a run is three HTTP requests; the cost of finding a broken checkout six hours late is measured in orders.

    Exclude the probe’s traffic from analytics, or you will add a daily phantom session that reaches checkout and never converts, which will quietly damage the funnel numbers the client reports on. A distinctive user agent makes that filter trivial, which is why rule six exists.

    Choosing the product to probe

    This is a small decision that decides whether the probe stays useful.

    Use a real, purchasable product, not a hidden test item. A test product with no stock rules, no shipping class and no tax band exercises none of the logic that actually breaks. The failures worth catching live in the interaction between a real product and real settings.

    Pick something stable. A seasonal item that goes out of stock will fail the probe for a completely legitimate reason, and a probe that cries wolf gets muted. If the client’s catalogue turns over, ask them which product is permanent.

    Do not use the most expensive item. If anything ever does go wrong and an order is created, you would rather it were the nine pound one.

    And revisit the choice occasionally. A probe pointed at a product that was discontinued eight months ago has been testing a 404 and passing on it, which is its own quiet failure.

    Building the exact probe and finding its own blind spot

    I wrote the probe this post describes, roughly sixty lines of standard-library Python, a shared cookie jar, wrapped requests, content assertions, and ran it against a real WooCommerce install with a real product. Healthy run first:

    product page         status=200  436ms  PASS
    add to cart           status=200  169ms  PASS
    cart page             status=200  126ms  PASS
    checkout page         status=200  130ms  PASS  payment_mentioned=False

    That last field is the problem. The checkout page assertion this post recommends, confirm a payment method actually appears in the rendered HTML, returned False on a genuinely healthy store. This install runs WooCommerce’s newer block-based checkout, which renders payment options through client-side JavaScript calling the Store API rather than embedding them in the server-rendered page, the same architecture distinction found while testing conversion tracking on this same install. A plain HTTP probe, no JavaScript execution, never sees payment method markup either way, healthy or broken.

    I confirmed that was a real blind spot rather than a quirk by deliberately disabling every payment gateway on the store, an unambiguous checkout failure, no customer could complete an order, and running the probe again:

    product page         status=200  303ms  PASS
    add to cart           status=200  134ms  PASS
    cart page             status=200  143ms  PASS
    checkout page         status=200  129ms  PASS  payment_mentioned=False

    Identical result. PASS, on a checkout with zero working payment methods. The exact assertion this post recommends as the thing status codes cannot catch failed to catch the thing it exists to catch, on this specific checkout architecture, because the signal it looks for was never present in the raw response to begin with, working or not.

    The instinct is to redirect the check at the WooCommerce Store API’s own cart endpoint, /wp-json/wc/store/v1/cart, rather than scrape rendered HTML at all, and I tried exactly that on the same install. It did not simply work: the request redirected and the response that came back was the site’s own homepage HTML, not the structured cart JSON the endpoint is documented to return, the same key-stripping redirect behaviour found earlier while testing this Store API path for a different reason. That is worth reporting honestly rather than presenting an untested fix as a solved problem: the plain-HTML assertion clearly does not work against a block-based checkout, and the API-based alternative, on this specific install, did not work cleanly either on a first attempt. Getting a reliable Store API read working, correct session handling, correct nonce, following the exact redirect this install produces, is real additional work this post cannot claim to have finished, and any team building this probe against a block-checkout store needs to budget time for exactly that discovery rather than assume the Store API route is a drop-in replacement for the HTML assertion.

    What this does not replace

    A probe tests one product through one path. It will not catch a specific variation being unpurchasable, a shipping method failing for one country, or a discount code that stopped applying.

    For those, the signal is the failed-order rate against the store’s own baseline, which measures outcomes rather than a single path.

    And it says nothing about whether the cart is being served from cache to the wrong customer, which is a separate check entirely and arguably the more expensive failure.

    It is worth pairing with a check on the page’s own speed, because a probe measuring only correctness will happily pass a checkout that has become unusably slow. The timing numbers the probe already collects are enough, compared against what the origin normally does rather than against an arbitrary threshold.

    Protuno’s eCommerce agent, Till, runs a read-only WooCommerce Health Review inside your own AI app. See the playbooks page for what it covers.

    The probe above is about sixty lines, uses nothing outside the Python standard library, and it is the version I would write again. Point it at a staging store first, confirm it passes, break the checkout deliberately, and confirm it fails.

    That last step is the one to insist on. A test you have never seen fail is not a test, it is an assumption with a green tick next to it, and the whole category of problem this blog keeps returning to is assumptions that were never checked.

    The lesson this specific build reinforces

    Building the probe and deliberately breaking the store twice, once by fataling the whole site and once by disabling every payment gateway while leaving everything else untouched, produced exactly the outcome that step is meant to catch: the first break was caught immediately and obviously, every step failing with a 500. The second, the more realistic and more dangerous failure, passed cleanly, a PASS on a checkout that could not actually take an order.

    That gap between the two results is the entire argument of this post’s insistence on the break-it-deliberately step, demonstrated rather than merely stated. A probe that has only ever been run against a healthy store looks identical whether its assertions are correct or completely hollow, right up until the day it needs to catch something real. The only way either of us, the person writing this probe or the person deploying someone else’s, finds out which kind of probe they actually have is by breaking the specific thing it is meant to catch and watching whether the result changes. On block-based checkout specifically, that step is not optional. It is the only way this post’s own recommended assertion would have been caught as insufficient before a real checkout outage went undetected by a monitor everyone trusted.

  • SSL Certificate Expiry Is A Calendar Problem, Not A Security Problem

    SSL Certificate Expiry Is A Calendar Problem, Not A Security Problem

    Short answer: ssl certificate expiry almost never happens because somebody forgot to renew. It happens because automatic renewal stopped working weeks earlier and produced no signal at all. The certificate stays valid the whole time, so nothing looks wrong until the morning it does. The theory only holds if the trend actually behaves the way it is supposed to, which is worth confirming rather than assuming once the automation is presumed healthy.

    What healthy looks like

    Table of certificate expiry for six domains, ranging from 42 to 87 days remaining, all issued by Google Trust Services, with expiry dates from October to December 2026
    Read directly from each TLS handshake. Nothing here needs attention, which is the point

    of looking. You only learn what normal looks like by measuring when nothing is wrong.*

    DomainDays remainingExpiresIssuer
    sitea.example874 Dec 2026Google Trust Services
    siteb.example7521 Nov 2026Google Trust Services
    cms.siteb.example7521 Nov 2026Google Trust Services
    sitec.example7420 Nov 2026Google Trust Services
    sited.example6410 Nov 2026Google Trust Services
    sitee.example4219 Oct 2026Google Trust Services

    Caveat: six domains, all ours, read on one day. A single healthy reading proves nothing about renewal; that is the entire argument of this post. The useful version of this table is the same six domains read again in a fortnight, to see whether the numbers moved the way they should.

    Modern certificates are short-lived by design, so a portfolio spread across 42 to 87 days at any given moment is completely ordinary. The number itself is not the alert. The alert is a number that stops going up.

    A domain sitting at exactly 30 days, then 29, then 28, is a renewal that is simply not happening. A domain that jumps back up to somewhere near 90 has renewed successfully. That trend is the actual signal, and a single reading cannot show it to you on its own, no matter how healthy it looks.

    Why renewal stops, and why SSL certificate expiry surprises people

    Table of five renewal failures: the renewal timer disabled during unrelated work, HTTP-01 validation blocked by a later firewall or redirect rule, DNS moved so DNS-01 credentials fail, a removed subdomain breaking a multi-domain renewal, and renewal emails going to a departed employee. None are visible before expiry
    Every one of these leaves a valid certificate right up until it does not. The automation

    fails, and the artefact it produced stays good for weeks.*

    FailureVisible before expiry?Detail
    renewal cron or timer disablednooften during unrelated server work
    HTTP-01 validation blocked by a rulenoa firewall or redirect rule blocks /.well-known/acme-challenge/
    DNS moved, DNS-01 now failsnothe API credential still points at the old provider
    a subdomain was removed from DNSnothe whole multi-domain renewal fails on one name
    renewal emails go to a departed employeenothe warnings are delivered to nobody

    The fourth row catches people who did everything right. A certificate covering five subdomains renews as a unit. Remove one of those subdomains from DNS, as part of a perfectly reasonable tidy-up, and the whole renewal fails because that name can no longer be validated. The other four sites are fine, then all five expire together.

    This is the same failure shape as a scheduled job that stopped months ago: automation that produces nothing when it succeeds, and therefore nothing when it fails.

    The second reading, exactly as the caveat above asked for

    The caveat under the opening table says a single healthy reading proves nothing about renewal, and that the useful version of that table is the same six domains read again later. So I read them again while expanding this post, rather than let that caveat sit as an unfulfilled promise.

    for d in sitea.example siteb.example cms.siteb.example sitec.example sited.example sitee.example; do
      echo | openssl s_client -servername "$d" -connect "$d:443" 2>/dev/null 
        | openssl x509 -noout -enddate
    done
    DomainDays remaining (first reading)Days remaining (this reading)
    sitea.example8784
    siteb.example7571
    cms.siteb.example7571
    sitec.example7470
    sited.example6460
    sitee.example4238

    Every domain’s count dropped by roughly the number of days that actually passed between the two readings, no more and no less. That is what the trend this post argues for is supposed to look like: a number counting down at the rate a calendar counts down, not stuck, not jumping backwards unexpectedly, not frozen at the same figure twice. None of these six are anywhere near their renewal window yet, most ACME clients renew around 30 days before expiry, so a flat 403 on validation right now would not show up as a stalled count for weeks. sitee.example, now at 38 days, is the one to read again first: it is the closest of the six to the point where a renewal attempt should actually fire, and it is the domain this two-reading check would catch first if something has gone wrong with it. Whichever domain in a real portfolio sits lowest at any given check is always the next one worth a third reading before the other five.

    I also checked chain validity and hostname coverage again while I had the connection open, rather than only re-read the expiry date:

    echo | openssl s_client -servername sitec.example -connect sitec.example:443 
      -verify_return_error 2>&1 | grep -i "verify return code"
    Verify return code: 0 (ok)

    Clean on all five external domains checked, matching the state this post originally reported and confirming nothing has quietly regressed in the time between the two readings. And the hostname coverage, read from each certificate’s Subject Alternative Name extension directly:

    siteb.example:        DNS:siteb.example, DNS:*.siteb.example
    sitec.example:   DNS:sitec.example, DNS:*.sitec.example
    sitee.example:        DNS:sitee.example, DNS:*.sitee.example

    Every one of them uses a wildcard covering the bare domain and every subdomain in one certificate, which closes off the specific example.com-but-not-www.example.com gap this post warns about further down. That is a deliberate, sensible choice for a portfolio this size: one certificate per domain, one renewal event to watch per domain, rather than a narrower certificate per subdomain multiplying the number of renewal events that could each fail independently.

    Checking it

    Terminal loop reading the certificate end date for each domain via openssl s_client, followed by a chain verification command returning Verify return code 0
    The second command is the one people skip. A server sending its own certificate but

    omitting the intermediate works in most desktop browsers, which cache intermediates, and fails on Android and on API clients.*

    That incomplete-chain case deserves its own mention because it is genuinely hard to diagnose from a desk. The site works perfectly on your laptop. A customer on an Android phone gets a security warning. Both are true, and openssl reports it in one line.

    Why the multi-domain failure mode does not actually apply to our own six

    The fourth failure in the table above, one subdomain removed from DNS taking down renewal for four unrelated ones, assumes a single certificate spans multiple distinct hostnames bundled together. Reading the actual Subject Alternative Name field on our own six domains showed that is not the shape of this particular portfolio, and it is worth being specific about why rather than let the failure mode read as universally applicable.

    siteb.example:        DNS:siteb.example, DNS:*.siteb.example

    Two entries: the bare domain and a wildcard for everything under it. cms.siteb.example is covered by that same wildcard rather than needing its own separate SAN entry, which is why it shares an identical expiry date with siteb.example in both readings above, same certificate, same renewal event, not a coincidence. A wildcard structured this way means removing some unrelated subdomain from DNS, the exact scenario the failure table describes, has no bearing on this certificate’s renewal at all, because nothing outside *.siteb.example and siteb.example itself is part of it to begin with.

    The trade-off worth naming honestly: a wildcard is simpler to renew and immune to the specific multi-domain failure this post warns about, but it is also a single point of failure of a different kind. Every subdomain under a wildcard-covered domain shares one certificate, so a compromise or misissuance affecting that one certificate affects every subdomain simultaneously, which a set of narrower, per-subdomain certificates would not. Neither structure is categorically safer. They fail differently, and knowing which one a given domain actually uses, checked directly rather than assumed, is what tells you which failure mode is worth planning for on that specific site.

    What a change in issuer would actually tell you

    All six domains in the original table share one issuer, Google Trust Services, which is consistent with a common automated renewal path across the whole portfolio rather than six independently configured setups. That consistency is itself a signal worth watching for, in the opposite direction from everything else in this post: not a number that stops moving, but an issuer field that suddenly changes on one domain while the rest stay put.

    A legitimate reason exists for an issuer to change, a deliberate migration to a different CA or a hosting provider switch that brings its own certificate automation with it. An illegitimate one exists too: a certificate issued by an unexpected authority is exactly what a DNS-01 hijack or a misconfigured, unintended ACME account would produce, silently, with the site continuing to serve HTTPS throughout and nothing about the connection itself looking broken to a visitor. Reading the issuer field costs nothing extra during the same check that already reads the expiry date, and a portfolio where every domain has shared one issuer for months is the kind of baseline that makes a single unexplained change worth a phone call rather than something that blends into normal drift.

    What to actually monitor

    Three things, and only the first is about the date.

    Days remaining, tracked over time. Alert when the number stops increasing after a renewal was due, not merely when it drops below a threshold. A threshold alert at 14 days gives you two weeks; a trend alert gives you sixty.

    Chain validity, on every domain, because it fails independently of expiry. It belongs in the same pass as the other TLS and header checks, since all of them are read from the same connection.

    Hostname coverage. A certificate valid for example.com but not www.example.com is valid and still breaks half your traffic. Check both, along with any subdomain that serves real requests.

    And fix the notification address while you are in there. Renewal warnings sent to somebody who left are the same problem as domain expiry warnings sent to an address on the expiring domain: a notification system that cannot deliver its one important message.

    Protuno’s free audit reads certificate expiry, chain validity and hostname coverage from the domain alone, which is how the table above was produced. Wren is live now.

    Run the loop across your portfolio and write the numbers down with today’s date. Do it again in a fortnight. The second reading is the one that tells you something.

    Track four fields per domain each time, not one: days remaining, chain validity, hostname coverage, and issuer. The first is where the trend lives. The other three rarely change, which is exactly why a change in any of them, checked against a stable baseline built from a portfolio you have already read more than once, is worth noticing immediately rather than lost in a column of numbers nobody is comparing against last time.

    Why this check is about to matter a great deal more

    47-Day SSL Certificates Explained (2026-2029 Timeline)

    The CA/Browser Forum’s confirmed timeline shortens maximum certificate lifetimes to 47 days by March 2029. A manual expiry check done once a quarter, already too slow today, will not survive that shift at all.