Author: Aditya Sharma

  • My WordPress Image Alt Text Audit Found 17,261 Problems. All Of Them Were Wrong.

    My WordPress Image Alt Text Audit Found 17,261 Problems. All Of Them Were Wrong.

    Short answer: any wordpress image alt text audit that counts every tag will produce enormous, useless numbers on a modern site. Lazy-loading inserts placeholder images that correctly have no alt attribute. Exclude anything whose src is a data: URI and the noise disappears.

    I found this the honest way, by nearly publishing the wrong number.

    The number I almost reported

    I crawled 45 pages of one of our own sites and my script reported 17,261 images missing alt attributes. That is the kind of figure that makes a good headline and a bad report.

    It looked wrong, so I opened one page and counted by hand.

    Table of three pages counted naively and then properly. A widget page shows 413 img tags of which 412 are lazy-load stubs, leaving 1 real image and 0 missing alt. The homepage shows 423 tags, 421 stubs, 2 real images, 0 missing. woocommerce.com shows 66 images, no stubs, 0 missing alt and 16 correctly empty
    Every one of the 17,261 was a lazy-loading placeholder. The real count of content images

    missing alt text across those pages was zero.*

    Page tagsLazy-load stubsReal imagesMissing alt
    sitea.example (a widget page)41341210
    sitea.example (home)42342120
    woocommerce.com (home)660660

    Caveat: three pages, two sites. This is not a claim about how common lazy-load false positives are across the web. It is the specific reproduction that turned a reported 17,261 into a verified zero, which is the only number in this post I would stake anything on.

    What the crawler was actually counting

    Code sample of a lazy-load placeholder image tag whose src is an inline SVG data URI twenty pixels square, with a data-src attribute holding the real image URL, annotated with the two mechanical tells
    A twenty-pixel inline SVG waiting for JavaScript to swap in the real file. It has no alt

    attribute because it is not an image anybody sees.*

    Two mechanical tells: the src is a data: URI rather than a URL, and there is a data-src attribute holding the image that will really load. Excluding on the first tell alone took the finding from 17,261 to zero.

    Counting WordPress image alt text in a way you can defend

    Table of four categories. Lazy-load placeholders should not be counted. Empty alt on a decorative image is correct markup and should not be counted. No alt attribute at all is the real defect. Alt text like image or a filename is present but useless and worth flagging separately
    The second row is the one tools get wrong in the other direction. An empty alt is a

    deliberate accessibility decision, and flagging it trains people to ignore the report.*

    CategoryCount it?Why
    src="data:..." with data-srcnolazy-load placeholder, never seen by a user or a screen reader
    alt="" on a decorative imagenocorrect markup, tells a screen reader to skip it
    no alt attribute at allyesthe screen reader falls back to reading the filename aloud
    alt="image" / alt="IMG_4821.jpg"yespresent and useless, harder to detect automatically

    That second row deserves emphasis because it is genuinely misunderstood. alt="" is not a missing alt. It tells a screen reader to skip the image, which is exactly correct for a divider, a flourish, or a background shape. woocommerce.com has sixteen of them and is right to.

    The fourth row is the harder problem and the one worth a human. alt="image", alt="IMG_4821" and alt="banner" all pass an automated check and help nobody. No script can reliably tell you whether alt text is good, only whether it exists.

    Checking our own site turned up a different lazy-loading pattern entirely

    I had not run this audit against protuno.com itself before writing this post, so I did, and the result illustrates the other side of the lazy-loading story: not every lazy-load implementation produces the false-positive problem this post is built around.

    imgs = re.findall(r'<img[^>]*>', html)
    missing = [i for i in imgs if 'alt=' not in i]
    data_uri = [i for i in imgs if 'src="data:' in i]
    Page tagsData-URI stubsMissing alt
    protuno.com/blog/wordpress-crawl-audit600
    protuno.com/blog/wordpress-abilities-api600
    protuno.com/blog/wordpress-page-cache-header500

    Zero data-URI stubs, across every image on three separate pages, and every single tag carries either descriptive alt text or a deliberate empty alt="" on the one decorative element, an author avatar pulled from Gravatar. The mechanism is different from the sitea.example case this post opens with: our own images use the native HTML loading="lazy" attribute rather than a JavaScript-driven swap from a placeholder src to a real one. Native lazy loading tells the browser to defer the request for the real image URL until it is needed, but the src attribute in the markup is the real image the whole time. There is no placeholder tag for a naive audit to miscount, because there is no placeholder tag at all.

    That distinction matters for anyone running this audit on their own portfolio: the false- positive problem this post is built around is specific to one particular lazy-loading implementation, the JavaScript-and-data-URI pattern still common on older themes and page builders, not to lazy loading as a category. A site built more recently, or rebuilt on a framework that uses the browser’s native loading="lazy" attribute instead, will not produce the same inflated count, and running the data: URI exclusion against a site that never had the problem in the first place is harmless. It simply finds nothing to exclude, which is itself worth confirming rather than assuming, the same discipline this post argues for everywhere else.

    The wider lesson

    I have been sceptical of automated SEO and accessibility reports for a while, and this is exactly why. A tool that reports seventeen thousand problems on a site with none is not slightly wrong. It is worse than no tool, because the client stops reading the reports and then misses the real finding when it eventually appears.

    The same discipline applies everywhere on this blog. It is why a 404 from the plugin directory does not prove a plugin was removed, and why a dangling CNAME is only a finding once you check the target can be claimed.

    A number without a verified method behind it is not evidence. It is a number.

    What WordPress core actually outputs when nobody sets alt text

    The fourth row of this post’s category table names filename-style alt text, IMG_4821.jpg, as a real category worth flagging. I went looking for it across the same reference sites this post already checks, and found none:

    suspicious = [a for a in alt_values if a in ('image','photo','picture') 
                  or re.match(r'^(img|dsc|screenshot)[-_]?d+', a)
                  or a.endswith(('.jpg', '.png', '.gif', '.webp'))]
    Site tagsFilename-style or generic alt found
    woocommerce.com660
    wordpress.org180
    sitea.example4230

    Zero across three sites and 507 combined images, which is worth reporting honestly as a clean result rather than manufacturing a finding to fill the category. It also raised a more useful question: where does the filename-as-alt-text horror story people repeat actually come from, if not from WordPress’s own default behaviour?

    I tested it directly. I uploaded a real image to a WordPress install with no alt text set at all, then read what wp_get_attachment_image(), the core function themes and blocks use to render an image, actually output:

    wp media import /tmp/test-photo.jpg
    wp eval 'echo wp_get_attachment_image(93, "thumbnail");'
    <img width="150" height="150" src=".../test-image-photo-150x150.png"
         class="attachment-thumbnail size-thumbnail" alt="" decoding="async" .../>

    alt="". Not the filename, and not a missing attribute either. WordPress core’s own image rendering function always emits an alt attribute, empty when nothing has been set, rather than omitting it or falling back to the filename. That means the specific worst case this post’s category table names, no alt attribute at all forcing a screen reader to read the filename aloud, is not a state WordPress’s own standard image output can produce on its own. It requires either hand-authored HTML in a custom HTML block, a theme or plugin calling directly without going through core’s helper functions, or an older, non-standard integration bypassing the attachment system entirely. Worth knowing precisely because it changes where an auditor should actually look for the true missing-attribute case: not in ordinary post content rendered by WordPress’s own block editor, but in custom template code and hand-written markup, which is a much smaller and more findable set of places to check than “every image on the site.”

    What to actually do about alt text

    Run the corrected count, which will usually be small and manageable. Then fix the images that genuinely lack alt attributes, and spot-check a sample of the ones that have it, because that is where the useless-but-present text hides.

    And set the expectation with whoever adds content: alt text describes what the image conveys in context, not what is in the picture. A photograph of a person on a team page is “Sarah Chen, Head of Delivery”, not “woman smiling”.

    Protuno’s free audit checks image alt attributes from the domain alone, and excludes lazy-load placeholders for the reason above. Iris is live now.

    Where the real defect actually hides, now that the false positives are cleared

    Once lazy-load stubs are excluded and the filename-as-alt myth is set aside, what remains is a narrower, more honest list of places worth an auditor’s actual attention, and it is worth naming them specifically rather than leaving “check for missing alt text” as the entire instruction.

    Custom HTML blocks. Anyone who pasted raw markup into a Custom HTML block bypassed the media library’s alt-text field entirely, and nothing in the editor prompts for one afterwards. This is the single most common source of a genuinely missing alt attribute on a modern WordPress site, precisely because it is the one path that skips core’s own image rendering function.

    Theme template code written before accessibility was a design requirement. A header.php or footer.php calling directly, rather than through wp_get_attachment_image(), produces exactly the missing-attribute case this post’s table names, on every single page the template renders, invisible to a content editor who never touches that file.

    Third-party embeds and widgets. A social media embed, a review widget, a map embed, each one is a black box shipping its own markup, and the same audit that clears a site’s own content needs to be re-run specifically against whatever a plugin injects, because that markup was never subject to WordPress’s own conventions in the first place.

    None of these three show up in a naive count, and none of them show up in the corrected, data:-URI-excluded count either, if the audit only reads the rendered output of the block editor’s own images. They show up only when the check specifically targets the places WordPress’s own helper functions do not reach, which is a smaller, more specific list than “all images,” and considerably more useful to a client than either the 17,261 or the zero this post opened with.

    If you have ever run an alt-text audit and got a number in the thousands, run it again excluding data: URIs. On the two sites checked for this post, the corrected number was zero both times, which is the entire argument for checking rather than trusting the first count. Whatever your real number turns out to be, a proper crawl will tell you which pages to start with.

  • WordPress Database Cleanup: The Autoload Query Everyone Is Running Is Now Wrong

    WordPress Database Cleanup: The Autoload Query Everyone Is Running Is Now Wrong

    Short answer: the standard wordpress database cleanup check for autoloaded options, WHERE autoload = 'yes', returns nothing on current WordPress. The column now holds on, off, auto and the legacy no. A query matching zero rows looks identical to a database with nothing to clean, which is how sites end up carrying megabytes nobody has measured.

    I found this by running the check on a stock install and getting a flat NULL back.

    The values actually in the column

    Table of distinct autoload values on a WordPress 7.1 install. on has 213 rows totalling 66 KB, auto has 84 rows, off has 136, no is present as a legacy value, and yes has zero rows
    Four values in use, and yes is not one of them on a fresh install. Sites upgraded from

    older versions carry no as well, so a correct query has to handle both eras.*

    ValueRowsMeaning
    on213 (66.0KB)explicitly autoloaded
    auto84left to WordPress, which does autoload these
    off136explicitly not autoloaded
    nopresentlegacy value, still found on upgraded sites
    yes0every tutorial’s query target, matches nothing on a fresh install

    Caveat: one install, WordPress 7.1. The column values themselves are a core schema change, documented and verifiable on any current install, not a one-off finding. The row counts are specific to this site and will differ on yours; the query correction is not.

    Two of those values mean autoloaded. on is explicit, and auto leaves the decision to WordPress, which does load them. So a real measurement matches on, auto and legacy yes together.

    Two SQL queries compared. The classic query filtering on autoload equals yes returns NULL. The corrected query filtering on on, auto and yes returns 297 rows totalling 66 KB, followed by a query listing the ten largest autoloaded options
    If you have ever run an autoload audit on a client site and been pleasantly surprised,

    run it again with the corrected clause. Matching nothing looks exactly like nothing to fix.*

    Re-running both queries on a real, plugin-heavy install

    The original finding came from a near-empty stock install. I re-ran both the broken query and the corrected one against a different, considerably busier install, forty plugins present, WooCommerce, Elementor and Yoast SEO active among them, to see whether a real site’s actual plugin load changes the picture.

    SELECT COUNT(*), ROUND(SUM(LENGTH(option_value))/1024,1) FROM wp_options WHERE autoload = 'yes';
    SELECT COUNT(*), ROUND(SUM(LENGTH(option_value))/1024,1) FROM wp_options WHERE autoload IN ('on','auto','yes');
    autoload = 'yes'                  ->  0 rows,   NULL
    autoload IN ('on','auto','yes')   ->  332 rows,  71.9 KB

    The broken query still returns exactly nothing on this second, busier install, confirming the schema change is not a one-off oddity of a fresh install specifically. The corrected query finds 332 autoloaded rows totalling 71.9 KB, close to the original reading’s 297 rows and 66 KB despite this install carrying a genuinely larger and more active plugin set. That similarity is itself worth a note: autoload weight does not scale simply with plugin count, because most of what a busy plugin adds to wp_options lands in the off category correctly, 164 rows and 1,080.6 KB of it here, and never touches every request at all.

    Reading the top of the corrected list on this install shows exactly the kind of drift a real site accumulates that a bare install cannot demonstrate:

    _transient_wp_core_block_css_files   23.22 KB
    _transient_wp_styles_for_blocks      11.08 KB
    wp_user_roles                        10.85 KB
    wpseo_titles                          8.42 KB
    cron                                  4.91 KB
    wpseo                                 4.75 KB

    wpseo_titles and wpseo, together over 13 KB, are Yoast SEO’s own configuration, present here specifically because this install has an SEO plugin active, absent from the original post’s near-empty baseline entirely. That is the practical shape of autoload growth on a real client site: not one runaway option, but each plugin adding its own few kilobytes, none individually alarming, all of it read on every single request regardless of whether the page being served needs any of it.

    Why autoload is the number that matters

    Every autoloaded option is read from the database on every single request, including requests that need none of it. It is loaded before WordPress knows whether the visitor is asking for the homepage, a REST endpoint, or an image.

    Table of the eight largest autoloaded options on a near-empty install: a core block CSS transient at 23.22 KB, rewrite_rules at 17.65 KB, a block styles transient at 11.08 KB, wp_user_roles at 7.94 KB, the cron array at 3.79 KB, and three smaller plugin rows
    66 KB on a site with fifteen posts and four plugins, which is completely fine. The

    reason to know the number is that it only ever grows, and nothing tells you.*

    Note the top of that list: two of the three largest rows are transients that are autoloaded. A transient is a cache with an expiry, and these are being read on every request whether or not anything needs them. That is normal core behaviour rather than a bug, and it is why a site with years of plugin history routinely reaches several megabytes.

    The cron row is worth a glance too. That single option holds the entire scheduled-task array, which means a site whose scheduler has quietly stopped is still paying to load its backlog on every page view.

    On this install, options beat postmeta, and that is worth explaining rather than hiding

    Running the full table-size query this post recommends against the same lab install produced a result that contradicts this post’s own claim about which table usually dominates, and it is worth showing rather than quietly adjusting the earlier text to match.

    SELECT table_name, ROUND((data_length + index_length)/1024/1024, 2) AS mb, table_rows
    FROM information_schema.tables WHERE table_schema = DATABASE()
    ORDER BY (data_length + index_length) DESC LIMIT 8;
    wp_options                    2.14 MB   491 rows
    wp_actionscheduler_actions    0.16 MB    17 rows
    wp_wc_orders                  0.16 MB     0 rows
    wp_comments                   0.14 MB     3 rows
    wp_posts                      0.14 MB    32 rows

    wp_options at 2.14 MB, wp_postmeta nowhere in the top five at all, a mere 0.05 MB across 107 rows. This post’s earlier section calls wp_postmeta “almost always the biggest,” and on this specific install that is simply not true.

    The reason is install age, not a flaw in the general claim. wp_postmeta grows with content volume and history, every post, every revision, every piece of metadata a plugin ever attached, accumulated over years. This lab install has 32 posts and no real editorial history behind it. wp_options, by contrast, front-loads a large amount of its weight the moment plugins are activated, page-builder configuration, SEO plugin settings, WooCommerce’s own setup data, regardless of how much content exists yet. A young, plugin-heavy site can easily carry more weight in wp_options than in wp_postmeta for exactly that reason, and the general claim this post makes describes the steady state a site reaches after years of content, not the shape of a site in its first months. Running the actual query, rather than trusting either claim by default, is the only way to know which shape a specific site is actually in, which is the entire argument this post has been making from its first paragraph, now proven against itself.

    The tables that actually grow

    Autoload is the number that affects every request, but it is rarely the largest thing in the database. Four tables do most of the growing on a long-lived site.

    wp_postmeta is almost always the biggest. Every plugin that attaches data to posts writes here, and a plugin removed two years ago usually left its rows behind. Orphaned rows, where the parent post no longer exists, are safe to delete once you have verified the join.

    wp_options grows in two directions: autoloaded rows that cost you on every request, and expired transients that cost you storage. Both are worth clearing, for different reasons.

    wp_comments and wp_commentmeta on any site that has ever had comments open. Spam that was caught and never emptied sits there indefinitely.

    Plugin tables. Form submissions, order line items, activity logs, licence checks. These are the ones that reach gigabytes, and they are the ones a generic cleanup tool must never touch.

    Measure before assuming:

    SELECT table_name,
           ROUND((data_length + index_length)/1024/1024, 1) AS mb,
           table_rows
    FROM information_schema.tables
    WHERE table_schema = DATABASE()
    ORDER BY (data_length + index_length) DESC
    LIMIT 12;

    That single query tells you exactly where the size actually is, which is frequently not where you expected it to be.

    What is safe to remove

    Table of cleanup targets by safety. Expired transients, auto-drafts, trashed posts, spam comments and orphaned postmeta are safe. Post revisions and autoloaded options from removed plugins need checking first. Plugin-owned tables are never safe to guess at
    The bottom row is where real damage happens. A generic cleanup tool offering to empty

    tables it does not recognise is offering to delete a client’s orders.*

    TargetSafe?Notes
    expired transientsyesa cache by definition, WordPress regenerates them
    auto-drafts and trashed postsyesrespect the trash retention period first
    spam and trashed commentsyes
    orphaned postmeta and termmetayesverify the join before deleting
    post revisionsask firstsome clients genuinely use them
    autoloaded options from removed pluginscheck firstset autoload to off rather than delete
    anything in a plugin’s own tablesnoorder data, form entries, licence records

    Two judgement calls worth making deliberately.

    Revisions. Purging them is the default advice and it is wrong for some clients. Editors who actually use revisions will notice. Cap them going forward with WP_POST_REVISIONS instead of deleting history somebody may rely on.

    Options from removed plugins. Set autoload to off rather than deleting the row. The plugin may come back, and an option that is not autoloaded costs nothing on requests that do not read it.

    Running a WordPress database cleanup without breaking anything

    The order matters more than the technique.

    Take a backup and prove it restores. Not take one. Prove it, because a cleanup is exactly the operation you might need to undo.

    Measure before. Record the autoload total and the ten largest rows. Without a before number you cannot tell a client what changed.

    Change one category at a time. Transients, then trash, then orphaned meta. If something breaks you want to know which step did it.

    Measure after, and load the site. Homepage, a form, the checkout if there is one.

    On most sites this recovers less than people expect, and that is worth saying plainly. Database cleanup is not usually the reason a site is slow. It is worth doing because unbounded growth eventually becomes a real problem, not because it will transform a page load next Tuesday.

    If the site genuinely is slow, start with where the time is actually going rather than with the database, or you will spend an afternoon optimising something that was never the bottleneck.

    Protuno’s Performance agent, Dash, carries a Database Optimisation playbook. It can change a site, so it waits for the owner’s approval in Protuno and takes a backup first. Dash is live now.

    One habit worth adopting, because it turns this from a one-off tidy into something useful. Record the autoload total and the top three tables in the client’s notes each quarter, with the date. Four numbers, thirty seconds. The value is not any single reading; it is that a year later you can see the shape of the growth and say something specific, like this table has tripled since March and here is what changed then. Nobody can act on “the database is quite big”. Everybody can act on a trend with a date attached.

    Run the corrected autoload query on your largest client site today. If it comes back over a megabyte, you have found something worth an afternoon.

    Run the table-size query on the same site immediately afterwards, rather than stop at autoload alone. The two queries answer different questions, one measures the cost paid on every request, the other measures where the disk space actually sits, and a site can score badly on either one independently of the other. Checking both, on the actual site in front of you rather than assuming which one matters more from a general rule, is the only way to know which afternoon is actually worth spending.

    Seeing the autoloaded-options problem on a real site

    EASY! How To Cleanup WordPress Database Fast!

    A practical walkthrough of the exact Site Health warning this post’s autoload query is built to catch, including why the warning does not update immediately after a fix ships.

  • WordPress User Enumeration Is Still On By Default, And Two Of Our Sites Prove It

    WordPress User Enumeration Is Still On By Default, And Two Of Our Sites Prove It

    Short answer: wordpress user enumeration is available to anyone through /wp-json/wp/v2/users, through /?author=1, and sometimes through the user sitemap. None of these is a vulnerability. All of them hand over the usernames that a password attack needs, and the defaults are open.

    I checked four production sites rather than describing this in the abstract.

    What real sites expose

    Table of four sites showing the users endpoint result and xmlrpc status. sitea.example and siteb.example return 200 with ten usernames each and block xmlrpc. sitec.example blocks both. woocommerce.com returns 404 on users but 200 on xmlrpc
    The first two rows are ours, and they expose ten usernames each. Every combination is

    represented in this small sample, which is a fair picture of production WordPress.*

    SiteREST usersUsernames exposedxmlrpc.php
    sitea.example20010403
    siteb.example20010403
    sitec.example4030403
    woocommerce.com4040200

    Caveat: four sites, checked once, two of them ours. Not a survey of WordPress generally, and I would not extrapolate a percentage from it. It is enough to prove the point the post makes: this is not a rare misconfiguration, it is two out of four sites we happened to check while writing about it, including our own.

    An attacker needs a username and a password. Half of that is being published on request, with no authentication, in a machine-readable format.

    Checked again, and the admin slug this post warns about is still there

    I re-ran the full three-route probe against all four sites before expanding this post. The overall pattern held: sitea.example and siteb.example still answer 200 on the REST users endpoint, sitec.example still returns 403, woocommerce.com still returns 404.

    I also read the actual response from our own two exposed sites this time, rather than only confirm the status code, because the content matters as much as the count.

    curl -s https://sitea.example/wp-json/wp/v2/users 
      | python3 -c "import json,sys; [print(u['slug']) for u in json.load(sys.stdin)]"

    Ten real accounts came back, real names and slugs belonging to actual people on our team, which I am deliberately not reproducing here. Publishing a full staff roster in a blog post about not leaking information would be a strange way to make this post’s point. What is worth reporting plainly: one of those ten slugs is the literal string admin, on an account displaying as the parent company’s real name. That is exactly the worst case this post already names, an enumerable account confirming the highest-value username on the site, sitting unfixed on our own infrastructure since this post first published.

    siteb.example also still exposes ten real slugs, none of them literally admin, though one follows an easily-guessable pattern combining the brand name with “admin.” Neither site has applied any of the three fixes this post describes since the original check.

    The ?author=1 route, checked fresh, showed a difference worth naming between the two exposed sites. siteb.example still redirects, 301, straight to /author/siteb-master-admin/, naming the login in the Location header exactly as this post’s stock-install test found. sitea.example answers 200 directly rather than redirecting, serving the author archive page itself with the account’s name in the page title, a different mechanism reaching the same outcome: the login name reaches an unauthenticated visitor either way, whether by a header a scanner reads without rendering the page or by a title tag a human reads by looking at it.

    woocommerce.com‘s ?author=1 now redirects to the bare homepage rather than an author archive, which is the correct behaviour this post recommends and confirms it has been applied there, matching that site’s REST endpoint already returning 404. Two different routes, independently closed, on the one site in this comparison that treats this as solved rather than merely quiet.

    Three routes, not one

    Table of three enumeration routes. The REST users endpoint returns JSON with name, slug and author link. The author query string redirects to the author archive, confirming the login name. The core user sitemap lists author archives where enabled
    The author-archive redirect is the one people forget. You can block the REST endpoint

    completely and still hand over every username through a numbered query string, because that behaviour predates the REST API by a decade.*

    RouteResultWhat it returns
    /wp-json/wp/v2/users200JSON array with name, slug and author link
    /?author=1301redirects to /author/admin/, naming the login in the header
    /wp-sitemap-users-1.xmlvariescore’s user sitemap, where enabled

    I confirmed all three on a stock install. /?author=1 returned a 301 to /author/admin/, which names the account in the Location header before the page even loads.

    Closing WordPress user enumeration on all three routes

    Three fixes, because there are three routes.

    The REST endpoint. Require authentication for user listing:

    add_filter( 'rest_authentication_errors', function ( $result ) {
        if ( ! empty( $result ) ) { return $result; }
        if ( ! is_user_logged_in()
             && str_contains( $_SERVER['REQUEST_URI'] ?? '', '/wp/v2/users' ) ) {
            return new WP_Error( 'rest_forbidden', 'Authentication required.', [ 'status' => 401 ] );
        }
        return $result;
    } );

    The author query string. Redirect or block ?author= on the front end, before the redirect to the author archive happens.

    Table of three enumeration routes with their fixes and how durable each is. The REST filter, the author query string block and the user sitemap filter are all medium durability. Making the display name differ from the login name is high durability
    The bottom row is the one I would do first. The other three are filters a future plugin

    update can step around. A login name that was never public cannot be leaked by a route nobody anticipated.*

    RouteFixDurability
    /wp-json/wp/v2/usersrequire auth via rest_authentication_errorsmedium, a plugin can re-register the route
    /?author=Nblock before the archive redirect firesmedium, depends where you hook it
    /wp-sitemap-users-1.xmlfilter the user sitemap provider outmedium
    all of them at oncedisplay name differs from login namehigh, nothing to break

    Nicknames. Set the public display name to something that is not the login name. This is the one that keeps working when the other two are undone by a plugin update, and it is the only fix that survives a route nobody has thought of yet.

    Reading the rest of what the endpoint returns, not just the username

    The REST users endpoint on sitea.example returns more than a name and slug. I read the full response for one account to see what else is riding alongside the username this post is built around.

    curl -s https://sitea.example/wp-json/wp/v2/users | python3 -c "
    import json, sys
    u = json.load(sys.stdin)[0]
    print(list(u.keys()))
    "

    Alongside id, name, slug and avatar_urls, a meta object carries fields prefixed rnrd_author_: a job title, an employer, a headshot URL, social profile links, and a biography paragraph. That is not a leak. It is a deliberate author-schema plugin, the kind that feeds structured data to search engines for an “About the author” byline, publishing exactly the public-facing biography it was configured to publish. Distinguishing this from the username finding above matters for the same reason this post’s caveats elsewhere insist on precision: a meta object full of fields is not automatically alarming, and treating a deliberately public author bio as a data leak would be exactly the kind of over-broad finding that trains a client to stop reading a security report closely.

    What the same response confirms, by its absence, is one of core’s own good defaults holding up: no email field anywhere in the payload, on any of the ten accounts checked. WordPress core restricts the email address to authenticated requests from an account with the edit_users capability, and that restriction held on every account tested here, unauthenticated, exactly as it should. It is worth checking specifically rather than assuming, since a plugin that adds its own user-facing endpoint can bypass this restriction without anyone noticing until the response is actually read field by field, the same way the meta fields above were.

    One smaller, genuine defect turned up while following the third route, the user sitemap, to its actual destination rather than stopping at its redirect:

    curl -sI https://sitea.example/wp-sitemap-users-1.xml | grep -i location
    location: https://sitea.example/author-sitemap.xml

    That redirect target itself returns 404. Core’s own user sitemap route correctly redirects toward an SEO plugin’s replacement sitemap, and the replacement does not exist at the URL being redirected to. Not a security finding, since a 404 discloses nothing, but a broken link in the site’s own sitemap infrastructure, the kind of drift a crawl audit like the one covered elsewhere on this blog is built to catch and a manual check of this one route happened to catch here instead.

    How much does this matter

    Honestly: on its own, not very much. A username is not a credential, and any site with a strong password and rate limiting is not going to fall to this.

    It matters in combination. Enumerated usernames plus an open XML-RPC endpoint is a credential-stuffing setup with the guesswork removed. Enumerated usernames plus an old plugin with a known authentication flaw is worse.

    Treat it as removing free information rather than as patching a hole. That framing is also more honest with clients than calling it critical.

    There is one case where it is worse than an inconvenience, and it is worth checking specifically. If a site still has an account literally named admin, as the stock install I tested did, then enumeration confirms the highest-value target on the site by name. Renaming that account, or at minimum giving it a display name that differs, removes more risk than any of the filters above.

    Why it stays open

    Because nothing about it is broken. The endpoint is behaving exactly as designed, the site works perfectly, and no scanner your client runs will produce an alarm they understand.

    It is another item in the category this blog keeps returning to: cheap to fix, invisible while open, and never on anybody’s Tuesday list. The same as a debug log left readable or a backup file sitting in the web root.

    Protuno’s free audit checks REST user enumeration from the domain alone, alongside the other information-disclosure checks, which is how the table above was built. Rook is live now.

    Run curl -s https://yourclient.com/wp-json/wp/v2/users on the site you administer most. It takes two seconds and you will know immediately.

    What to actually write down after running the check

    Given everything found while re-checking this post, the report worth producing from this audit has four lines, not one: whether the endpoint answers at all, whether any exposed slug matches admin or an obvious variant of the company name, whether the ?author= route leaks the same information by a different mechanism, and whether anything in the response beyond the username was never meant to be public, distinct from a deliberately published author bio.

    The first two are the urgent half. An exposed admin slug is the single highest-value piece of information this whole category of check can surface, and it is the one line worth a same-day fix rather than a note for the next quarterly pass. The other two are worth recording precisely without treating them as equally urgent: a redirect leaking the same username through a second route is real but adds no new information an attacker did not already have from the first route, and a field in the response that turns out to be a deliberately public author bio is worth confirming as intentional and then setting aside, not escalating alongside a genuine gap.

    Our own two exposed sites, checked again for this post, land in exactly that shape: one real admin slug worth fixing today, ten real names on each site that were already going to be public through the sites’ own author pages regardless of the REST endpoint, and no email addresses or other genuinely private fields anywhere in either response. That is a specific, proportionate finding, not a generic “usernames are exposed” alarm, and it is the version worth handing to whoever owns fixing it.

    The fix, demonstrated

    Stop REST API User Enumeration in WordPress (2026 Fix)

    Walks through the same /wp/v2/users request this post uses to find the exposure, and the separate-display-name-from-login-name fix this blog’s own portfolio audit landed on too.

  • The WordPress Update Process, Written Out Before It Runs

    The WordPress Update Process, Written Out Before It Runs

    Short answer: a defensible wordpress update process has eight steps, and two of them are the ones that get dropped: proving the backup restores, and walking a real user journey afterwards. Those two are also the ones that catch the failures that cost money.

    Here is ours, published in full, with the machine time measured on a controlled install.

    The sequence

    Table of eight update steps with measured times: record installed versions, take a database dump at 53 milliseconds, restore that dump and count rows at 125 milliseconds, archive wp-content at 695 milliseconds, apply updates at 10 seconds, load the homepage at 300 milliseconds, walk a real journey, and restore and stop if anything fails
    About eleven seconds of machine time in total. The value is not the speed, it is that the

    sequence is written down before it runs, so it does the same thing this Tuesday that it did last Tuesday.*

    #StepMeasuredWhy it is in the list
    1record installed versions<1swithout a before-state you cannot say what changed
    2take a database dump53mscheap
    3restore the dump, count the rows125msthe step almost every process skips
    4archive wp-content695msa database backup alone cannot rebuild a site
    5apply the updates10sthe part everyone thinks the job consists of
    6load the homepage, assert it responds300mscatches a fatal immediately
    7walk a real journey: a form, or checkoutsecondsa 200 on the homepage says nothing about the cart
    8if anything fails, restore and stopn/athe rollback is part of the playbook, not an incident response

    Caveat: these timings are one controlled install, small database, local disk. Scale them for a real client site with a larger database and a remote backup destination. The relative shape holds: steps 2 through 4 are consistently the cheap ones, step 5 dominates because it waits on a third party, and none of that changes what steps 3 and 7 are protecting against.

    Step three is the one I would argue for hardest. Taking a backup and having a backup are different things, and a truncated dump that restores 8 tables out of 77 looks identical to a good one in a folder listing.

    Step seven is the other. A homepage returning 200 says nothing about whether the checkout still works, which I demonstrated by breaking one deliberately while every other URL stayed green.

    Running the same eight steps again, on an install closer to a real client site

    The original timings came from a controlled, small install. I ran steps one through four again on a second, much busier install, forty plugins, WooCommerce, Elementor, several page-builder add-ons, to see how far the numbers actually move once the site looks like something a real agency would inherit rather than a clean lab environment.

    step 1  record versions              instant
    step 2  database dump                250ms
    step 3  restore under renamed prefix, count rows   750ms, 26 posts confirmed
    step 4  archive wp-content           41.1 seconds, 405 MB

    Steps two and three barely moved, still comfortably sub-second, because the database itself stays small regardless of how many plugins are installed. Step four is where the difference actually lives: 695 milliseconds on the original near-empty install against 41 seconds here, because wp-content/plugins alone accounts for 1.4 GB on this install, Elementor and its add-ons being the overwhelming majority of it. That is not a criticism of the site. It is exactly the shape a real, actively maintained WordPress install takes, and it confirms this post’s own caveat directly: step 5, applying the updates, dominates the original budget because it waits on a third party, but step 4 is the one whose cost scales hardest with how much plugin weight a specific site is actually carrying.

    The practical consequence worth adding to the playbook: budget step 4’s time per site rather than assuming the original eleven-second total transfers unchanged. A portfolio of forty client sites with wildly different plugin footprints will see wildly different step-4 times, and a playbook that assumes every site archives in well under a second will be surprised, repeatedly, by the handful of sites carrying a gigabyte or more of plugin code.

    What each step is actually for

    Table of five real failure modes matched to the step that catches each: a plugin fatal caught by loading the homepage, a broken checkout caught by walking a journey, a truncated backup caught by the restore test, missing uploads caught by archiving wp-content, and an unrecorded version caught by the inventory step
    Steps 3 and 7 are the two dropped when a process is written in a hurry, and they are the

    two that catch the expensive failures. Everything else is logistics.*

    What goes wrongCaught byIf that step is missing
    a plugin update fatals on a templatestep 6the site is white until someone loads it
    the update succeeds, checkout breaksstep 7homepage returns 200 all day while the store takes nothing
    the backup was truncated mid-writestep 3discovered while restoring, the worst possible moment
    database restored, uploads did notstep 4the site comes back with every image missing
    nobody can say what version it was onstep 1you cannot roll back to a state you never recorded

    Steps 6 through 8, run for real rather than described

    The homepage check, the journey walk, and the rollback are the three steps that only mean anything once something has actually gone wrong, so I forced a real failure on a separate test run to confirm all three fire correctly together rather than trust each one in isolation.

    Corrupting a plugin’s main file to simulate a bad update produced a genuine site-wide 500, caught immediately by step 6, a plain homepage request. Restoring only the plugin’s files, never touching the database, brought the site back to 200 in one request, and a real order created before the fault was introduced survived the whole cycle untouched, confirmed by checking its status directly rather than assuming the restore left it alone. That full sequence is written up in detail separately, and it is the same eight-step shape this post describes, run end to end rather than only described in a table.

    Step 7 is the one worth being most honest about, because it is also the hardest to automate correctly. A checkout-walking probe built to assert on rendered payment-method HTML worked perfectly against one WooCommerce install and passed silently, wrongly, against a second one running the newer block-based checkout, where that exact assertion never has anything to match against regardless of whether the store is healthy or completely broken. That gap is documented in full in the checkout probe post, and it is the clearest evidence available that step 7 cannot be written once and trusted forever. It has to be verified against the specific site it is meant to protect, the same way this playbook argues for verifying every other step, because “the probe passed” and “the probe is actually testing the right thing” are two different claims that can silently diverge.

    The step people argue about

    Step three, the restore test, gets pushed back on more than any other, and the objection is reasonable: it doubles the storage and the time for something that has never gone wrong.

    Two answers. The time is 125 milliseconds on a small site and a few seconds on a large one, which is not the constraint anybody thinks it is. And “it has never gone wrong” is not evidence when nobody has ever checked, which is the exact situation the step exists to correct.

    The genuine version of the objection is about scale: restoring a 40 GB database into a scratch environment on every update is not free. The practical compromise is to verify structurally on every run, by confirming the dump completed and the table count matches, and to do a full restore test on a schedule rather than on every update. That is weaker than verifying every time and much stronger than never verifying, and it is worth saying which one you are doing.

    Why a fixed list rather than a clever one

    This is the part worth arguing about, because the obvious modern answer is to let something reason about each site individually.

    Comparison table of a written playbook against a model deciding at runtime, across whether you can read it before allowing it, whether it repeats identically, whether it can be audited, whether an insurer can accept it, and whether it handles unanticipated cases
    The last row is the honest trade and it cuts both ways. A playbook stopping on an

    unanticipated case is a feature near a client site, because stopping is safe and improvising is not.*

    An agency cannot audit a judgement call. It can audit a list of steps.

    That is the entire reason to write the sequence down in advance: you can read it before you allow it. A client asking what exactly will happen to their site gets a document rather than a reassurance.

    The cost is real and I would rather state it than gloss it. A fixed playbook does not handle the case nobody anticipated. It stops, and somebody has to look. On a client site that is the correct behaviour, and it is still a limitation.

    Writing a WordPress update process for your own agency

    You do not need software to benefit from this. The document is most of the value.

    Write the steps in the order you actually do them, including the ones that feel too obvious to write down. Those are the ones that get skipped under time pressure.

    Put the verification steps in explicitly. “Take a backup” is a step. “Restore the backup into a scratch database and confirm the row counts match” is a different step, and only the second one is a guarantee.

    Define the stop condition. What makes you abandon the update and restore? Decide that when nothing is on fire, not at the moment it is.

    Keep the before state. Versions, a database dump, an archive. You cannot roll back to a state you never recorded, which is step one and the least interesting item on the list.

    Table of three permission levels: Read Only which analyses and reports without changing anything, Ask First which prepares a change and stops for approval, and Auto-Run which executes approved playbooks without stopping, each with guidance on when to use it
    If the playbook is going to run unattended, the permission it runs under matters as much

    as the steps. Start on Read Only, move to Ask First when the reports have been right for a while, and treat Auto-Run as something a specific playbook earns rather than a setting you switch on.*

    Then run it identically every time, on every site. The consistency is what turns it from a habit into something you can charge for and show a client afterwards.

    Protuno’s Care agent, Wren, runs exactly this sequence, and the eleven-second measurement above came from timing it. Wren is live now.

    There is one more decision that belongs in the document rather than in somebody’s head: what happens to the update that should not be applied at all. A plugin flagged as incompatible, a major version that crosses a breaking change, a site still on a PHP branch that the new version does not support. A playbook that applies everything it finds is not safe just because it takes a backup first. The list of updates to hold, and who decides, is part of the process.

    The other thing worth writing down is what you do when the update succeeds and something breaks a day later. Rollback within the run is the easy case. A regression that surfaces on Thursday needs the before-state from step one to still exist, which means retention is a decision too: how long you keep each backup, and where.

    Until it ships, the document is the useful half. Write your update sequence down this week, add the two steps that are probably missing from it, and use the same one on every site. The consistency is worth more than the cleverness.

  • WooCommerce REST API Security: The Defaults Are Right, So Check What Changed Them

    WooCommerce REST API Security: The Defaults Are Right, So Check What Changed Them

    Short answer: woocommerce rest api security is sound out of the box. Every admin endpoint refuses an unauthenticated request. The Store API is public deliberately, because it powers the block storefront. What is worth auditing is everything added on top: API keys, plugin endpoints and custom routes.

    I probed a test store and, later, two separate live ones, rather than assume either way from a single sample.

    What answers, and what does not

    Table of six WooCommerce endpoints tested unauthenticated. Orders, customers, products and sales reports all return 401 on both a test store and woocommerce.com. The Store API products and cart endpoints return 200, marked as public by design
    The honest headline is that WooCommerce gets this right. Every admin endpoint refuses the

    request, and the two that answer are meant to.*

    EndpointTest storewoocommerce.comBy design?
    /wc/v3/orders401401yes, admin API, key required
    /wc/v3/customers401401yes
    /wc/v3/products401not testedyes
    /wc/v3/reports/sales401not testedyes
    /wc/store/v1/products200200yes, powers the block storefront
    /wc/store/v1/cart200not testedyes, session-scoped, returns your cart only

    Caveat: two stores, one test and one live, checked once. This does not prove every WooCommerce install behaves the same; it proves the defaults are correct on the two I could verify directly, which is a reasonable basis for trusting the defaults and auditing what was added on top instead.

    The Store API returning 200 is not a finding. It exists so a block-based storefront can fetch products and cart contents from the browser. The cart endpoint is session-scoped: it returns your cart, keyed to your cookie, not anybody else’s.

    I checked that specifically while testing cart caching, and two separate sessions returned entirely different payloads, which is the correct behaviour.

    What is actually worth checking for WooCommerce REST API security

    Table of four real failures: an API key issued with read/write and never revoked, a plugin registering its own unauthenticated endpoint, a custom endpoint with a permission callback returning true, and order data exposed through an export endpoint
    None of these are WooCommerce defaults. All four are things added to a store afterwards,

    and none of them announce themselves.*

    FailureHow it looksHow to find it
    API key issued read/write, never revoked401 to you, 200 to whoever holds itSettings → Advanced → REST API, check last-access dates
    plugin registers its own unauthenticated endpoint200read /wp-json/, look at every unfamiliar namespace
    custom permission_callback returning true200code review, or probe the routes it advertises
    order data exposed via search or export endpoint200probe with a query string; some exporters skip auth on parameters

    The second row is the most common by a distance. Every plugin that registers a namespace under /wp-json/ adds surface, and the REST index lists them all for anybody who asks.

    The first row is the one with the longest tail. An API key issued to a developer, an integration, or a migration tool three years ago is still valid until somebody revokes it. WooCommerce records a last-access date against each key, which makes the review quick: anything unused for months is a credential nobody is monitoring.

    Reading the full namespace list on a real, high-traffic store

    The advice to read /wp-json/ before probing anything is easy to state and more interesting once you actually do it against a site with a lot of integrations installed. I pulled woocommerce.com’s namespace list in full.

    curl -s https://woocommerce.com/wp-json/ | python3 -c "
    import json, sys
    d = json.load(sys.stdin)
    print(len(d['namespaces']))
    for n in d['namespaces']: print(n)
    "

    89 distinct namespaces, on one production store. Payment processing (paypal/v1, payments/woopay), analytics and email tooling (wc-analytics, wccom-mailchimp/v1, wccom-klaviyo/v1), support and billing (wccom/support-form, wccom/billing/1.0), a whole family of wccom-* routes for the store’s own marketplace features, and two worth calling out specifically for readers of this blog: mcp and wp-abilities/v1, registered and answering on woocommerce.com’s live site right now. The same MCP and Abilities API surface covered in an earlier post is not a future feature here. It is already live on one of the highest-traffic WordPress installs there is.

    Reading the list is not itself a finding, and I want to be precise about that rather than let a large number read as alarming on its own. Requesting a namespace’s own index, GET /wp-json/wc-admin or GET /wp-json/wc-analytics, returns a route map, method names and argument schemas, the same category of metadata /wp-json/ itself already exposes for the whole site. I checked what a few of the more interesting-sounding ones actually return:

    curl -s -o /dev/null -w '%{http_code}n' https://woocommerce.com/wp-json/wc-analytics
    curl -s -o /dev/null -w '%{http_code}n' https://woocommerce.com/wp-json/wc-analytics/reports/revenue/stats
    200
    401

    The namespace index answers 200 with a route map. The actual revenue data one directory deeper answers 401, exactly as the earlier table in this post says every real admin endpoint should. That is the distinction worth internalising: a namespace existing and answering 200 at its own root tells you what a plugin registered, not what it will hand over. The finding is never in the list. It is in whichever specific route inside that list you have not yet checked individually, which is the entire argument for reading the index before assuming any of it is either safe or dangerous.

    Checking the MCP endpoint specifically, since it is the newest surface

    The mcp namespace is worth a closer look than the others, because it is the newest addition to this list by a wide margin and the category this blog has been tracking as it rolls out across WordPress. Reading its route map:

    curl -s https://woocommerce.com/wp-json/mcp | python3 -m json.tool

    turns up one functional route beneath the namespace index itself, /mcp/mcp-adapter-default-server, accepting POST, GET and DELETE. That is the actual endpoint an MCP client would talk to, the equivalent of /wc/v3/orders for this newer protocol rather than the older REST API. I requested it the same way I requested every admin endpoint earlier in this post:

    curl -s https://woocommerce.com/wp-json/mcp/mcp-adapter-default-server
    {"code":"rest_forbidden","message":"Sorry, you are not allowed to do that.","data":{"status":401}}

    401, gated the same way as every WooCommerce admin endpoint this post has tested. The pattern holds even on infrastructure that did not exist when this post was first written: a new protocol, a new namespace, the same permission check underneath it. That consistency is worth more than any individual result, because it means the thing actually worth auditing on a site adopting MCP is not whether the protocol itself is secure in the abstract, it is whether whatever registered that route remembered to apply a permission_callback at all, exactly the second row of the what-to-check table earlier in this post, now with a live, current example of a site getting it right.

    A third store, checked the same way

    Two stores were enough to draw a conclusion when this post was first written. I added a third while expanding it, a site this blog has covered before as a real, active WooCommerce installation rather than a hypothetical.

    for ep in /wc/v3/orders /wc/v3/customers /wc/v3/products /wc/v3/reports/sales; do
      curl -s -o /dev/null -w "$ep -> %{http_code}n" "https://sitea.example/wp-json$ep"
    done
    /wc/v3/orders -> 401
    /wc/v3/customers -> 401
    /wc/v3/products -> 401
    /wc/v3/reports/sales -> 401

    Four for four, matching the other two stores exactly. Three independent WooCommerce installations, running different themes, different hosting, different sets of plugins, and the same four admin endpoints refuse an unauthenticated request on every one of them. That is a meaningfully stronger claim than the original two-store caveat allowed, though the honest version is still narrower than “every WooCommerce site”: three real stores checked directly, consistent every time, which is exactly the basis this post has argued for trusting the defaults and spending the actual audit time on what gets added afterwards instead.

    Probing a store

    Two shell commands: reading the REST namespace index to see everything a site exposes, then requesting the four sensitive WooCommerce endpoints and checking each returns 401
    The index tells you what exists. The probe tells you what answers. Anything other than 401

    or 403 on those four is worth investigating immediately.*

    Reading the namespace list is the step I would not skip on an inherited store. Unfamiliar namespaces are where custom code and abandoned integrations live, and they are frequently the work of somebody who is no longer available to explain them.

    It is the same instinct as reading the abilities a site exposes before connecting anything to it: inventory first, opinions afterwards.

    Keeping it that way

    Three habits, none expensive.

    Review API keys quarterly. Revoke anything with no recent access. A key is a credential, and credentials outlive the projects that needed them.

    Re-probe after every plugin addition. A new namespace appearing is normal; a new namespace answering unauthenticated is not.

    Watch the wider information-disclosure surface too. A store that also hands out its usernames through the REST API gives an attacker both halves of what a credential attack needs.

    Never issue read/write when read will do. Most integrations that ask for full access need to list products, and the difference matters enormously on the day the key leaks.

    Read the namespace list after every change, not only during a scheduled audit. The MCP route on woocommerce.com is the clearest illustration in this post of why that habit outlasts any fixed checklist: the surface a site exposes changes faster than any list of endpoints written down today can keep up with.

    Protuno’s free audit checks whether a WooCommerce admin endpoint answers unauthenticated, from the domain alone, alongside storefront and checkout reachability. Till is live now.

    Run the four-endpoint loop on every store you look after. It takes seconds per site, and the expected result is four 401s. Anything else is your afternoon.

    The habit worth building alongside the checklist

    Everything in this post is a list of endpoints to request and status codes to expect, which makes it easy to treat as a one-time script rather than a way of looking at a site. The more durable version of the same discipline is smaller than any checklist: whenever a site gains new functionality, whether that is a plugin, an integration, or an entirely new protocol like the MCP namespace found on woocommerce.com above, read /wp-json/ again before assuming anything about what it exposes.

    That habit would have caught the MCP surface the moment it appeared, without needing a blog post to point at it specifically. It is also cheap enough to run on a schedule rather than only during an audit: the full namespace list is one request, and diffing today’s list against last month’s tells you exactly what changed and when, which is the same idea as re-checking a crawl’s findings days later rather than treating a single pass as permanent knowledge.

    The reason this matters more for WooCommerce specifically than for a brochure site is what is actually reachable once something goes wrong. An unauthenticated endpoint on a site with no commerce layer might leak a page nobody cares about. The same mistake on a store means order data, customer records, or a payment integration’s configuration, which is exactly why the default posture tested throughout this post, refuse everything unless explicitly opened, matters more here than almost anywhere else in a typical WordPress install. Getting the defaults right, which WooCommerce demonstrably does across all three stores checked, buys the room to focus the actual auditing effort on the narrower, more dangerous category: the handful of routes somebody added on purpose and forgot to gate.

  • DMARC Record Setup For Agencies: Our Own Records, Including The Embarrassing Ones

    DMARC Record Setup For Agencies: Our Own Records, Including The Embarrassing Ones

    Short answer: dmarc record setup is a sequence, not a switch. Publish SPF and DKIM, then DMARC at p=none with reporting on, read the reports until you know every legitimate sender, then quarantine a small percentage, then raise it. Skipping to p=reject destroys legitimate mail. Stopping halfway leaves a record that reads like protection and is not.

    Rather than invent an example, I read the records on seven domains we own, and then went back and read the same seven again weeks later to see whether any of it had actually moved.

    What a real portfolio looks like mid-rollout

    Table of seven domains with their DMARC policy, enforcement percentage and SPF status. sitea.example, siteb.example and sitec.example enforce p=reject at 100 percent. sited.example and sitee.example use p=quarantine with pct=5. sitef.example is p=none. siteg.example publishes p=quarantine with no SPF record at all
    Read live on 5 September 2026. Three domains enforcing, two paused at five percent,

    one monitoring, and one enforcing with only DKIM to align against. This is what a genuine rollout looks like partway through.*

    DomainPolicyEnforced onReading
    sitea.examplep=reject100%enforcing
    siteb.examplep=reject100%enforcing, sends no mail
    sitec.examplep=reject100%enforcing, sends no mail
    sited.examplep=quarantinepct=595% of failing mail unaffected
    sitee.examplep=quarantinepct=5same
    sitef.examplep=nonen/amonitoring only
    siteg.examplep=quarantine100%enforcing with only DKIM, no SPF

    Caveat: seven domains, all ours, chosen because I can verify every claim against records we control rather than screenshots of somebody else’s DNS. It is not a claim about typical agency DMARC hygiene. It is what checking our own portfolio actually found, and two of those rows needed fixing before this post went out.

    The two amber rows are the ones I want to draw attention to, because they are the state most domains are actually in and the one people misread.

    p=quarantine sounds like enforcement. With pct=5, the policy applies to five percent of failing messages. The other ninety-five percent are handled as though the policy were none.

    That is a correct, deliberate rollout step. It is also very easy to leave in place for a year or more and quietly believe the domain is fully protected the whole time. Ours genuinely have.

    The red row is a genuine misconfiguration: a quarantine policy sitting on a domain with no SPF record at all published, which leaves DKIM as the only mechanism DMARC has left to align against. It is on the list, and it has been for a while now.

    Re-read weeks later, and nothing moved at all

    I re-ran the same seven-domain check while expanding this post, expecting at least the two flagged rows to have been addressed by now.

    for d in sitea.example siteb.example sitec.example sited.example 
             sitee.example sitef.example siteg.example; do
      echo -n "$d: "; dig +short TXT "_dmarc.$d"
    done

    Every one of the seven records came back byte-for-byte identical to the original reading. The three p=reject domains still enforce fully. sitef.example still monitors only. And both amber rows named in this post’s own caveat are exactly where they were: sited.example and sitee.example still capped at pct=5, siteg.example still publishing p=quarantine with no SPF record for DMARC to align against, confirmed separately in the SPF lookup post‘s own re-check.

    Zero drift is not, on its own, good news here. It is confirmation of exactly the failure mode this post’s “why this drifts” section describes: pct=5 behaves indistinguishably from full protection in every day-to-day signal available to whoever owns the domain, so there is no event that prompts anyone to come back and raise it. A configuration set once during a rollout and never revisited does not correct itself through the passage of time. It just sits, looking finished, for as long as nobody specifically goes and checks the one number that says otherwise.

    Reading a DMARC record setup, tag by tag

    Annotated breakdown of the sited.example DMARC record showing the v, p, pct, rua and fo tags, with pct=5 highlighted as meaning the other 95 percent are treated as p=none
    Two tags people omit and should not. Without rua you receive no reports, so you are

    enforcing blind. And pct, when present, is the most important number in the record, because it decides whether the policy does anything at all.*

    If you check one thing on a client domain today, check whether pct is present and what it says.

    The rollout, and the mistake at each step

    Five-step DMARC rollout table. Publish SPF and DKIM first, monitor with p=none and reporting, identify every legitimate sender from the reports, quarantine a small percentage, then raise to reject. Each step lists its characteristic mistake
    Step four is where most domains stop, including two of ours. It is a legitimate place

    to pause and an easy place to forget you paused, because the record reads as though it is working.*

    #StepThe mistake here
    1publish SPF and DKIM firstnothing for DMARC to align against without them
    2monitor only, p=none with reportingsetting it and never reading the reports
    3find every legitimate sender from the reportsforgetting the CRM, invoicing tool, helpdesk, newsletter
    4quarantine a small percentageleaving it here for a year, believing you are protected
    5raise the percentage, then rejectjumping here directly from step two

    Step three is the one that costs agencies real goodwill when skipped. The senders you forget are never the mail server. They are the invoicing tool, the helpdesk, the CRM, the newsletter platform, the booking system. Each one sends as the client’s domain, and each one starts failing the moment you enforce.

    That is why p=none with rua exists. It generates the list for you, from real traffic, over a few weeks. There is no way to produce that list by thinking hard about it.

    A tag pattern in our own records worth naming precisely

    Reading all seven records side by side while re-checking surfaced a detail the original table did not call out: an fo=1 tag, requesting per-message forensic failure reports in addition to the periodic aggregate summary rua already provides, appears on exactly three of the seven domains, and it is not a random three.

    sitea.example               fo: absent
    siteb.example               fo: absent
    sitec.example    fo: absent
    sited.example               fo=1
    sitee.example          fo=1
    sitef.example                fo: absent
    siteg.example             fo=1

    fo=1 sits on sited.example, sitee.example and siteg.example, three of the four domains this post already identifies as mid-rollout or misconfigured. The three fully-enforcing p=reject domains and the one purely-monitoring p=none domain all leave it unset.

    That split makes sense read as a deliberate operational choice rather than an accident: per-message forensic reports are far noisier and more useful during active tuning, when somebody is trying to identify exactly which message failed and why, than on a domain that has already reached a stable, fully-enforcing state and only needs the periodic aggregate summary to confirm nothing has changed. Whether that specific reasoning was the actual intent behind these three records or a side effect of when each was configured, the pattern itself is a useful one to copy deliberately: turn on fo=1 while a domain’s policy is still being raised step by step, and consider turning it back off once the domain reaches steady-state enforcement, since forensic reports carry more of the original message’s content than an aggregate summary does and are worth keeping only as long as they are actually being read.

    The DKIM half, briefly

    DMARC aligns against SPF or DKIM, and most rollouts lean on SPF because it is the one people already understand. DKIM is worth the extra hour for one reason: it survives forwarding.

    When a message is forwarded, the sending server changes but the DKIM signature travels with the message body. SPF breaks in that situation and DKIM does not. If a client’s mail is routinely forwarded, to a personal address, an alias, a shared inbox, alignment on DKIM alone is what keeps it delivering.

    Check whether a selector is publishing at all:

    dig +short TXT google._domainkey.example.com | head -c 120
    dig +short TXT selector1._domainkey.example.com | head -c 120

    Selector names vary by provider, which is the genuinely annoying part. google for Workspace, selector1 and selector2 for Microsoft 365, and something provider-specific for most bulk senders. There is no way to enumerate them from outside, so you check the ones you expect and confirm the rest in each provider’s console.

    Checking a portfolio

    Terminal loop reading the DMARC TXT record for each domain in a list, with real results from four of our own domains showing reject, quarantine with pct=5, none, and a quarantine policy on a domain with no SPF
    No tooling required. It is a TXT lookup on a fixed subdomain, so a whole portfolio

    takes seconds.*

    Read three things in each result: the policy, the pct if present, and whether rua exists.

    A record with p=reject and no rua is the riskiest configuration on this page. It is enforcing, and nobody can see what it is refusing.

    What it protects, and what it does not

    Worth being precise here, because DMARC gets oversold.

    DMARC stops someone sending mail that claims to be from your client’s domain in the visible From address. That is exactly the mechanism behind most invoice fraud and supplier impersonation aimed at their customers.

    It does nothing about a genuinely compromised mailbox, because that mail is authentic. It does nothing about lookalike domains, where an attacker registers something one character different and passes their own DMARC perfectly. And it does nothing about mail sent from the site itself if the site never actually dispatches it.

    So it is worth doing and it is not a mail security strategy on its own. Say that to clients plainly, because the vendor pitch around DMARC rarely does.

    Why this drifts

    DMARC does not fail loudly. A domain sitting at pct=5 for a year behaves exactly like one at pct=100 right up until somebody spoofs it. A domain that added a new marketing tool six months ago has been failing alignment for that tool ever since, quietly, in reports nobody opened.

    It is the same class of silence as an SPF record that drifted past its lookup limit or a contact form that reports success and delivers nothing. Mail problems almost never announce themselves at the sending end.

    Protuno’s free audit reads the DMARC policy, whether reporting is configured, and the SPF record alongside it, from the domain alone. That is how the table above was built. Echo is live now.

    One note on selling this, because it is genuinely hard to charge for. DMARC produces no visible improvement. Nothing gets faster, nothing looks different, and when it works the client experiences precisely nothing. The framing that lands is the one about their customers: without it, anybody can send an invoice that appears to come from them, and the person who gets defrauded is a client of theirs, not them. That is a reputational problem with their name on it, and it is worth an afternoon.

    Run the loop on your client domains this afternoon. If any come back with pct=5, that is a decision somebody made deliberately and then forgot, and it is worth finishing.

    Ours included. The two domains sitting at pct=5 in this post’s original table are still sitting there today, unchanged across the entire time this post has been live. Writing that sentence twice, once when the finding was new and once after confirming it is still true, is the entire argument this blog keeps making about the gap between publishing a finding and actually acting on it. The record is correct either way. The honesty is in saying so.

  • WordPress Image Optimization: Find The 794KB File Before Your Visitor Does

    WordPress Image Optimization: Find The 794KB File Before Your Visitor Does

    Short answer: wordpress image optimization starts with measuring what a page actually downloads, not with installing a plugin. Fetch every image the page references, sort by bytes, and look at the top three. On one of our own pages that was 2,590 KB across forty images, all of them PNG.

    What the audit found

    Table of the twelve heaviest images on sitea.example, all PNG, led by a 794 KB feature image, then 529 KB, 464 KB and 385 KB files, with upload dates ranging from 2021 to 2026
    One file is 794 KB, which is larger than the entire compressed HTML of most pages. Note

    the dates: images uploaded in 2021 are still being served on a 2026 page.*

    That last point is the part people miss. A media library is append-only in practice. Nobody revisits a decorative image from four years ago, and nothing flags it, so it keeps being served long after the page it belongs to was redesigned around it.

    Running a WordPress image optimization audit yourself

    You do not need a tool. The method is: read the page, collect every img src, source srcset and CSS url(), request each one, and sort by response size.

    curl -s https://example.com/ 
      | grep -oE 'src="[^"]+.(jpe?g|png|webp|avif)"' 
      | sed 's/src="//;s/"$//' | sort -u 
      | while read -r u; do
          printf '%8s  %sn' 
            "$(curl -s -o /dev/null -w '%{size_download}' "$u")" "$u"
        done | sort -rn | head

    Two refinements that matter in practice.

    Request as a browser would. Send an Accept header including image/webp and image/avif. Some servers negotiate format, and requesting with curl’s default headers will show you the fallback rather than what visitors actually receive.

    Follow srcset, not just src. Responsive images mean the file a phone downloads is often not the one in src. Audit the largest candidate, because that is what a desktop visitor gets.

    Checked again, and the page got heavier, not lighter

    I re-ran this post’s own audit against the same sitea.example homepage before expanding it, expecting either no change or a modest improvement. Neither happened.

    curl -s https://sitea.example/ | grep -oE 'src="[^"]+.(jpe?g|png|webp|avif)"' 
      | sed 's/src="//;s/"$//' | sort -u | while read -r u; do
        curl -s -o /dev/null -w '%{size_download} %{url_effective}n' "$u"
      done | sort -rn
    Original readingThis reading
    Images on the page4064
    Total image weight2,590 KB6,475 KB
    Largest single image794 KB (00-feature-image.png)838 KB (a new file, ...-Elmentor-Email.png)

    Sixty-four images now, up from forty, and total weight up to 6,475 KB, nearly two and a half times the original figure. The original 813,352-byte file is still there, unchanged, now sitting at the second-heaviest position rather than the first, because a new 857,815-byte PNG, uploaded in June, has since overtaken it. Nothing about the original finding was fixed. The page simply grew a new, larger problem on top of the old one that was never addressed.

    I checked the new heaviest file the same way this post’s format-negotiation table checks the old one:

    curl -s -H 'Accept: image/avif,image/webp,image/png,image/*' -D - -o /dev/null 
      "https://sitea.example/wp-content/uploads/2026/06/site-notification-email.png" 
      | grep -i content-type
    content-type: image/png

    Same pattern as before: the Accept header is ignored regardless of what it asks for, and .webp returns 404 when requested directly. Whatever caused the original file to bypass format negotiation is still causing every new upload to do the same thing, which means this is not a one-off oversized file. It is a standing gap in how images reach this page at all, quietly accepting new casualties every time someone adds a graphic.

    Format is only half of it

    Comparison table showing sitea.example serving 2,590 KB across 40 PNG images with a 794 KB largest file, against woocommerce.com serving 6,418 KB across 40 WebP images with a 1,412 KB largest file
    The honest complication. woocommerce.com does format conversion properly and still ships

    more image weight, including a single 1.4 MB WebP.*

    sitea.examplewoocommerce.com
    Images on the page4040
    Total image weight2,590KB6,418KB
    Format servedPNG throughoutWebP throughout
    Largest single image794KB (png)1,412KB (webp)

    Caveat: one page from each site, checked once. This is not a claim that WebP sites are heavier in general, it is the opposite point: format alone does not bound page weight, and this pair happens to prove it cleanly. Do not skip the dimension check because a site already converted formats.

    Converting to a modern format shrinks a file. It does not stop somebody uploading a photograph at twice the dimensions the layout will ever display, and it does not remove an image the design no longer uses.

    Table comparing responses to three Accept headers on sitea.example, all returning 813,352 bytes of PNG, with no webp or avif sibling, against woocommerce.com which serves image/webp from URLs ending in .jpg and .png
    The same file requested three ways. Ours ignores the Accept header entirely and there

    is no WebP sibling to fall back to. woocommerce.com serves WebP from URLs that still end in .jpg, because something in front converts per request.*

    Requestsitea.example
    Accept: image/png813,352 bytes, image/png
    Accept: image/webp,image/*813,352 bytes, image/png (ignored)
    Accept: image/avif,image/webp,...813,352 bytes, image/png (ignored)
    Does a .webp sibling exist?404, no

    woocommerce.com, by contrast, serves image/webp from URLs that still end in .jpg and .png, because something in front of it converts per request regardless of the extension.

    So the audit has three questions, in this order:

    Is this image needed at all? The cheapest image is the one you removed.

    Are the dimensions sane? A 3000px-wide file displayed in a 600px column carries twenty-five times the pixels the layout can use, and no format conversion recovers that.

    Is the format modern? This is the last question, not the first, and it is covered separately in the piece on WebP and AVIF, publishing later this week.

    The comparison this post opened with has nearly closed

    The original table in this post used woocommerce.com as the counterexample: a WebP site still shipping more image weight than a PNG one, proof that format alone does not bound page weight. I re-checked woocommerce.com the same way, and the comparison this post built itself around looks different now.

    curl -s https://woocommerce.com/ | grep -oE 'src="[^"]+.(jpe?g|png|webp|avif)"' 
      | sed 's/src="//;s/"$//' | sort -u | while read -r u; do
        curl -s -o /dev/null -w '%{size_download}n' "$u"
      done | awk '{s+=$1} END {print s}'
    sitea.example (PNG)woocommerce.com (WebP)
    Images6457
    Total weight6,475 KB6,637 KB

    162 KB apart, on 6.5 megabytes of images. What was originally a clean illustration, PNG site lighter in file count but present anyway, WebP site heavier despite doing format conversion correctly, has become two sites of nearly identical total weight, arrived at by different routes: one added twenty-four new images without converting any of them to a modern format, the other apparently kept pace on its own. The lesson this post opened with, format is not the first question and page weight can grow regardless of it, holds more strongly now than it did originally, not less, because the specific numbers that used to make one site look like the counterexample have converged into two sites making the same mistake for different reasons.

    Where the weight actually concentrates

    Worth naming precisely rather than leaving as “many images add up”: on the current sitea.example homepage, the three heaviest files alone, 858 KB, 813 KB and 542 KB, total 2,213 KB, just over a third of the entire page’s 6,475 KB of images, out of sixty-four files. Three files, out of sixty-four, carrying more weight than the other sixty-one combined would if evenly distributed.

    That concentration is exactly why this post recommends sorting by size and starting at the top rather than working through a full image inventory in upload order. Fixing the three heaviest files on this specific page would remove more weight than optimising the next twenty smaller ones, and it is a single afternoon’s work rather than a full site audit. The audit loop this post already gives sorts for exactly this reason: the answer to “where do I start” is almost always sitting in the first three lines of output, not buried somewhere in the middle of a sorted list nobody scrolls to.

    Where this fits

    Image weight is the most common single cause of a slow-feeling page, and it is also the one that degrades silently. Nobody ships a 794 KB image deliberately. Somebody exported a mockup at full resolution once, and every visitor since has paid for it.

    It is worth checking alongside whether compression is even reaching your assets, because an uncompressed JavaScript bundle and an oversized hero image are usually found on the same site, for the same reason: nobody has measured.

    And measure the page, not the server. A fast server response time with three megabytes of images behind it is a fast server attached to a slow page.

    Protuno’s free audit flags oversized images from the domain alone as part of the performance checks. Dash is live now.

    One organisational fix is worth more than any plugin here: put a ceiling on what can be uploaded. Most oversized images arrive because somebody exported at full resolution and WordPress accepted it without comment. A maximum upload dimension, agreed with whoever writes the content, stops the problem at the source rather than compressing it afterwards. Optimising a 4000px image that should never have been 4000px is solving the wrong problem well.

    Run the loop above on your client’s homepage. The top three lines are your afternoon’s work, and they are usually worth more than everything else on a performance checklist combined.

    Why the same page kept growing without anyone deciding it should

    Nobody sat down and decided sitea.example‘s homepage should carry six and a half megabytes of images. Twenty-four images arrived one at a time, each added for a real reason, a new section, a new screenshot, a new testimonial logo, by different people over different months, none of whom were looking at the running total the way this audit does. That is worth naming as the actual mechanism behind this post’s second reading, not a one-off oversight but the default outcome of a page that anyone with publishing access can add an image to, with nothing in the workflow surfacing what the page now weighs as a whole.

    That is also why re-running this exact check periodically matters more than running it once thoroughly. A single audit, however careful, describes the page on the day it ran. The next image added the following week is invisible to it. A page that never gets re-measured will keep growing in exactly the direction this one did, because every individual addition looks small and reasonable in isolation, and the only view that shows the accumulated cost is the one this post keeps insisting on: fetch everything, sum it, sort it, and look at what the page actually weighs today rather than what it weighed the last time anyone checked.

    What would have caught this before it doubled

    The upload-ceiling recommendation later in this post, a maximum dimension enforced at the point of upload, would not have caught this specific growth, because the problem here is not that individual images are oversized for their display dimensions. It is that the count of images grew without anyone tracking the running total. A complementary check worth adding alongside the dimension ceiling: alert when a page’s total measured image weight crosses a threshold, checked the same way this post’s loop already checks it, rather than only checking each new image in isolation as it is uploaded. A single 400 KB image added to a page that already carries six megabytes is a smaller problem than the same 400 KB image added to a page that started at zero, and a per-image check alone cannot see that difference. Only a running total, measured against the whole page, catches death by a thousand reasonable-looking additions.

    The other side of the same problem: images nobody is even using

    Your Website has 1000s of unused images – Here is how to delete them

    Oversized images that are still in use are this post’s subject. Unused images sitting in the media library, never referenced on a live page at all, are the adjacent failure this walkthrough covers, and they cost real storage and backup time even though no visitor ever loads them.

  • llms.txt On WordPress: We Measured Twelve Sites, And Ours Were The Worst

    llms.txt On WordPress: We Measured Twelve Sites, And Ours Were The Worst

    Short answer: llms txt wordpress files are a single Markdown document at /llms.txt that gives an AI agent a short, curated map of your site. It is a proposal rather than a standard. It is trivial to add. And it is very easy to get wrong in a way that removes any benefit, which is what we did on two of our own sites.

    I checked twelve sites in this industry rather than guessing at adoption. Here is what came back.

    Who actually has an llms txt on their WordPress site

    Table of twelve WordPress-adjacent sites and their llms.txt status. wordpress.org, sitea.example, yoast.com, kinsta.com, patchstack.com and elementor.com publish files between 2KB and 16KB. siteb.example publishes 119,758 bytes and sitec.example publishes 212,539 bytes with 662 links. woocommerce.com, wpengine.com, rankmath.com and sited.example have none
    Eight of twelve publish one. The two largest by a wide margin are ours, and

    sitec.example’s is roughly forty times the size of the file on wordpress.org.*

    Sitellms.txtSize
    wordpress.orgpresent5.8KB, 36 links, a curated index
    sitea.examplepresent2.3KB, 12 links
    yoast.compresent7.7KB
    kinsta.compresent9.5KB
    patchstack.compresent15.2KB
    elementor.compresent15.5KB
    siteb.example (ours)present119.8KB, far past “fits in context”
    sitec.example (ours)present212.5KB, 679 lines, 662 links
    woocommerce.comabsentnone
    wpengine.comabsentnone
    rankmath.comabsentnone
    sited.example (ours)absentnone

    Caveat: twelve sites, chosen because they are in our industry and easy to check, not a random sample of WordPress-adjacent sites generally. I would not generalise an adoption percentage from this. What it does show, reliably, is the size gap: the two largest files in the list are ours, by a wide margin, and that comparison needed no sampling to be true.

    I counted a 200 that returns HTML as absent, because several sites answer every path with their normal 404 page at status 200. If you check this yourself, look at the body rather than the status code.

    Terminal output showing a portfolio check loop for llms.txt. sited.example returns a 404 with a 399,216 byte body and wpengine.com a 404 of 146,134 bytes, while sitea.example returns a genuine 2,270 byte file and sitec.example returns 212,539 bytes
    The loop I used, and the trap inside it. A 404 that returns a 399 KB body is a styled

    error page, not a missing file. A status-only check would have recorded several of these as present.*

    Our own good example has started down the same path

    I re-checked all five of our own domains before expanding this post, and the two files already named as too large have not been rewritten. siteb.example sits at 120,105 bytes, 212,742 for sitec.example, both essentially unchanged from the original reading. “On the list to rewrite by hand” has not happened.

    The more interesting finding is sitea.example‘s own file, the one this post holds up as the example done right at 2.3 KB and 12 links. It is now 8,077 bytes with 33 links:

    curl -s https://sitea.example/llms.txt | grep -c "^- ["
    33

    Reading the actual content shows exactly why: the ## Writing section lists every published blog post automatically, title, link and meta description, added as each new post goes live. That is not the styled-error-page problem this post’s own checking method exists to catch. It is a real file, correctly served, growing for a structural reason: a site that publishes regularly and lists every post it has ever published in one flat section will keep adding rows forever, with no mechanism deciding which of them still deserve a place in a file meant to stay small enough to fit in context.

    Eight kilobytes and thirty-three links is not yet the 212 KB, six-hundred-link problem this post diagnoses on our other two domains. It is the same mechanism, caught earlier, before it became unusable. A curated list needs active curation, on a schedule, not a one-time edit that then gets left to accumulate every future addition automatically. The honest fix, applied here before it becomes another entry in this post’s own table of what we got wrong: cap the writing section at a fixed number of the most relevant posts, rotate older ones out as new ones are added, and treat the file as something that needs periodic pruning rather than something finished the day it was first written well.

    What the file is supposed to be

    The proposal comes from Jeremy Howard and is explicitly not an adopted standard, though thousands of sites publish one and Chrome’s Lighthouse now audits for it.

    Its purpose is stated plainly: “Agents are best served by concise, expert-level information gathered in a single, accessible location”.

    And the constraint that matters: “The file itself stays small enough to fit in context. The detail lives behind the links, and is fetched only when needed”.

    That last sentence is the whole design. It is a map, not the territory.

    Table of the structure llms.txt specifies. An H1 with the site name is the only required element. A blockquote summary, detail sections and H2-delimited file lists of markdown links are all optional
    Everything except the H1 is optional, which is the clearest statement of intent in the

    specification. It is a short document by design.*

    Where we went wrong

    Comparison of the llms.txt proposal's requirements against wordpress.org and sitec.example. wordpress.org is 5.7KB with 36 links and follows every principle. sitec.example is 212KB with 662 links, inlines detail rather than linking to it, and does not fit in context
    A 212 KB llms.txt is a sitemap wearing a different filename. It defeats the single

    thing the format exists to do.*

    The proposal sayswordpress.orgsitec.example (ours)
    concise, single accessible location5.7KB212KB
    small enough to fit in contextyesno
    a curated overview, not exhaustive36 links662 links
    detail behind links, fetched when neededfolloweddetail inlined

    Nobody did anything careless here. A plugin or a build step generated the file from the site’s content, and generating everything is the obvious default when you automate a job like this.

    But the value of llms.txt is entirely in the curation. A model reading a 2 KB file can hold the whole thing and decide what to fetch. A model handed 212 KB of inlined detail either truncates it or spends its budget on your file instead of your content.

    Both of ours are on the list to rewrite by hand. Twenty links chosen deliberately will do more than six hundred generated ones.

    This is a pattern I keep running into while writing these posts. The automated version of a job is usually right about what to include and wrong about how much. A generator has no opinion about which twenty pages matter, so it ships all six hundred and calls it thorough. Curation is the part that needs a person, and it is the part that gets skipped precisely because a script can produce something that looks finished without it.

    It is the same failure as an SEO plugin that checks each page against itself and never asks how the pages relate.

    What to put in it

    For a WordPress site that sells something or documents something, this shape works:

    # Site Name
    
    > One paragraph saying what this is, who it is for, and what is true today.
    
    Any context a reader needs before following a link. Pricing model, what is
    free, what is not shipped yet.
    
    ## Documentation
    - [Getting started](https://example.com/docs/start): the fastest path to a working setup
    - [Requirements](https://example.com/docs/requirements): what the site needs to run
    
    ## Product
    - [What it does](https://example.com/features): the capability list, current as of today

    Three rules I would apply after doing this exercise:

    Cap it. If the file is over about 10 KB, you are inlining detail that belongs behind a link. Generate a candidate list if you like, then cut it by hand.

    Say what is not true yet. Ours stated, when this was written, that the Super Agents were built and not released. That sentence costs nothing and prevents a model confidently telling somebody we ship something we do not.

    Date it, or do not claim currency. A stale llms.txt is worse than none, because it presents itself as authoritative.

    Point at pages, not at PDFs and downloads. The links are there so an agent can fetch detail cheaply. A link to a 40 MB asset is a link it will not follow, and it spends your credibility for nothing.

    The four well-curated reference files did not grow at all

    Worth the contrast directly: I re-checked the four external sites this post cites as doing this well, the same way as our own five, and none of them moved by a meaningful amount.

    SiteOriginal readingRe-checked
    yoast.com7.7 KB7,682 bytes
    kinsta.com9.5 KB9,497 bytes
    patchstack.com15.2 KB15,182 bytes
    elementor.com15.5 KB15,451 bytes

    Flat, stable, no drift, across every one of the four. That is the actual signature of a file somebody wrote deliberately and revisits on purpose rather than one wired to grow automatically with every new piece of content: it does not move just because time passes and the site keeps publishing. Our own sitea.example file grew by nearly four times in the same interval these four sat still, and the difference is not effort spent maintaining it. It is that theirs are not structurally tied to an ever-growing list the way a “list every post automatically” section is. Whichever approach a given site takes to generating this file, checking whether it is actually a static, curated document or a live query against a growing content table is worth doing explicitly, because the two look identical the day the file is first published and only diverge months later, exactly the shape of every other drift this blog keeps finding.

    Is it worth doing at all

    Honestly: the evidence that llms.txt changes anything is thin. It is a proposal, adoption by crawlers is inconsistent, and nobody should promise a client traffic from it.

    What I would say is that it costs an hour, it makes you write down what your site is actually for, and that exercise has value even if no agent ever reads the file. The sites above that did it well have a clearer description of themselves in /llms.txt than on their homepage.

    Treat it as cheap insurance on a shift that is clearly happening, not as a ranking tactic.

    One caution worth stating, because it follows directly from what this file is. An llms.txt is a document you hand to something that will summarise you to a stranger. Anything inaccurate in it gets repeated with more confidence than it deserves. That is a good reason to write it by hand, and a good reason to keep it short enough that you can re-read the whole thing whenever the product changes.

    It is also worth pairing with the indexing signals a site already sends, because they can contradict each other. A site that publishes a friendly llms.txt while quietly telling search engines not to index anything is sending two opposite messages, and only one of them is deliberate.

    Protuno’s free audit checks for the presence of llms.txt from the domain alone, alongside the sitemap and robots.txt, which is how I built the table above. Iris is live now.

    If you want a genuinely useful hour this week, write one by hand for your own site, then compare it with what a crawler actually finds there. The gap between the two is usually the more interesting document.

    And whichever way you build it, whether by hand once or generated from a template, set a reminder to re-read the whole file every few months rather than trust that writing it well once means it stays that way. Ours did not, and the only reason that is now a footnote instead of a repeat of the 212 KB mistake is that this post itself got re-checked before the gap grew any wider.

    The mechanical part, if you would rather not write it by hand

    The Easiest Way to Add llms.txt to Any WordPress Site

    Covers the plugin-assisted route. Worth pairing with this post’s own argument: a generated file still needs the by-hand review this post recommends, not a set-and-forget install.

  • Verify A WordPress Backup, Or You Only Have A File With A Hopeful Name

    Verify A WordPress Backup, Or You Only Have A File With A Hopeful Name

    Short answer: to verify wordpress backup files you have to restore them. Into a scratch database, then count the rows and compare with live. Everything short of that, including the plugin reporting success and a file appearing with today’s date, tells you a file was written. It does not tell you the file works.

    I proved this to myself this week rather than repeating it as received wisdom, and proved it again later on a different install with a more restrictive set of database credentials, which turned out to matter more than the first test alone would have shown.

    Two files, one useless

    I took a complete, working backup of a WordPress database, then made a second copy truncated mid-write, which is exactly what happens when a disk fills, a process is killed, or a connection drops midway.

    Then I restored both, carefully, into a location where a mistake could not touch anything that actually mattered.

    Table comparing a complete backup against a truncated one. The complete file is 1.7MB, ends with a completion marker, restores with exit code 0, and returns 77 tables and 15 posts. The truncated file is 879KB, has no completion marker, exits with code 1, restores 8 tables, and the wp_posts table does not exist
    The truncated file is 879 KB. In a folder listing it looks like a backup. It restored

    eight tables out of seventy-seven, and wp_posts was not among them.*

    Complete backupTruncated backup
    File size1.7MB879KB (plausible)
    Present in the backup folderyesyes
    Ends with a completion markeryesno
    Restore exit code01
    Tables restored778
    Posts restored15wp_posts does not exist

    Caveat: one deliberately truncated file on one test database, built to show the mechanism cleanly. It is not a claim about how often real backups fail this way. It is a demonstration that “the file exists and is a plausible size” tells you nothing about whether it restores.

    Nothing about the broken file announces itself. Right name, plausible size, correct folder, today’s date. Every check most agencies run would have passed it.

    The only single check that actually failed it was restoring it in full.

    How to verify a WordPress backup, in under a second

    Four-command procedure: dump the database, create a scratch database and restore into it, count posts and tables and compare with live, then drop the scratch database. Timings show 163 milliseconds to dump and 361 milliseconds to restore
    Under a second of machine time on a small install. The live database is never

    touched, because the restore goes into a database created for the purpose and dropped afterwards.*

    Two rules for doing this safely, and the first one is not negotiable.

    Never restore into the live database to test a backup. Create a scratch one. A verification step that can destroy the thing it is verifying is not a verification step.

    Compare row counts, not just success. A restore can complete without error and still be missing content if the dump was taken while a table was locked. wp_posts, wp_options and wp_users counts against live is enough to catch it.

    Reproducing it fresh, and hitting a permissions wall worth knowing about

    I re-ran this exact experiment on a different install while expanding this post, and the first attempt failed for a reason worth documenting on its own: the database user WordPress was configured with had no privilege to create a new database at all.

    mysql -h db -u wp -pwp -e "CREATE DATABASE IF NOT EXISTS wp_verify;"
    ERROR 1044 (42000): Access denied for user 'wp'@'%' to database 'wp_verify'

    SHOW GRANTS confirmed why: GRANT ALL PRIVILEGES ON wp.* TO wp@%, full control over the one database WordPress already uses, nothing beyond it. That is an extremely common shape for database credentials on shared and managed hosting specifically, where an account is scoped to exactly one schema for isolation reasons, and it means the scratch-database method this post recommends first is not always available to whoever is actually running the check.

    The workaround, and it is a real, usable one rather than a theoretical fallback: restore into the same database, under a different table prefix, rather than a different database.

    sed 's/`wp_/`verify_/g' backup.sql > backup-renamed.sql
    mysql -h db -u wp -pwp wp < backup-renamed.sql

    Rewriting every backtick-quoted wp_ prefix to verify_ before restoring means the import creates a parallel set of tables, verify_posts alongside the live wp_posts, inside the one database the credentials actually permit writing to. Nothing about the live tables is touched, because the restored tables have entirely different names, and the same row-count comparison this post already recommends works identically against them.

    With that in place, the reproduction ran cleanly. A full backup, 1,417,073 bytes, 58 tables, restored in 0.73 seconds with a clean exit code. The same file truncated to 850,000 bytes exited with code 1 and left exactly 9 tables behind, verify_options, verify_comments, verify_links and six others, all of the site’s actual content tables, verify_posts included, absent. The live wp_posts count, checked immediately after, was untouched at 29, exactly what it was before the test started.

    That is the same mechanism this post opened with, reproduced on different infrastructure, under real credential constraints rather than an idealised scratch-database setup, and it still found the same shape of failure: a plausible file size, a clean-looking folder listing, and a restore that silently drops the one table that actually matters.

    What each check is actually worth

    Ranked table of backup checks. The plugin reporting success and a file existing with today's date are weak. File size and a completion marker are better. Restoring into a scratch database, matching row counts against live, and restoring files as well as the database are strong
    Most monitoring stops at the first row. The last row catches a separate failure

    entirely: plenty of setups back up the database faithfully and never touch wp-content, so uploads, themes and plugins are not in the backup at all.*

    ProofCheckWhat it does not tell you
    weakplugin reports successwhether the file was written completely
    weaka file exists with today’s datewhether it contains the whole database
    betterfile is roughly the expected sizemisses a table dropped mid-dump
    betterdump ends with a completion markersays nothing about whether it reads back
    strongrestores into a scratch database without errornothing structural
    strongrow counts match the live databasenothing, this is the check
    strongfiles as well as database restore and loadnothing, a database-only backup cannot rebuild a site

    It is also worth confirming the backup is stored somewhere the site cannot reach. A backup sitting in wp-content on the same server is not a backup, it is a second copy of the thing that is going to break, and it is downloadable by anyone who guesses the filename.

    That last one is worth a specific check on any site you inherited. Take the backup, look inside it, and confirm wp-content/uploads is actually there. A database-only backup cannot rebuild a site, and the day you discover that is the worst possible day to discover it.

    Verifying the files half the same way, not just the database

    The row-count comparison above proves the database restores. It says nothing about the wp-content/uploads, wp-content/themes and wp-content/plugins directories, which is the half of a backup this post already names as the one people forget to check exists at all.

    The same discipline applies, cheaply. Extract the files archive into a scratch directory, never over the live install, and count what actually landed:

    mkdir /tmp/restore-check
    tar -xzf backup-files.tar.gz -C /tmp/restore-check
    find /tmp/restore-check/wp-content/uploads -type f | wc -l
    find /var/www/html/wp-content/uploads -type f | wc -l

    Two numbers, compared directly. A backup archive that unpacks cleanly but produces a file count noticeably lower than live is the files-side equivalent of the truncated database dump: present, plausible, and missing exactly the content a restore actually needs. A mismatch here is worth investigating before trusting the archive, the same way a mismatched row count is worth investigating before trusting the SQL dump, and for the same underlying reason: an archive tool interrupted partway through a large uploads directory produces a smaller, still-valid-looking .tar.gz file, not an error anyone sees at backup time.

    Running both checks together, database row counts and file counts, against the same backup job is worth treating as one verification step rather than two separate ones, because a backup that fails only on the files half, database perfect, uploads directory silently truncated, is just as capable of turning a routine restore into a lost client’s media library as the reverse case this post opened with.

    Why the prefix-swap method belongs in a portfolio-wide script

    The credential constraint found while reproducing this test, a database user scoped to one schema with no privilege to create another, is worth treating as the default assumption rather than the exception when building a verification script meant to run across a whole portfolio of client sites. Managed hosts, shared hosting panels and several popular hosting providers’ control panels all commonly provision exactly this kind of scoped credential, and a script that assumes CREATE DATABASE will always succeed will fail silently, or noisily and confusingly, on a meaningful share of the sites it is meant to protect.

    The table-prefix rewrite is not a workaround to reach for only when the preferred method fails. Building it in as the default method from the start, with a full scratch database as the upgrade path on hosts that permit it, means one script handles both cases without needing to detect which kind of host it is running against first. That is a small design decision with a disproportionate effect on how often the check actually gets run: a verification script that occasionally throws a permissions error on a subset of a portfolio is a script someone eventually stops trusting, in exactly the same way an alert that fires on the wrong things trains people to ignore alerts generally.

    Why this is the check that never gets done

    It is not difficult and it is not slow. It is that a verified backup and an unverified one look identical right up until the moment one of them matters.

    There is nothing to show a client. No line on an invoice. No visible improvement to the site. So it loses to work that produces something, every single week, on every site.

    It is the unbilled half hour in its purest form: cheap, invisible, and the only thing standing between a bad update and a lost client.

    And it compounds with the other silent failures. If the scheduler on the site has stopped, your scheduled backups have not been running at all, and the most recent file in that folder may be from March.

    This is why the update playbooks in Protuno re-open the backup they just took before anything writes to a site, rather than simply taking one first. The archive has to open and hold files, the database dump has to decompress, carry the statements a dump carries and account for every table on the site. What that check does not do is restore the backup to see what comes back, so it is a verified file rather than a rehearsed recovery. Taking a backup and having a backup are different things. Wren is live now.

    Do the manual version this week on one client site. Take the backup, restore it into a scratch database or a renamed table prefix if that is what the credentials on that host actually allow, count the posts, and count the files while you are there. If the numbers match, you have learned something real. If they do not, you have just avoided a very bad afternoon, on a day you got to choose rather than one a failed restore chose for you.

    Why backup storage itself is worth a second look

    Why I Stopped Saving Backups to Google Drive & Dropbox

    A recent, practical walkthrough of where WordPress backups actually go wrong once they leave the server, and why the destination matters as much as the restore test this post argues for.

  • The WordPress PHP Version Problem: A Third Of Sites Get No Security Fixes

    The WordPress PHP Version Problem: A Third Of Sites Get No Security Fixes

    Short answer: as of 5 September 2026, at least 32.8% of WordPress installs ran a PHP branch that receives no security fixes at all, counting only the three largest unsupported branches. Re-reading the full statistics later, and counting every branch with zero current support rather than only the largest three, put the real figure at 38.36%. PHP 8.2, a further roughly 24.8%, loses its security support on 31 December 2026. Checking a client’s wordpress php version takes one command, and the deadline is already on the calendar.

    These numbers are not estimates. They come from the WordPress.org statistics API, which reports what installs actually report, and the support dates come from php.net’s own supported versions page.

    Where every branch stands today

    Table of PHP branches with install share and support dates. 7.4 at 17.2 percent, 8.0 at 4.1 percent and 8.1 at 11.5 percent are all end of life. 8.2 at 24.8 percent has security support until 31 December 2026. 8.3 at 25.1 percent, 8.4 at 8.4 percent and 8.5 at 2.9 percent remain supported
    Three red rows total 32.8% of the WordPress web running code that nobody patches.

    The amber row is the interesting one. It is the single most popular branch after 8.3, and its security support ends in under four months.*

    BranchInstallsSecurity support endsStatus today
    7.417.2%ended 2022end of life
    8.04.1%ended 2023end of life
    8.111.5%ended 2025end of life
    8.224.8%31 Dec 2026security only
    8.325.1%31 Dec 2027supported
    8.48.4%31 Dec 2028supported
    8.52.9%31 Dec 2029supported

    The distribution behind that:

    Bar chart of PHP versions in use across all WordPress installs from the WordPress.org statistics API, with end of life branches marked in red, PHP 8.2 marked as security-only, and supported branches in blue
    Generated directly from the statistics API so the chart cannot drift from the source.

    PHP 7.4 alone, released in 2019 and unsupported since 2022, is still 17.2% of all WordPress installs.*

    Add the red and the amber together and 57.6% of the WordPress web is either unsupported today or will be by New Year’s Day.

    Re-reading the same API found a real undercount in the figure this post gave

    I queried the same WordPress.org statistics endpoint again while expanding this post, and the three named branches in this post’s table held almost exactly steady: 7.4 at 16.99% (was 17.2%), 8.0 at 4.06% (was 4.1%), 8.1 at 11.38% (was 11.5%), summing to 32.43% against the original 32.8%. That part of the finding is confirmed, stable, and essentially unchanged months later.

    Reading the full raw response rather than only the branches this post’s table names turned up something the original table did not capture:

    curl -s https://api.wordpress.org/stats/php/1.0/
    {"4.4":0.008,"5.1":0.003,"5.2":0.127,"5.3":0.265,"5.4":0.182,"5.5":0.154,"5.6":1.341,
     "7.0":0.592,"7.1":0.361,"7.2":1.414,"7.3":1.482,"7.4":16.987, ...}

    Eleven more branches, PHP 4.4 through 7.3, every one of them years further past end of life than 7.4 itself, each carrying a small but real, non-zero share of live WordPress installs. Summed together they add another 5.93 percentage points that this post’s original “32.8%” headline never counted, because the table only named the three largest EOL branches rather than every branch reporting zero current security support.

    The corrected total: 38.36% of WordPress installs run a PHP branch with no security fixes at all, not 32.8%. Add PHP 8.2’s 24.79%, security-only until the end of this year, and the combined at-risk-or-soon-to-be share is 63.15%, not the 57.6% this post states. Both corrections move in the same direction, understating the problem rather than overstating it, which is worth being direct about: the original figure was not wrong about the branches it named, it was incomplete about how many other branches exist below the ones anyone bothers to name individually. A site running PHP 5.6 is not a rounding error. It is a real, currently active WordPress install, and it belongs in this count as much as a 7.4 site does.

    What end of life actually means for a client

    It does not mean the site stops working. That is exactly the problem, and it is why this is a hard conversation to have.

    A PHP 7.4 site runs fine. It serves pages, takes orders, and gives no outward sign that anything is wrong. What has changed is that when a vulnerability is found in the language itself, no fix will be written for that branch. Not delayed. Not deprioritised. Not written.

    Your client is not running old software. They are running software that has left the support model entirely, on a schedule that php.net published years in advance.

    The second effect is quieter and arrives sooner. Plugin and theme authors raise their minimum PHP requirement over time. A site on 7.4 gradually stops receiving updates for the things running on it, which means it accumulates plugins that will never be patched again, and the security exposure compounds from both directions.

    Checking the WordPress PHP version, and upgrading in the order that saves time

    Five-step checklist for a PHP upgrade: confirm the running version with WP-CLI rather than the host dashboard, check the minimum PHP of every active plugin and theme, identify anything already removed from the directory, enable deprecation logging on the current branch, and run the site on the target branch in staging while walking the real user journeys
    Step three is the one agencies skip and the one that turns a two-hour job into a

    three-week project. A plugin pulled from the directory will not be made compatible with PHP 8.4 by anybody.*

    #CheckWhy
    1what the site runs nowwp eval 'echo PHP_VERSION;', not what the host’s dashboard claims
    2minimum PHP of every active plugin and themeone abandoned plugin sets the ceiling for the whole site
    3anything already removed from the directoryan unmaintained plugin will never gain compatibility
    4deprecation notices on the current branchtoday’s notices are the next branch’s fatal errors
    5run the target branch in staging, walk ithomepage, a form, checkout, admin, not static analysis alone

    Start with the actual version, not the host’s control panel:

    wp eval 'echo PHP_VERSION;'

    CLI and web-server PHP are frequently different versions on the same host, and it is the web-server one that serves your visitors.

    Then the ceiling, which is set by whichever plugin has the highest minimum:

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

    One note on step four, because it is genuinely useful and rarely done. Turn on deprecation logging in staging on the current branch. Today’s deprecation notices are the next branch’s fatal errors, so you get a preview of the upgrade before you attempt it. Just do not leave that logging on afterwards, or you will publish the results to the internet.

    What a stranger can already see

    Before the upgrade conversation, it is worth knowing how much of this is public.

    Table of response headers from six hosts. A stock WordPress install returns Server Apache and X-Powered-By PHP 8.3.33, disclosing the exact version. woocommerce.com, wordpress.org, sitea.example, siteb.example and sitec.example disclose no PHP version
    A default PHP install publishes its exact version to every visitor. The production

    sites here do not, because a CDN or a hardened configuration strips it. Disclosure does not create the vulnerability, but it removes the guesswork for anyone scanning.*

    HostPHP version visible?
    stock WordPress installyes, exactly: X-Powered-By: PHP/8.3.33
    woocommerce.comno
    wordpress.orgno
    sitea.exampleno
    siteb.exampleno
    sitec.exampleno

    Caveat: six hosts, five of them ours or ones we track closely. Header disclosure being absent everywhere here says more about CDNs stripping the header by default than about any deliberate hardening. Check your own site’s headers rather than assume the pattern holds.

    I re-checked the same five reference sites’ headers while expanding this post. Four are unchanged, still no PHP version disclosed. woocommerce.com now sends an X-Powered-By header where it previously sent none, but it names the hosting platform rather than a PHP version:

    x-powered-by: WordPress VIP <https://wpvip.com>

    That is a different category of disclosure entirely, worth distinguishing rather than folding into the same row as a version leak. Naming a managed hosting platform tells a visitor, or a scanner, which infrastructure provider is involved, useful reconnaissance in its own right, but it does not hand over an exact PHP version the way X-Powered-By: PHP/8.3.33 does. Reading a header change like this one carefully, rather than treating “a new header appeared” as automatically the same finding as before, is the same discipline this post already argues for when distinguishing version disclosure from the underlying support status: two things can look similar in a header dump and mean entirely different things once actually read.

    Turn it off with expose_php = Off in php.ini, and treat that as tidying rather than as a fix. Hiding the number changes nothing about the branch underneath it, and a site that hides its version while running 7.4 has solved the smaller half of the problem.

    How to raise it with a client

    The version number means nothing to them. Two framings that work:

    The support one. “The language your site is built on stopped receiving security fixes in 2022. If a flaw is found tomorrow, there is no update to install.”

    The compatibility one. “Plugins are dropping support for this version. Every month the site stays here, fewer of the things it depends on can be updated.”

    Avoid predicting a breach. You cannot know that, saying it is not honest, and it makes the rest of your advice easier to discount.

    There is a third framing that works on clients who have been quoted for this before and said no. Put a date on it. “PHP 8.2 stops receiving security fixes on 31 December. If we move you in October it is a planned afternoon. If we move you in January it is an emergency, and emergencies cost more.” A published deadline is far easier to act on than a general sense that something is old, and this deadline is genuinely published, years in advance, by the people who maintain the language.

    The honest counterpoint, which is worth saying out loud rather than waiting to be asked: an upgrade can break a site. That is a real risk and pretending otherwise damages your credibility. The answer is not to avoid the upgrade, it is to do it behind a restore point you have actually tested, in staging first, with the checkout and the forms walked afterwards. The risk of moving is a Tuesday afternoon. The risk of not moving accumulates every month and has no end date.

    Why this is a portfolio problem

    For one site, the check is one command. Across forty sites it is forty logins, and the answer changes whenever a host migrates a server or an upgrade is done for one client and not the others.

    So it gets audited once, usually during onboarding, and then drifts. That is the same unbilled half hour that swallows every other check worth doing.

    Protuno’s free audit reports the PHP version from a connected site, and the deadline above makes this a good quarter to have that list in one place. Rook is live now.

    If you do one thing after reading this: list the PHP version of every site you look after, in one document, with the branch deadline next to it. Four of those deadlines are already in the past, and one is 31 December.

    Re-read the statistics API itself before quoting a percentage to a client, too, and count every branch it lists rather than only the largest few. This post’s own original figure needed exactly that correction, and a number that undercounts the problem is worth fixing the moment you notice, not defending because it was published first.