Blog

  • Technical SEO Audit: The Playbook, Step By Step

    Technical SEO Audit: The Playbook, Step By Step

    A technical SEO audit can crawl fifty pages and still miss the one setting that hides all of them.

    That setting is a single checkbox. No crawler reads it from the outside, and it can sit switched on for months while every other report looks tidy.

    Short answer: a technical SEO audit is a fixed list of questions about whether a site can be found, read and trusted by a search engine. The useful ones start from inside WordPress, where the indexing switch lives, then check the public pages, then say plainly what they could not see. Protuno’s Technical SEO Audit is an eight-step, read-only playbook built in that order.

    What this post is, and is not: it is a practical walk through how one audit is put together and how to run a small version by hand today. It is not a promise that a clean audit will rank anything. Technical SEO removes reasons not to rank. It does not supply a reason to.

    Technical SEO audit pipeline: eight read-only steps from the on-site baseline to a prioritised report
    Eight read-only steps, from the indexing baseline to the report.

    An audit tool tells you what it found. A good audit also tells you what it could not look at.

    Why A Crawl Alone Is Not An Audit

    Most technical SEO audits begin with a crawler. Point it at the homepage, let it follow links, collect status codes, titles and canonicals, then sort the list.

    That is a useful view. It is also a view from the outside, and the outside has three blind spots.

    The first is configuration. A crawler can see a noindex tag on a page, but it cannot read the WordPress option that tells search engines to stay away from the whole site. We wrote about what that setting does and does not change in the robots.txt myth post, where Disallow and noindex turn out to be two different instructions.

    The second is reach. A crawler follows links. A page nothing links to never appears in the crawl, which is why orphan pages need a different source of truth.

    The third is honesty about coverage. A crawl that quietly stops at its page limit, or gets an interstitial page instead of the real site, produces a clean-looking report built on partial data.

    None of this makes crawling wrong. It makes crawling one step in a sequence, not the whole job. For what a crawl actually produces on a real site, see our crawl audit write-up.

    Three Ways To Look At The Same Site

    A site can be examined from the inside, from the public pages, and from a search engine’s own records. Each answers a different question.

    ViewWhat it tells youWhat it cannot prove alone
    From inside WordPressWhether search engines are allowed in at all, the permalink structure, HTTPS, which SEO plugin is active, how many pages are publishedWhat a visitor or a crawler actually receives
    From the public pagesStatus codes, canonicals, robots meta, titles, descriptions, structured data as servedSettings that never show up in the HTML
    From Search ConsoleWhich pages have impressions and how Google treats themAnything about pages Google has never seen

    The third view is optional in Protuno’s playbook, and the playbook says so out loud. More on that below.

    Three views of one site: inside WordPress, the public pages, and Search Console, each with its blind spot
    Each view catches what the others cannot prove.

    A Small Test You Can Run Today

    You do not need a tool to check the highest-impact items. Pick one site and record these:

    • Is “Discourage search engines from indexing this site” switched on under Settings, Reading?
    • Does /robots.txt return 200, and does it contain a blanket Disallow: / for every user agent?
    • Does a sitemap exist at /sitemap.xml, /wp-sitemap.xml or /sitemap_index.xml?
    • Does the http:// version of the homepage redirect to https://?
    • Does your homepage declare a canonical that points to itself?
    • Does your latest post carry a robots meta tag with noindex?

    Three of those take one command each:

    curl -s https://example.com/robots.txt | head -20
    curl -sI http://example.com/ | head -5
    curl -s https://example.com/ | grep -i -E 'rel="canonical"|name="robots"'

    Write the answers down next to the date. Then do the same for four more sites. You now have the shape of a technical audit, and you will see why it needs a fixed order.

    The Playbook, Step By Step

    Technical SEO Audit is a Protuno SEO playbook. In the Protuno dashboard it is a Beta playbook handled by the SEO agent, Iris. It runs a fixed sequence of eight steps and only reads the site. It changes nothing. It is not on the public playbooks page yet, so treat it as a new playbook that is still being rolled out.

    #StepWhat it does
    1On Site Indexability BaselineReads the signals only the plugin can see: the search engine visibility option, permalink structure, HTTPS, which SEO plugin is active, whether core sitemaps are on, and how many posts and pages are published
    2Crawl The Site As A Browser Sees ItCrawls the public pages, bounded to about 50, and records status, canonical, robots meta, title and description length, and whether structured data is present
    3Sitemap And Robots HealthFetches robots.txt and looks for a sitemap at the usual addresses, checking it stays inside protocol limits
    4Schema Validity CheckScans for JSON-LD blocks and checks they parse and declare a context and a type
    5Core Web Vitals CheckAsks the public PageSpeed API for a lab estimate on the homepage, and reports unknown if it cannot
    6Indexation Canonical And Redirect HygieneFetches the homepage and the most recent posts and pages, comparing canonicals and robots meta, and tests whether plain HTTP redirects
    7GSC Merge OptionalAdds Search Console impressions if a source is connected, and skips cleanly if not
    8Prioritised Technical SEO ReportBuilds one ranked report with evidence, and lists any pillar it could not check

    Order matters here. Step 1 comes first because every later step depends on it, and the playbook treats a missing baseline as a broken chain, not as a pass. The crawl is step 2, not step 1, because a crawl with no baseline cannot tell a deliberate noindex from an accidental one.

    Step 1 In Detail: The Baseline Nobody Crawls

    The baseline reads the WordPress option that decides whether the whole site asks to be indexed. If that option says no, the playbook records a critical finding. A site asking Google not to index it is the most expensive technical problem you can have, and it is the least visible.

    It also flags a plain permalink structure (the ?p=123 style) and a site not served over HTTPS as high-severity findings. It notes the active SEO plugin and whether core sitemaps are enabled, because a site with neither has no sitemap source.

    Step 2 In Detail: A Crawl With Its Limits Stated

    The crawl is bounded to roughly fifty pages. If the crawl engine hits an interstitial page, such as a tunnel warning, the playbook discards that page rather than treating it as the site. If every page is an interstitial, it falls back to fetching pages from the server itself.

    The playbook also names a gap that most audits skip: signals injected by JavaScript can be invisible to a server-rendered read. That is stated as a coverage gap in the report, not hidden.

    Steps 3 To 6: One Pillar Each

    Robots and sitemap come from the site fetching itself, so nothing in front of the site can interfere. A robots file that blocks everything for all user agents is a critical finding. One that cannot be reached is a low finding, because an unreachable file does not prove there is none.

    Schema is checked structurally: does it parse, and does it declare a context and a type? The playbook says that is not full validation and points to Google’s Rich Results Test for the authoritative answer. It never claims rich-result eligibility.

    Core Web Vitals are lab estimates against the usual thresholds: LCP at 2.5 seconds, INP at 200 milliseconds, CLS at 0.1. INP is a field metric with no lab equivalent, so the playbook reports Total Blocking Time as a proxy and labels it as one. If the PageSpeed call fails, the pillar says unknown with the reason.

    Indexation hygiene looks at a small sample, the homepage plus the most recent posts and pages, and compares each page’s canonical to its own address. It flags a missing canonical, a canonical pointing elsewhere, unexpected noindex, and an HTTP version that does not redirect.

    Technical SEO Audit: eight read-only steps.

    What A Technical SEO Audit Does Not Solve

    A technical audit is narrow on purpose. It does not tell you:

    • Whether the content is any good, or whether anyone wants it.
    • Whether other sites link to you.
    • Whether a clean report will lead to rankings. It will not guarantee that.
    • What real visitors experience. Lab estimates are not field data.
    • Which fixes are safe to make. The playbook recommends, a person decides.

    Protuno’s own report footer says the same: technical audit only, no content quality or backlink judgment.

    Questions To Ask Your Current Audit Tool

    • Does it read the WordPress indexing option, or only what is in the HTML?
    • What is its page limit, and does the report say when the limit was hit?
    • What does it do when the site returns an interstitial page instead of content?
    • Does it separate “clean” from “could not check”?
    • Are Core Web Vitals labelled lab or field?
    • Does every finding come with the URLs it affects and the evidence behind it?

    If a tool cannot answer the fourth question, treat every green tick with suspicion. A false zero issues is worse than an honest “could not check”.

    Which Check Are You Actually Running?

    If you need to knowEvidence that answers itNext action
    Is the site allowed to be indexed?The visibility option, robots.txt, meta robots on key pagesFix the setting first, before anything else
    Can a search engine find the pages?Sitemap present and in limits, internal linksRepair the sitemap, see the sitemap audit post
    Are the pages consistent?Canonicals and redirects on a sampleCorrect the canonical or flatten the redirect
    Does the markup parse?JSON-LD scan, then Rich Results TestFix the invalid block
    Is it fast enough?Lab estimate, then field dataTreat the lab number as a pointer, not a verdict
    Which pages deserve effort first?Search Console impressionsConnect Search Console or accept severity-only ordering

    Fixing Findings In The Right Order

    The playbook ranks findings by severity, and by traffic too when Search Console is connected. That is a sensible default for a manual audit as well.

    PriorityExamplesWhy this order
    CriticalSite-wide noindex, robots blocking everythingNothing else matters while the site asks to be ignored
    HighPlain permalinks, no HTTPS, unexpected noindex on a pageThese change what can rank
    MediumMissing sitemap, canonical mismatch, redirect chain, HTTP not redirectingThese dilute signals
    LowUnreachable robots.txt, high Total Blocking TimeThese need a second look before action

    Two practical rules apply to every fix. Take a restore point before changing a setting or a redirect rule. Re-run the same check after the change, so you know the fix did what you intended and nothing else.

    A Five-Site Audit You Can Do This Week

    Pick five sites you look after. For each, record the visibility setting, robots.txt status, sitemap address, canonical on the homepage, and whether HTTP redirects to HTTPS.

    Then decide, for each finding, one of these:

    • Fix now, because it is critical or high.
    • Test first, because the change could affect live traffic.
    • Replace, because the plugin producing it is the problem.
    • Remove, because the setting or file is no longer needed.
    • Accept, with an owner and a date.
    • Investigate, because the evidence is incomplete.

    “Investigate” is a legitimate answer. It is better than guessing. If you want the longer version of the crawl side, the broken link audit post covers why a 404 is only the easy half.

    Why Optional Does Not Mean Unimportant

    Step 7, the Search Console merge, is labelled optional in the playbook. If no source is connected, it skips and the report says findings are ranked by severity alone, not severity times traffic. It does not invent impressions.

    That honesty matters. A page with a medium finding and ten thousand impressions deserves attention before a page with a high finding and none. Without the traffic data you cannot tell them apart, and the report says so.

    Speed is a related case. If a slow response is part of what worries you, the TTFB explainer covers why the usual number is easy to misread.

    Where Protuno Fits

    In the Protuno dashboard, the SEO agent, Iris, covers this work. The Technical SEO Audit playbook is read-only. It reports, a human decides what to fix, and the report hands the top pages on to a separate per-page playbook, the AI SEO Optimiser, for the fix layer.

    If you want to see how a Protuno playbook is laid out before running anything, the SEO agent page shows what is built and what is still in build.

    The Point

    An audit is a list of questions asked in a sensible order, with an honest note on the ones it could not answer.

    Start with whether the site is allowed in. Then check what a visitor and a crawler receive. Then say what you could not see.

    Which of your sites would you trust to pass question one today?

  • WordPress Malware Scan: The Playbook, Step By Step

    WordPress Malware Scan: The Playbook, Step By Step

    Your site can look perfectly fine to you and still be selling pills to Google.

    That is the unsettling part of modern WordPress malware. The owner loads the homepage, sees the real homepage, and moves on. The crawler gets a different page. The client finds out when a customer asks why the search result mentions casinos.

    Short answer: a useful WordPress malware scan checks four places, not one: what the site shows to crawlers, what is sitting in the files, what is sitting in the database, and what has been set up to bring the problem back. Then it verifies every hit before it accuses anything, and it says plainly which areas it could not reach.

    What this post is, and is not: this is a practical walkthrough of how a read-only malware scan is built and how to read its output. It is not a claim that any scanner can promise a site is clean forever, and it does not describe a cleanup. Detection and removal are different jobs, and mixing them is how good files get deleted.

    An alert says something matched. A report says what it is, how sure we are, and what we could not see.

    Six numbered cards listing the WP Malware Scan steps: gateway and environment, external cloaking scan, filesystem malware sweep, database and persistence scan, verify and attribute findings, and forensic malware report. A note underneath says detection only, nothing is deleted, quarantined or edited.
    Six read-only steps, from environment to report.

    Why A Single Scan Falls Short

    Most people meet malware scanning as one button. It reads the files, matches them against a list, and prints a number.

    That catches the loud cases. It misses the quiet ones, and the quiet ones are the ones that last.

    Where can an attacker leave something behind?

    • In a PHP file that only runs when the visitor looks like a search engine.
    • In an option row in the database, stored encoded so a plain text search finds nothing.
    • In a scheduled task that quietly rewrites the payload an hour after you delete it.
    • In an administrator account that does not show up in the normal user list.
    • In an application password, which can survive a normal password reset.

    A scanner that reads files only will never see the second, third, fourth or fifth. A scanner that reads your pages only as a browser will never see the first. Each blind spot is a place to hide.

    Three Ways To Look At The Same Infection

    The same compromise looks different depending on where you stand. That is why one view alone is rarely enough.

    ViewWhat it tells youWhat it cannot prove alone
    The page as a crawler sees itWhether search engines are being shown something your visitors are notWhere the code lives, or whether anything else is planted
    The files on diskWhich files are modified, unexpected or behave like backdoorsWhether a match is malicious or a legitimate library that looks odd
    The databaseInjected content, off-domain settings, hidden accounts, odd optionsWhether the file that wrote it is still there to write it again

    A file match is a clue. A cloaked page is evidence of a symptom. A database row is a place to look. None of them is a verdict until someone reads it in context.

    Three views of one infection: the page a crawler sees, the files on disk and the database, with what each cannot prove alone
    Each view catches what the others cannot prove.

    A Small Test You Can Run Today

    Before you trust any scanner, including ours, ask a simpler question. Does your site show the same page to everyone?

    Pick five URLs: the homepage, two recent posts, one page from your sitemap, and one odd query string. Then request each one three ways and compare. Write down:

    • The page title for each request.
    • The social preview title and image.
    • Any words in the body that do not belong on your site.
    • Any script or image loaded from a host you do not recognise.

    A basic way to see it from a terminal:

    curl -s -A "Googlebot/2.1 (+http://www.google.com/bot.html)" https://example.com/ | grep -i "<title>"
    curl -s -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)" https://example.com/ | grep -i "<title>"
    curl -s -A "Googlebot/2.1 (+http://www.google.com/bot.html)" "https://example.com/?g=casino" | grep -i "<title>"

    If the titles differ, or a query string produces a page that does not exist in your sitemap or your editor, stop and investigate. Two different titles are not proof of malware on their own, because some sites legitimately serve different markup to bots. But an unexplained difference is exactly what a scan is meant to surface.

    The Playbook, Step By Step

    WP Malware Scan is a Protuno security playbook, currently in beta. It runs a fixed sequence of six steps, and it only reads the site. It changes nothing: nothing is deleted, nothing is quarantined, nothing is edited. It needs the Protuno plugin on the site, because it runs its checks from inside it. It is not on the public playbooks page yet, so treat it as a new playbook that is still being rolled out.

    #StepWhat it does
    1Malware Scan Gateway And EnvironmentReads the WordPress and PHP versions, the real database table prefix, multisite status and key paths, then records what the scan can and cannot reach.
    2External Cloaking ScanRequests a bounded set of URLs as a crawler, a social preview bot and a desktop browser, and compares what each one is shown.
    3Filesystem Malware SweepLooks across the web root, WordPress core, plugins, themes, uploads, mu-plugins and drop-ins for modified, unexpected or suspicious files. Reports candidates, not verdicts.
    4Database And Persistence ScanReads options, posts, users and scheduled tasks, plus startup rules such as .htaccess, for injected content and ways back in. Select queries only.
    5Verify And Attribute FindingsReads each candidate in context and sorts it into confirmed, needs human review, or false positive. Looks for the likely way in.
    6Forensic Malware ReportWrites one honest report with a status, the evidence, and a described remediation plan. Executes nothing.

    The order matters. The first step decides what the others are allowed to claim. The next three collect candidates. The fifth step is the only one allowed to turn a candidate into a finding. The sixth cannot invent anything the earlier steps did not produce.

    Step 1: Know What You Can Reach

    Before it looks for anything, the scan records its own limits: can it read files, search content, query the database, make web requests, read the debug log. Server-level scheduled jobs and web server access logs sit outside what a WordPress plugin can read, so they are written down as coverage gaps from the start. It also reads the real table prefix instead of assuming wp_.

    Step 2: Look Like Google

    This is the step most scanners skip. The scan requests a bounded set of URLs, the homepage, up to around fifteen sitemap URLs and a few recent posts, as Googlebot, as a social preview bot and as a normal desktop browser. It compares titles, social titles and images, and checks the body for spam words such as casino, slot, pharmacy and replica. It also flags images or scripts loaded from hosts that commonly serve spam payloads.

    If the real server address is known, it asks the origin directly too, because a clean cached copy at a CDN can hide a dirty origin. If it is not known, that is recorded as a gap, not a pass. It also tries a couple of query strings as a crawler, because some spam pages only exist when a particular URL is requested.

    Step 3: Candidates Only

    The filesystem step works through the places files hide: odd folders and loose PHP in the web root, modified core files, plugins on disk but not active, PHP in uploads, mu-plugins and drop-ins, known web shell patterns and obfuscated code that runs input.

    Core files are compared with the official WordPress checksum list for the exact version installed. If that reference cannot be fetched, the step must say core integrity could not be checked. It may not compare against an empty reference and call a file modified. A false alarm that sends an agency hunting a compromise that does not exist is treated as worse than an honest “not checked”. Vendor folders and minified files are listed but never marked safe to auto-clean, because legitimate code uses the same tricks malware does.

    Step 4: The Database And The Way Back

    The database step reads every table with select queries only. In options it looks for injected scripts, off-domain site addresses and known indicator keys. It also decodes values that look like base64 and tests the decoded text, because an encoded payload contains none of the obvious markers.

    It compares the number of administrators in the database with the number in the user list; a gap means a hidden account. It lists application passwords by name, never the secret. For comments it reads only for injected markup. It does not count spam or advise emptying the spam or trash folders, because that trash is the undo path for comment cleanup.

    Step 5: Read Before You Confirm

    Every candidate is read in context and labelled confirmed, needs human, or false positive. Unsure means needs human, never confirmed. A documented list covers legitimate things that look suspicious, such as snippet plugins that store PHP in uploads on purpose.

    The step also tries to work out how the attacker got in, using the debug log where available. Without access logs, the report says attribution is limited. It maps everything that could rewrite a payload, so those can be switched off together, and if no vulnerability feed key is set it says that check was not assessed, never “no vulnerabilities”.

    Step 6: Say What Is Actually Known

    The report ends in one of four states:

    StatusWhat it means
    InfectedAt least one finding was confirmed, in a file, the database or a cloaked page.
    SuspiciousNothing confirmed, but at least one item needs a human to look.
    CleanNothing confirmed, nothing needing review, and full coverage with no gaps.
    IncompleteNothing confirmed, but some areas could not be scanned, or an earlier step did not finish.

    The last one is the important one. “No malware found in what could be scanned” is a different sentence from “your site is clean”. The playbook is written so that the word clean is earned, never assumed, and so that a layer that did not finish is never reported as clear.

    WP Malware Scan: six read-only steps.

    What A Read-Only Malware Scan Does Not Solve

    • It does not remove anything. Cleanup needs an off-site backup you hold and a human confirming each item.
    • It cannot see server-level persistence or web server access logs from inside WordPress.
    • It cannot promise the site stays clean after today.
    • It does not fix the way in. A stolen password or an outdated plugin will let the next one through.

    Questions To Ask Your Current Scanner

    Whatever you use today, these separate a scan from a signature match:

    • Does it ever request pages as a crawler, or only read files?
    • Does it check core files against the official checksums for your exact version?
    • Does it read the database, including encoded option values?
    • Does it look for hidden administrators and leftover application passwords?
    • When it finds a match, does it read it in context before calling it malware?
    • When it cannot reach a place, does it tell you, or stay silent?

    If the answer to the last one is silence, the green tick means less than it looks like.

    Which Check Are You Actually Running?

    If you need to knowThe evidence you needThe next action
    Whether search engines see spam you do notCrawler and browser responses comparedInvestigate any difference before touching files
    Whether WordPress core was alteredFiles compared against the official checksum listReinstall from a clean source, after a backup
    Whether something is hidden in the databaseOption, post and user rows read in full contextReview by a person, with a backup in hand
    Whether an old vulnerability is the doorInstalled versions matched against a vulnerability feedSee vulnerability monitoring
    Whether it will come backA map of every item that can rewrite the payloadNeutralise them together, then watch for reinfection

    Fixing Findings In The Right Order

    When a scan reports a confirmed finding, the order of work matters more than the speed.

    PriorityActionWhy this order
    1Take a full off-site backup of files and databaseYou need an undo that the attacker cannot touch
    2Note a health baselineSo you can tell whether your fix broke something
    3Reinstall legitimate core and plugin files from clean sourcesReplaces modified files without guessing
    4Neutralise everything that can rewrite the payload, togetherDelete one writer first and it regenerates
    5Move confirmed malware aside rather than deleting itQuarantine can be reversed
    6Rotate secrets and revoke rogue application passwordsA reset alone leaves some access in place
    7Prove it clean as a crawler and watch for ten to fourteen daysReinfection often shows up later

    Two practical rules sit under all of it. Restore point first, because you are about to change a live site. Re-check after each change, because the fix itself can break things. See verify a WordPress backup for why a backup you have not tested is only a hopeful file.

    A Five-Site Audit You Can Do This Week

    Pick five client sites. For each one, write down:

    • The site and who owns it.
    • Whether pages looked the same as a crawler and as a browser.
    • Whether the number of administrators matches the user list.
    • The date core files were last checked against official checksums.
    • Which areas nobody can currently see: server logs, scheduled jobs, the origin behind a CDN.

    Then choose one outcome for each item you found:

    • Fix now: confirmed, backup taken, plan agreed.
    • Test first: looks suspicious, reproduce it on a copy.
    • Replace: a plugin you cannot verify, swap it for a maintained one. See what happens when a plugin leaves the repository.
    • Remove: unused plugins and themes, which still count as attack surface.
    • Accept with an owner and a date: a known gap, written down, with a review date.
    • Investigate: unexplained difference, needs a person.

    Stopping the next break-in is half the job, so read the hardening checklist and the wp-config exposure check next.

    Why A Malware Scan Goes Stale

    A scan is a photograph. It tells you about the moment it ran.

    A clean result on Monday says nothing about a plugin installed on Tuesday, a password reused on Wednesday, or a vulnerability published on Thursday. The real value of a scan is not one report. It is the habit of running it again, comparing the answers, and noticing what changed.

    Coverage gaps matter as much as findings. A gap you wrote down is a question you can answer next month. A gap nobody recorded is where the next problem lives.

    Where Protuno Fits

    Protuno’s Rook is the Security Super Agent. WP Malware Scan is one of its playbooks, and it is read-only: it reports what it found, how sure it is and what it could not see, and a human decides what happens next. It does not clean, quarantine or delete anything. It is new and not on the public playbooks page yet; the current public security playbooks are listed on the playbooks page.

    For the wider picture of how security checks fit together, read WordPress security monitoring and WordPress security audit.

    The Point

    A site is not clean because nobody has complained yet.

    It is clean when the places that matter have been looked at, the matches have been read, and the gaps have been named.

    When did you last look at your site the way Google does?

  • WordPress Security Audit: The Playbook, Step By Step

    WordPress Security Audit: The Playbook, Step By Step

    A security grade is only useful if it tells you what it did not check.

    That sounds like a small detail. It decides whether an “A” means your site is safe, or only that the scan never reached the parts that would have lowered it.

    Short answer: a good WordPress security audit reads the site in passes, checks the site directly, tries to verify what it finds, and then gives one grade with the gaps written beside it. A grade with no gaps listed is a guess dressed as a result.

    What this post is, and is not: it is a practical look at how a graded audit is built, and what one real run on a test site produced. It is not a claim that every finding below is exploitable, or that a grade replaces a human review.

    Two cards side by side. The left card shows a security grade of C with an asterisk and a score of 78.6 out of 100. The right card lists the eight audit steps, with two marked as errored and the injection scan marked as not finished.
    A grade only means something next to the coverage behind it.

    Why A Single Grade Misleads

    Most people meet security as a number. A plugin shows a green badge. A report says 92 out of 100.

    The number is easy to read. What it hides is the question underneath it: what was actually looked at?

    A site can score well because it passed every check that ran, while the checks that matter most never ran at all. A timeout, a missing permission or a blocked feed can quietly remove half the evidence. The score still arrives.

    That is why a useful audit shows two things together: the grade, and the coverage behind it.

    Four Passes, Not One Scan

    A WordPress site can be exposed in different ways, and each way needs different evidence.

    PassWhat it looks atWhat it cannot tell you on its own
    Configuration and exposureSettings and files a visitor can reach from outside, such as logs, headers, version details and usernamesWhether any installed software has a known flaw
    Vulnerable componentsInstalled core, plugins and themes, matched against public advisory dataWhether a plugin that is not on wordpress.org has a problem
    Access controlCode paths that should check who is asking before they actWhether a flagged path can really be abused
    Injection and executionCode that handles input, uploads, requests and dynamic executionAnything, if the scan did not finish
    Four labelled columns showing configuration and exposure, vulnerable components, access control, and injection and execution, with the type of evidence each pass reads.
    Four passes, each reading a different kind of evidence.

    The audit is the work of putting these four side by side, so one pass can cover the blind spot of another. If you want the monitoring side of the same question, WordPress Security Monitoring covers how to keep checking after the first audit.

    A Small Test You Can Run Today

    Pick one staging site, or a site you are authorised to manage. Open these in a private window and write down what each one returns:

    /debug.log
    /?author=1
    /readme.html
    /wp-json/wp/v2/users

    Then look at the response headers for the home page and note which of these are present: Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Referrer-Policy and Content-Security-Policy.

    A response is not automatically a vulnerability. It is evidence to review. The useful question is whether the exposure is needed, and who it helps.

    The Playbook, Step By Step

    WP Security Audit is a Protuno security playbook: a fixed sequence written down before it runs, so it asks the same questions every time. It only reads the site and changes nothing. There are eight steps, as they appear in the Protuno dashboard. It is not on the public playbooks page yet, so treat it as a new playbook that is still being rolled out.

    #StepWhat it does
    1Recon And FactsReads the live site: WordPress and PHP versions, theme, plugins, admin count and whether registration is open
    2Config And Exposure AuditChecks what an unauthenticated visitor can reach: debug log, headers, version disclosure, usernames, directory listing
    3Vulnerable Components AuditMatches installed core, plugins and themes against public vulnerability data
    4Security Scan, Access ControlSweeps plugin and theme code for routes that may skip an access check
    5Security Scan, Injection, Execution And Supply ChainLooks for injection, upload, request-forgery and dynamic-execution patterns
    6Verify And Triage FindingsChecks each finding against the real code before it is reported, and ranks it
    7Posture Score, A To FTurns the evidence into a grade, and marks any area it could not grade
    8ReportWrites the plain-language report with a fix for each finding

    Step six is the one that matters most, because an unverified finding is an accusation and a verified one is a task. It is also the step that errored in the real run below, so the findings in this post come from the direct checks in steps one to four, not from step six.

    What It Found On A Real Site

    The video below walks through the result of one real audit run on a WordPress test site, on 24 September 2026. Steps one to four and step seven completed. Step five did not finish, because its code scan timed out three times. Steps six and eight errored. That is part of the result, so it is shown rather than hidden.

    • Grade: C* with a score of 78.6 out of 100. The asterisk marks that one area was left ungraded.
    • Two critical findings: the debug log was publicly readable at about 1.4 MB, and the only admin account had no two-factor authentication.
    • Warnings: the admin username was revealed through the author archive, three security headers were missing, the WordPress version was disclosed in several places, plain HTTP was still served, there was no login lockout, and PHP errors were printed to visitors.
    • Low priority: in-dashboard plugin and theme installs were still allowed.
    • Passed: the checked components had no known vulnerabilities, and the core configuration checks passed, including file permissions, security salts, the file-edit lock and closed registration.
    • Not finished: step five (injection and execution) timed out three times, and steps six (verify and triage) and eight (report) errored. The report page still showed the findings from steps one to four and the score from step seven, and it states that the unfinished checks were unassessed, not clean.
    • Candidates, not confirmed: the access-control sweep flagged 65 candidates and one request-forgery candidate across 4,557 files. None was confirmed exploitable, and the report says so.
    WP Security Audit on a real test site, 24 September 2026.

    The honest part is the useful part. A scan that times out and still prints “no issues” is worse than a scan that says “this part did not run”. The grade here is lower than it might have been, and it is also more believable.

    What An Audit Does Not Solve

    An audit is one moment in time, and security monitoring covers what changes after it. Neither is a full security programme.

    It cannot, by itself, tell you whether:

    • the site is being attacked right now;
    • a plugin that is not on wordpress.org has a known flaw;
    • a flagged code path can really be abused by a real visitor;
    • a file changed after the scan finished; or
    • the site has already been compromised.

    Those are different questions that need different checks. This is also why the vulnerability monitoring question and the audit question are kept apart.

    Reading The Findings

    These are the main findings from that run, ordered by what to fix first. The smaller ones are listed in the run summary above.

    PriorityFindingWhy it matters
    CriticalPublic debug logIt can reveal paths, plugin names and errors to anyone. See WordPress Debug Log Exposed
    CriticalNo two-factor authentication on the adminOne stolen password is then enough
    WarningUsername shown by the author archiveIt hands an attacker half of a login. See WordPress REST User Enumeration
    WarningMissing security headersThey are cheap to add and easy to verify. See WordPress Security Headers
    WarningVersion disclosed in several placesRemoving one tag does not remove the others
    WarningPlain HTTP still servedVisitors and bots can still reach the site without encryption
    WarningNo login lockoutRepeated password guesses are not slowed down
    WarningPHP errors visible to visitorsDebug output belongs in a private log
    LowIn-dashboard plugin installs still allowedLow priority if you use the dashboard for updates

    Two practical rules keep this safe. Take a restore point before changing anything that can affect the site. And re-check the finding after the change: “the setting was changed” is not the same as “the exposure is gone and the site still works.” The hardening checklist lists the fixes in a suggested order.

    When The Audit Cannot See A Plugin

    One line in the report deserves its own section. Three plugins on the test site were private or off-repository, so public vulnerability feeds had nothing to say about them. The report listed them as “verify with the vendor” rather than counting them as clean.

    That distinction matters for any agency running custom or premium code. “No known vulnerabilities” can mean nothing was found, or it can mean nothing was checked. A good report tells you which. For a related risk, see WordPress Removed Plugin Risk.

    A Five-Site Audit You Can Do This Week

    Pick five sites you are responsible for. For each one, record:

    • WordPress and PHP versions
    • Whether a public debug log exists
    • Whether the author archive reveals a username
    • Which security headers are present
    • Whether the admin accounts use two-factor authentication
    • Whether failed logins are limited
    • Which plugins are private or off-repository
    • The owner, the next action and the date it was last checked

    You will learn two things quickly: which site has the biggest gap, and which of your checks would have been silently skipped. For each site, write the decision beside the finding: fix now, test first, accept with an owner and a date, or investigate. A finding without a decision is just a better-organised inbox.

    Where Protuno Fits

    In the Protuno dashboard, WP Security Audit sits under the Security agent, Rook. It is a read-only playbook with eight steps that reports a grade with its coverage beside it, and it does not change the site. Fixes stay a human decision, made with the evidence in front of you. It is new and not on the public playbooks page yet; the current public security playbooks are listed on the playbooks page.

    The Point

    A security grade should make you ask better questions, not stop asking them.

    A C that tells you what it could not check is worth more than an A that does not.

    So before asking “what is our score?”, ask the question underneath it: what did this audit actually look at, and what did it skip?

  • WordPress Security Monitoring: The Playbook, Step By Step

    WordPress Security Monitoring: The Playbook, Step By Step

    A vulnerability feed can tell you what is vulnerable. It cannot tell you which of your WordPress sites actually runs it.

    That sounds obvious. It changes the order a useful security check should happen in.

    Short answer: start with the site. Read its WordPress version, installed plugins, themes, and relevant exposure points. Match that inventory against known vulnerability information. Then validate whether the finding applies before asking anyone to act.

    Security monitoring becomes useful when it answers a question a site owner can do something with: what is running here, what needs attention, and why?

    What this post is, and is not: this is a practical monitoring playbook, not a claim that every visible endpoint is a vulnerability or that a vulnerability record proves a site is compromised. The checks below establish evidence, then give a human the context to decide what to do next.

    Three cards left to right: an advisory feed entry saying example-plugin is affected up to version 2.4.1, a site inventory showing example-plugin 2.3.0 active with no inactive plugins, and a relevant finding that this site runs an affected version.
    An advisory is a fact about software. A finding is a fact about your site.

    Why The Feed Comes Second

    Most vulnerability monitoring begins with a feed.

    A vulnerability is published. A scanner finds a matching plugin name or version. An alert arrives. That is useful as far as it goes.

    But there is a question that comes before it:

    What is actually running on this site?

    A client portfolio is rarely uniform. One site has an active plugin at one version; another has the same plugin inactive; a third has removed it. Themes differ. Core versions differ. A vulnerability can also depend on a feature, role, configuration, or route being available.

    If a tool starts with the advisory alone, it can create a queue of alerts that are technically related and operationally vague. If it starts with the site, the finding can be specific: this site is running this affected version, and this is why it needs investigation.

    That is the difference between an alert and work someone can complete. The scanner-design side of this argument is covered in WordPress Vulnerability Scanner Design: Start From The Site; this post is the wider monitoring routine around it.

    Three Ways To Look For The Same Problem

    There are three useful views of WordPress security. They overlap, but they do not answer the same question.

    ViewWhat it can tell youWhat it cannot prove on its own
    Advisory informationWhat software and versions have publicly reported issuesWhether a particular site has that software installed or is affected in practice
    Installed inventoryWhat WordPress core, plugins, and themes the site has installedWhether a known issue has been published for them, or whether they are externally exposed
    External checksWhat a visitor can observe from outside the siteWhat is installed privately inside the WordPress site

    The monitoring job is to connect the three. Each catches a blind spot left by the others.

    Three cards comparing advisory, inventory and external views of WordPress security, each showing what it can tell you and what it cannot prove alone.
    Each view catches a blind spot left by the others.

    A Small Test You Can Run Today

    Pick one staging site or a site you are authorised to manage. Make a simple record of:

    • WordPress core version
    • Active plugins and their versions
    • Installed plugins that are inactive but still present
    • Active and installed themes
    • Administrator and editor access that needs review
    • Relevant exposure points, such as REST API routes, XML-RPC, security headers, and version disclosure

    Now compare the installed versions with the vulnerability information you rely on.

    The useful result is not:

    “Plugin X has a vulnerability.”

    It is:

    “This site is running the affected version of Plugin X. Here is the advisory, the affected version range, and the next action to validate or remediate it.”

    That language makes the difference clear. It separates an industry fact from a site-specific finding.

    The Playbook, Step By Step

    Vulnerability Watch is a Protuno security playbook, currently in beta: a fixed sequence written down before it runs, so it asks the same questions every time. It only reads the site. Here is the sequence, as it appears in the dashboard.

    #StepWhat it does
    1Inventory Installed SoftwareReads WordPress core, plugins and themes, their versions and whether each is active, and flags software that is not on wordpress.org
    2Load Vulnerability FeedPulls the public advisory data to check the inventory against
    3Match Versions Against RangesCompares each installed version with the affected version range in the advisory
    4Vulnerability ReportReports what matched, and states what it could not check

    The order matters. An advisory match without the inventory is a broad warning. An inventory without advisory context is a software list. The report is only useful if it says what it could not assess as clearly as what it found.

    What It Found On A Real Site

    The video below shows the playbook running on a real WordPress site. The run was on 24 September 2026, took 1 minute 57 seconds, and completed all four steps with none failed or skipped.

    • Inventory: WordPress core, four plugins (all active, none inactive) and two themes (one active, one installed).
    • Off-repository software: three plugins that are not on wordpress.org, so public feeds do not cover them. The report marks them as needing verification with the vendor.
    • Result: 0 vulnerable, 0 high or critical. Four of seven items were assessed; three were not.
    • A feed that could not be reached: the report notes when one advisory source was unavailable and that the scan fell back to another, rather than hiding it.

    That last point is the useful one. A clean result from a check that quietly skipped half its inputs is worse than an honest “assessed four of seven”.

    Vulnerability Watch running on a real WordPress site: four steps, read only.

    What A Vulnerability Check Does Not Solve

    A vulnerability check is important. It is not a complete security programme.

    It cannot, by itself, tell you whether:

    • a core or plugin file has changed unexpectedly;
    • an installed plugin has been abandoned or removed from its source;
    • too much user information is publicly exposed;
    • XML-RPC is unnecessarily available for the site’s needs;
    • response headers are missing protections the site expects;
    • a configuration change left a route open; or
    • the site has already been compromised.

    Those are different questions, so they need focused checks. Treating every signal as one giant “security score” makes it easier to miss what the signal actually means.

    This is why Protuno keeps security work in separate playbooks rather than one generic scan. Seventeen are planned and four are built today, because vulnerability matching, integrity checks, hardening and malware response each rely on different evidence and different safe actions.

    Questions To Ask Your Current Monitoring

    Before changing tools, test the monitoring you already have. On a site you control, work through these questions:

    1. Can it identify the exact plugin and version installed on the site?
    2. Does it distinguish active software from inactive software that is still installed?
    3. Does it link a finding to the affected version range and advisory details?
    4. Does it tell you which findings need validation before an update, replacement, or mitigation?
    5. Does it test anything outside the vulnerability database?
    6. Can you show a client the evidence without translating a generic severity label first?

    If the answer to the first three is no, the system may be reading the feed more reliably than it is reading the site.

    External Exposure: Test The Site, Not The Assumption

    External checks matter because the public site may reveal things that an installed-software inventory cannot.

    For example, on an authorised test or staging site, inspect whether the following routes respond and what they disclose:

    /wp-json/wp/v2/users
    /?author=1
    /wp-sitemap-users-1.xml

    The outcome can vary by WordPress configuration, user permissions, theme behaviour, and security controls. A response is not automatically a vulnerability. It is evidence to review: are usernames disclosed in a way that creates a meaningful risk for this site, and is the exposure needed?

    Related checks are worth running in the same pass: an exposed debug log and an exposed wp-config.php neighbour. The same principle applies to version disclosure. Removing one generator tag does not prove the version is no longer visible; version references can appear elsewhere, including asset URLs. The right check verifies the observable result after a change.

    Which Check Are You Actually Running?

    If you need to know…Check the evidence that answers itTypical next action
    Is this site running known-vulnerable software?Installed inventory plus advisory matchingUpdate, replace, mitigate, or investigate the match
    Have files changed unexpectedly?Core and extension integrity checksInvestigate the change before treating it as harmless
    Is an installed extension no longer maintained?Plugin lifecycle and source-availability reviewAssess replacement and removal risk
    Are usernames or other data unnecessarily visible?External enumeration and exposure checksReduce exposure where it is not needed
    Is XML-RPC appropriate for this site?XML-RPC reachability and use-case reviewRestrict it only after checking integrations that depend on it
    Are key security headers present?HTTP response-header checksAdd or correct headers, then verify the response
    Has the site been hardened safely?Targeted hardening checks plus functional verificationConfirm the security control and the site’s normal behaviour

    The table is deliberately unglamorous. It is also how a team avoids “we ran security” becoming a phrase that means nothing when a client asks what was checked.

    Fixing Findings In The Right Order

    Not every security finding needs the same response. Start with the issues most likely to leave a site exposed, then preserve evidence and verify each change.

    PriorityWhat to addressWhy it comes here
    1Known-vulnerable software that is confirmed on the siteIt has a documented issue and a site-specific match
    2Unexpected file changes or signs of compromiseUpdating first can overwrite evidence or conceal what happened
    3Abandoned or pulled softwareIt may not receive fixes and needs a replacement or removal plan
    4Meaningful public exposureReduce unnecessary information or routes after checking legitimate dependencies
    5Hardening improvementsApply carefully, then prove the intended protection and normal site behaviour

    Two practical rules make this safer:

    • Take a restore point before changes that can affect the site.
    • Re-check the finding after the change. “The update was clicked” is not the same as “the vulnerable version is no longer installed and the site still works.”

    A Five-Site Audit You Can Do This Week

    Pick five sites you are responsible for. For each one, make a short, comparable record:

    • WordPress version
    • Installed and active plugin versions
    • Installed themes and the active theme
    • Relevant known-vulnerability matches
    • Plugins that are abandoned, removed, or no longer needed
    • File-integrity status
    • Public REST API and user-enumeration exposure
    • XML-RPC status and whether a real integration needs it
    • Security-header status
    • The owner, next action, and date the finding was re-checked

    You will learn two useful things quickly: which sites have the largest gap, and whether your monitoring is giving you enough evidence to prioritise work without opening every dashboard by hand.

    For the most important sites, do not stop at an alert. Record the remediation decision: update now, test first, replace, remove, accept temporarily with an owner and review date, or investigate. A finding without a decision is just a better-organised inbox.

    Why A Security Check Goes Stale

    A security audit is a snapshot. The site changes after it.

    Plugins update. New advisories appear. A theme changes. A new user gets a role. A client installs an integration. A route that was restricted becomes public again after a configuration change.

    So the useful question is not:

    “Did we check this site?”

    It is:

    “When did we last check what this site is actually running and exposing?”

    That is where continuous security monitoring earns its place: it makes the next relevant change visible before it becomes the thing a client finds first.

    Where Protuno Fits

    Protuno’s Rook Security agent (see the playbook list) reads what a WordPress site actually has installed, watches relevant advisory information against that inventory, and reports focused security signals in plain language. Its security playbooks are kept separate for the same reason: each part of a site’s posture needs its own evidence.

    Rook is designed to report what it found and what should happen next. It should not be treated as permission to make broad changes blindly: security improvements are strongest when the evidence, site context, and remediation decision stay connected.

    The Point

    Security monitoring does not become more useful because it produces more alerts.

    It becomes more useful when every alert is connected to what the site actually runs, what the public site actually exposes, and the next action a person can take with confidence.

    So before asking “What vulnerabilities exist?”, ask:

    “What is this WordPress site actually running right now?”

  • WordPress Uptime Monitoring: The Playbook, Step By Step

    WordPress Uptime Monitoring: The Playbook, Step By Step

    Somewhere right now, a monitoring dashboard is glowing green for a site that is showing a client a blank white page. I served three broken pages from a test server and checked what three kinds of check would say.

    Short answer: a status-code check calls all three broken pages healthy, because all three return 200. I served them from a small test server and ran three kinds of check against the same responses. The status-code check missed every one. An error-word check caught only the fatal error. A check that looks for text the healthy page should contain caught all three, but somebody has to keep that text up to date.

    What this test is, and is not: the pages come from a 20-line local test server that returns the responses a broken WordPress site would (an uncaught PHP error printed as text, an empty body, a placeholder page). It shows what each kind of check can see. It is not a WordPress install, and I did not test any vendor’s product.

    A status code is not a site: 200 OK over a struck-through Site: down
    A status code is not a site.

    The Check Nobody Questions

    “Is the site up?” feels like a solved problem, the kind of thing you set up once and forget. Point a monitor at the homepage, ping it every few minutes, get a message if it stops answering. Many hosts, security plugins and budget monitoring tools default to exactly this.

    Here’s the part that should bother you more than it does: “up” to that monitor just means a server opened a connection and handed back a number under 400. That’s it. Nobody ever taught it what your homepage is supposed to look like. It’s a bouncer checking that the door opens, not checking who walked through it.

    Three Ways A WordPress Site Returns 200 While Being Broken

    These failure modes are common enough that most agencies have met at least one of them. Each produces a response the server is happy to label 200 OK.

    1. A fatal PHP error with display_errors left on. The server returns 200, the visitor gets a stack trace where the homepage used to be, and a status-code monitor waves it through.
    2. A plugin conflict producing a white screen. No error text, no clue, just an empty <body> and a 200 status sitting on top of nothing.
    3. A homepage overwritten by a template import or a failed migration. The page loads instantly, returns 200, and is completely the wrong page: a placeholder, a builder’s default template, last month’s cache.

    A status-code monitor sails through all three. A person notices straight away. Guess which one your client trusts more.

    An HTTP 200 OK response with headers next to a blank browser window at clientsite.com
    Same response code. One of these is a working site.

    Three Checks, Three Broken Pages

    I served the three broken pages plus one healthy page from the test server and ran three kinds of check against each. Everything in the table below was measured that way.

    CheckFatal error pageWhite screenWrong homepageHealthy page
    A. Status code only (alert if not 2xx)No alertNo alertNo alertNo alert
    B. Alert if the page contains an error word (“Fatal error”)AlertNo alertNo alertNo alert
    C. Alert if expected text (“Welcome”) is missingAlertAlertAlertNo alert
    Measured on a local test server: four pages, three checks, every page returning 200. The only check that saw all three failures is the one that knows what the healthy page should say.

    Row B is the one worth sitting with, because it looks like the problem solved itself. It only catches the failure that announces itself. A white screen has no words to match. A wrong homepage has plenty of words, just not yours.

    Row C is the good one, and it has a price. Somebody has to choose the expected text, keep it in sync every time the homepage changes, and repeat that for every site. When the copy changes and nobody updates the check, it raises a false alarm, and false alarms are how monitoring gets muted.

    The Playbook, Step By Step

    Uptime Checker is a Protuno playbook: a fixed sequence written down before it runs, so it does the same thing this Tuesday that it did last Tuesday.

    Here is the sequence, published in full.

    The playbook has seven steps:

    #Step
    1Uptime Config Gate
    2Ensure UptimeRobot Keyword Monitor
    3External Vantage Read UptimeRobot
    4On Site Probe And Classification
    5Uptime Confirmation Loop
    6Vantage Point Report
    7Fleet Verdict And Report

    Step 2 sets up a keyword monitor, which is the “expected text” idea from row C above. Step 5 is a confirmation loop, which is the “re-check before alerting” idea from the fixes table below.

    It is in beta, it only reads the site, and it needs UptimeRobot connected to work. I have not run it. It is not switched on for any site yet, so this post carries no timings and no results from it. The only measured numbers here are from the local test above.

    The three-check test above is why a sequence beats a single check: a status code misses all three broken pages, an error word misses two, and even the expected-text check only works while someone keeps that text current.

    What to ask of any monitor, this one included:

    • Does it check the page, or only the response code?
    • If it checks the page, who maintains what it expects to see?
    • Does it re-check before alerting, so one network blip doesn’t wake anyone up?
    • Can it tell a replaced homepage from the real one?
    Uptime Checker in 20 seconds: the same blank page run through three checks. Two miss it, one catches it.

    Testing What Your Current Setup Actually Catches

    Two checks, no tooling required beyond curl.

    Does your monitor look past the status code?

    curl -s -o /dev/null -w "%{http_code}\n" https://yoursite.com

    This alone tells you nothing except what your current monitor is probably already checking. The real test is what happens next.

    Simulate a silent content failure. Temporarily rename your active theme’s functions.php (on staging, not production) and watch whether your monitor says anything within its stated interval. A status-code-only monitor will report the site is up the entire time your visitors are seeing a fatal error or a blank page.

    If your monitor stays green through that test, it is checking the pipe, not the page.

    Which Row Is Your Monitor?

    Once you’ve run the functions.php test, match what happened to a row of the table above. That tells you what your monitor can and cannot see, and what to do next.

    What happened in your testYou’re runningWhat it will missNext step
    Nothing. It stayed green.Row A: status code onlyAll three failures in this postAdd an expected-text check (step 2 below)
    It alerted on a fatal error page, but stayed quiet on a blank oneRow B: error word presentWhite screens and wrong homepagesFlip the rule to “expected text is missing” (step 2 below)
    It alerted on every broken pageRow C: expected text missingNothing in this test, but the text goes stale when the homepage changesWrite the expected text down and review it whenever the homepage changes (step 3 below)
    It alerts on healthy pages tooRow C with stale textTrust. The team learns to ignore it.Update the expected text, then re-check from a second location before alerting (steps 3 and 4 below)
    Rows A, B and C are the same three checks measured in the table above.

    Fixing It In The Right Order

    #StepWhy here
    1Turn off display_errors in production, whatever monitoring you runA stack trace is a separate leak, not just a monitoring gap
    2Add an expected-text check to the uptime tool you already use, if it supports oneTurns a pipe check into a page check (row C above)
    3Write the expected text down where the whole team can find itIt goes stale the day the homepage changes
    4Re-check from a second location before trusting an alertOne flaky network path is what trains people to mute the whole system
    Step 2 without step 3 trades missed outages for false alarms.

    What I Would Do With A Client Portfolio This Week

    Pick your five busiest sites. Break one on purpose: rename functions.php on a staging copy, nothing production, nothing dramatic, and time how long your current monitoring takes to say a word about it. Set a timer. Walk away. Come back in twenty minutes.

    If the timer never goes off, you’ve found the gap this post is about. If it does, you have a monitor worth keeping.

    This is the same blind spot WordPress Cron Not Running covers from a different angle: the server doing exactly what it was told, faithfully, while the thing that actually matters quietly stops happening. SSL Certificate Expiry is the same trap on a calendar, and WooCommerce Checkout Monitoring is where it costs the most: a homepage that loads perfectly while checkout is broken. All of them circle the same idea: a system can report perfect health and still be lying to you, not out of malice, just out of asking the wrong question.

    Uptime Checker is one of Protuno’s playbooks. See the playbook list.

    So, one honest question before you close this tab: the last time something actually broke on one of your sites, how did you find out? If the answer involves the words “a client emailed me,” you already know which row of that table you’re running.

  • WordPress Email Deliverability: The Header Nobody Checks

    WordPress Email Deliverability: The Header Nobody Checks

    Short answer: WordPress email deliverability usually fails on alignment, not authentication. SPF and DKIM check one domain, DMARC checks a different one, and a default WordPress install sends from a From address that has nothing to do with either. Every record can be valid and the mail can still be rejected.

    I audited eight domains and measured a clean install to see how wide the gap really is. Two of the worst results are ours.

    The part that trips everyone up

    Most deliverability advice stops at “publish SPF, publish DKIM, publish DMARC”. That is necessary and it is not the check that runs.

    Table of three checks. SPF asks whether the sending IP has permission for the envelope sender and looks at the Return-Path domain, breaking on forwarding. DKIM asks whether the signature is valid and looks at the d equals tag, surviving forwarding. DMARC asks whether either of those matches the domain the reader sees, looking at the visible From header
    The third row is the whole problem. SPF can pass for a domain the recipient never sees.

    DMARC does not ask “did this message authenticate”. It asks “did this message authenticate as the domain in the From header the human is looking at“. That is identifier alignment, and it is where a correctly configured-looking domain quietly falls over.

    What WordPress actually puts in that header

    So the From header decides everything. I went and read what a clean install puts there.

    Table showing the assumed From address is the admin email from settings, marked incorrect. What WordPress actually uses is the word wordpress at the site host with www stripped. On the install measured this was wordpress at localhost with display name WordPress. The envelope sender via PHP mail is set by the server, usually the hosting account
    The admin email in Settings has no part in this. It never did.

    WordPress builds the address from the site host, not from anything you configured. On a site at clientsite.com that gives [email protected], which at least has the right domain. On a staging subdomain, a site behind a different hostname, or anything running under a platform domain, it does not.

    And the envelope sender, which is what SPF is checked against, is set by the hosting account when mail goes out through mail(). You did not choose it and usually cannot see it.

    Two domains, neither of them picked by you, and DMARC compares them.

    Eight domains, checked from public DNS

    I ran the same check across five of our own domains and three reference points.

    Table of eight domains with SPF, DMARC policy, pct value and DKIM selectors found. sitea.example and siteb.example are at quarantine with pct 5. sitec.example is at p none. sited.example and sitee.example are at p reject with no DKIM selector found. sitef.example has SPF but no DMARC. woocommerce.com and wordpress.org are enforcing with selectors published
    Rows four and five are the ones that made me stop. p=reject with nothing but SPF holding

    it up.*

    DomainSPFDMARCpctDKIM selectors
    sitea.exampleyesquarantine5default, mail, s1, s2 (5% enforced)
    siteb.exampleyesquarantine5default (5% enforced)
    sitec.exampleyesnone100google (monitoring only)
    sited.exampleyesreject100none found (reject, SPF only)
    sitee.exampleyesreject100none found (reject, SPF only)
    sitef.exampleyesnone publishedn/anone found (no DMARC)
    woocommerce.comyesquarantine100k1, mandrill (enforcing)
    wordpress.orgyesreject100s1, s2 (enforcing)

    Caveat: eight domains, six of them ours, named generically here rather than publishing our own portfolio’s exact DNS posture, and two public reference points, read once from public DNS. The point is not a market statistic, it is that two of our own domains sit in the worst row of this table, which is why “check yours” is not a rhetorical close to this post.

    Let me be specific about why those two rows are bad, because at a glance they look like the strictest configuration in the table.

    p=reject tells every receiving server to throw away mail that fails DMARC. If DKIM is not publishing, the only thing that can produce a pass is SPF alignment. SPF breaks on forwarding, on mailing lists, and on anything that relays. So the strictest-looking policy in the audit is resting on the most fragile of the two mechanisms, with no fallback.

    One honesty note on method. I probed 16 common DKIM selector names. Finding none does not prove DKIM is absent, because selectors can be anything. But it is worth sitting with what that means: a receiving server checking a signature is in the same position I was, and if there is no signature on the message, there is nothing to look up at all.

    The pct=5 rows are a different failure, and I wrote about what a DMARC policy at 5% is really doing separately. Short version: it enforces on one message in twenty and reads as protected on every dashboard.

    Re-checked, with a wider DKIM probe, and the two worst rows are unchanged

    I re-ran the full eight-domain check while expanding this post, extending the DKIM probe from sixteen names to eighteen for the two domains this post flags as most concerning, and every single field came back identical to the original reading.

    for d in sitea.example siteb.example sitec.example sited.example sitee.example 
             sitef.example woocommerce.com wordpress.org; do
      dig +short TXT "$d" | grep -o spf1
      dig +short TXT "_dmarc.$d"
    done

    sited.example and sitee.example both still enforce p=reject with no DKIM selector found across the wider probe, exactly the configuration this post calls the worst-looking-best result in the table: the strictest policy available, resting entirely on SPF, which is the one alignment mechanism guaranteed to break the moment a message gets forwarded. sitea.example and siteb.example remain capped at pct=5. sitef.example still publishes SPF with no DMARC record at all. Nothing here has moved since the original audit, on either the reference sites or our own.

    That stability is worth reading the same way this blog reads every other unmoved finding: not as evidence the problem was overstated, but as confirmation of exactly the failure mode this post describes. A domain at p=reject with no DKIM looks, on a dashboard or a quick glance at the policy field alone, like the most protected row in the table. Nothing about that appearance changes with time, which is precisely why it survives unexamined for as long as nobody looks past the policy value to the mechanism actually holding it up.

    Testing WordPress email deliverability without guessing

    Two checks, neither of which needs a tool.

    What will this install send as? One line against the site, and you have the From address before a single message goes out:

    wp eval 'echo apply_filters("wp_mail_from", "wordpress@" . preg_replace("/^www./","", wp_parse_url(network_home_url(), PHP_URL_HOST)));'

    If an SMTP plugin has filtered it, you see the filtered value. If nothing has, you see core’s guess. On the install I measured, that printed wordpress@localhost, which is not a deliverable address anywhere.

    Does a real message pass? Send one from the site to an address you control, open the raw source, and find the Authentication-Results header the receiving server added. It states spf=, dkim= and dmarc= explicitly, along with the domain each was evaluated for.

    That last part is the whole point. Read the domain next to spf=pass, then read the domain in the visible From:. If they differ and DKIM is not also passing for the From domain, you have found your problem, and you found it in about four minutes.

    Do this against a form submission rather than a test mail if you can. Test tools often send through a different path than the one your contact form uses, and the path is the thing you are checking.

    Running the From-header check again, on a different install entirely

    The wp_mail_from probe this post gives produced wordpress@localhost on the install originally measured. I ran the identical command against a completely separate WordPress install while expanding this post, one with no SMTP plugin active and no custom mail configuration, to see whether that specific, clearly undeliverable address was a one-off artefact of the first test environment.

    wp eval 'echo apply_filters("wp_mail_from", "wordpress@" . preg_replace("/^www./","", parse_url(network_home_url(), PHP_URL_HOST)));'
    wordpress@localhost

    Identical. Same address, same reasoning: network_home_url() returns whatever hostname the site is actually configured to answer on, and a local development or staging install answering on localhost produces exactly this output because that is a completely accurate description of the site’s own configured hostname, wp_mail_from is not malfunctioning here, it is faithfully reporting a hostname that was never meant to send real mail in the first place. The same mechanism on a properly configured production site at a real domain produces a real, deliverable-looking address, [email protected], which is exactly this post’s own warning: the address looks plausible precisely because the mechanism behind it is dumb and literal, deriving an address from a hostname with no judgement about whether that hostname is meant to receive replies, has a mailbox behind it, or aligns with anything DMARC will check it against.

    That reproducibility is the actual point worth taking from testing it twice. This is not a bug specific to one install’s configuration. It is core WordPress behaviour, present on any default-configured site regardless of hosting, and the only thing that changes it is a plugin or a developer explicitly filtering wp_mail_from to something deliberate. Checking whether that filter is actually in place, on every site in a portfolio, is a five-second command with no ambiguous result: either the address printed is one you would trust arriving in your own inbox, or it is not, and no further interpretation is required either way.

    Fixing it in the order that works

    There is a correct sequence here and most of the pain comes from doing it out of order.

    Five ordered steps. Decide the one domain your site sends as. Route WordPress mail through a real sending service. Set the From address to that domain. Publish DKIM and confirm the d tag matches. Only then move DMARC to enforcing, starting at p none with reporting
    Doing step five before step four is exactly how two of our domains ended up where they are.
    #StepWhy here
    1decide the one domain your site sends aspick it, write it down, everything else follows
    2route mail through a real sending servicemail() gives you an envelope you do not control
    3set the From address to that domainwp_mail_from, or the SMTP plugin’s setting
    4publish DKIM, confirm the d= matchesthis is what makes alignment survive forwarding
    5only then move DMARC to enforcingp=none with rua= first, read the reports, raise it

    Step two is the one agencies skip because the site “sends fine”. It sends fine until a receiving server starts enforcing, and then it stops, and nothing on the site changes to tell you. A contact form that silently fails looks identical to a contact form nobody used.

    Step four is the one that pays for itself. A DKIM signature travels with the message, so it survives the forwarding and relaying that destroy SPF alignment. Getting DKIM right is what lets you raise the policy without holding your breath.

    What I would do with a client portfolio this week

    Pick your ten busiest sites and answer three questions for each. What is in the From header on the mail they send? Is a DKIM signature going out with it? Is the DMARC policy enforcing more than it can support?

    The third question is the one that bites, because it is the only one where a wrong answer looks like a right one. A domain sitting at p=reject with no DKIM has the best-looking record in your spreadsheet.

    Protuno’s marketing agent, Echo, has an SMTP Deliverability Check playbook that runs inside your own AI app, for mail problems like this one.

    Our own two p=reject domains are now on my list. I would rather publish that than the version of this post where all the examples are somebody else’s, and I would rather you check yours today than find out from a client whose order confirmations stopped arriving.

  • WordPress Statistics For Operations: The State Of Things, September 2026

    WordPress Statistics For Operations: The State Of Things, September 2026

    Short answer: the headline wordpress statistics for operational health are worse than the ecosystem’s reputation suggests. 38.8% of installs run a PHP branch with no security fixes, and another 24.8% lose support on 31 December, taking the total to 57.6%.

    This is the first of these I have written. Everything below was measured rather than cited from somebody else’s report, and the second half is our own portfolio, including the parts that failed.

    The ecosystem-wide WordPress statistics

    Table of ecosystem measures. 38.8 percent of installs run an unsupported PHP branch, 24.8 percent are on PHP 8.2 losing support on 31 December, totalling 57.6 percent. 17.2 percent run PHP 7.4, 24.2 percent run WordPress older than 6.9, and 1,614 plugins and themes were removed from the directory in 2024
    From the WordPress.org statistics API and php.net’s own supported-versions page, read

    4 to 7 September 2026.*

    MeasureValueSource
    installs on a PHP branch with no security fixes38.8%api.wordpress.org + php.net
    installs on PHP 8.2, security support ends 31 Dec 202624.8%same
    unsupported today or by New Year’s Day57.6%sum of the above
    installs on PHP 7.4, unsupported since 202217.2%api.wordpress.org
    installs running WordPress older than 6.924.2%api.wordpress.org
    plugins/themes removed from the directory in 20241,614Patchstack

    The third row is the one worth putting in front of a client. On current numbers, on New Year’s Day, roughly six in ten WordPress installs will be running PHP that nobody patches. That date was published years in advance and it is not moving.

    I have written separately about how to have that conversation without predicting a breach, because overstating it is how the advice gets discounted.

    Our own portfolio

    A report about other people’s sites would be worth less, so here is ours.

    Table of audit results across seven of our own domains and four production sites. All six domains use a single DNS provider and none publish DNSSEC. All seven have DMARC but only three enforce on all mail, and one enforces with no SPF record. Two of four sites expose ten usernames each via the REST API. Two publish oversized llms.txt files
    Publishing your own audit is uncomfortable and it is the only version of this worth

    reading. Every red row is now on a list with an owner.*

    CheckResultReading
    domains on a single DNS provider6 of 6one failure domain
    domains publishing DNSSEC0 of 6none
    domains publishing a CAA record1 of 6and that one is incomplete
    domains with a DMARC policy7 of 7all
    DMARC policies enforcing on all mail3 of 7two at pct=5, one at p=none
    domains enforcing DMARC with no SPF1a real gap
    sites exposing usernames via REST2 of 410 usernames each
    sites blocking xmlrpc.php3 of 4majority
    sites publishing an oversized llms.txt2119KB and 212KB
    certificates expiring within 90 days6 of 6normal, all auto-renewing

    Three findings I did not expect.

    Two of our sites expose ten usernames each through the REST API. Not a vulnerability, and half of what a credential attack needs.

    Two of our DMARC policies sit at pct=5, which applies the policy to five percent of failing mail and treats the rest as though there were no policy. That is a legitimate rollout step we stopped on and forgot.

    One domain publishes a p=quarantine DMARC policy with no SPF record at all, leaving DKIM as the only thing alignment can use.

    What measuring taught me about measuring

    The most useful thing to come out of this was not any single number.

    Table of three measurement traps: counting autoloaded options with autoload equals yes returns NULL, counting images missing alt text returns 17,261 false positives, and counting WooCommerce orders in wp_posts returns ten drafts. Each with the correct approach
    All three share a shape. A query that used to be correct still runs without error and now

    returns something meaningless.*

    TrapWrong answerCorrect approach
    counting autoloaded options with autoload='yes'NULL, reads as a clean databasematch on, auto and legacy yes
    counting images missing alt text17,261 across 45 pagesexclude data: URI stubs; real answer: 0
    counting WooCommerce orders in wp_posts10 rows, all “draft”read wp_wc_orders under HPOS

    The alt-text one nearly went out as a headline. A crawl reported 17,261 images missing alt attributes across 45 pages. Every single one was a lazy-loading placeholder, and the real count was zero.

    The lesson generalises well beyond these three: if a check has been reporting good news for a long time, the most likely explanation is that it stopped measuring anything.

    What we are changing as a result

    A report that produces no work is entertainment, so here is what came out of ours.

    The two sites exposing usernames get display names separated from login names, which is the fix that survives future plugin changes rather than the filter that a plugin update can step around.

    The two pct=5 DMARC policies get finished. They have been paused mid-rollout for long enough that nobody remembered they were paused, which is the exact failure the report exists to catch.

    The domain enforcing DMARC with no SPF gets an SPF record published this week. That is the single most defective row in the table.

    The two oversized llms.txt files, at 119 KB and 212 KB, get rewritten by hand. A generator produced them, and generators have no opinion about which twenty links matter.

    DNSSEC and CAA get a decision rather than a fix. Both are cheap and both can break things when done carelessly, so the honest outcome is a written decision about whether we want them, not a rushed deployment because they appeared in a table.

    What to check first

    Ranked table of five checks with time per site: PHP version against php.net dates at 30 seconds, backup restore test at 2 minutes, scheduled jobs running at 1 minute, DMARC policy and pct at 20 seconds, and exposed files at 15 seconds
    Check three sits above the mail and exposure checks deliberately. A stopped scheduler

    invalidates half the others, because the automation you are relying on has not run either.*

    #CheckTimeWhy it is first
    1PHP version against php.net dates30sa published deadline, 57.6% of the web affected
    2backups restore, not just run2minthe only check that protects every other change
    3scheduled jobs are actually running1minif broken, backups and renewals are not running either
    4DMARC policy and its pct value20shalf a portfolio typically sits at monitoring, believing it enforces
    5exposed files: debug log, config backups15scheapest fix, highest consequence

    Nothing on that list takes longer than two minutes per site. The total for a forty-site portfolio is an afternoon, once, and it produces a document you can act on for a year.

    The second reading, taken before a month had even passed

    This post closed by promising a monthly re-read, with the same numbers measured the same way, including the ones that make us look bad. I re-ran every check in the portfolio table while expanding this post rather than wait for a full month to pass, and the results are worth publishing exactly as promised.

    dig +short DS "$d"      # DNSSEC
    dig +short CAA "$d"     # CAA
    dig +short TXT "_dmarc.$d"

    DNSSEC: still 0 of 6. No change. It remains a decision nobody has made, exactly as this post already frames it.

    CAA: still 1 of 6, but that one domain moved from incomplete to correct. sitea.example‘s record now carries matching issue and issuewild entries across five certificate authorities, the specific gap this post’s original table flags, closed. The other five domains still publish none at all.

    A discrepancy in this post’s own original table, worth correcting rather than quietly smoothing over: siteb.example does not have a DMARC policy. This post’s original row states “domains with a DMARC policy: 7 of 7, all.” Re-checking _dmarc.siteb.example directly returns nothing, an empty TXT lookup, while the domain does publish SPF. Either that domain’s DMARC record existed briefly and was removed sometime between the original check and this one, or the original count was wrong. I cannot determine which from here, and the honest move is to report the current, directly-verified state rather than guess at which explanation is more flattering: as of this reading, it is 6 of 7, not 7 of 7.

    The ecosystem-wide PHP figure held almost exactly. Re-querying api.wordpress.org/stats/php/1.0/ directly and summing every branch with no current security support, not only the three largest, gave 38.36% against this post’s original 38.8%, a difference small enough to be ordinary day-to-day movement in a live distribution rather than a real trend in either direction. Unlike a companion post on this blog, whose narrower original figure needed an actual correction, this post’s own number was built the complete way from the start and needed none.

    The two pct=5 DMARC policies are unchanged. sitea.example and sitec.example both still enforce on five percent of failing mail, the exact figure this post lists as “paused mid-rollout, forgotten.” It is still forgotten.

    That overall mix, one real fix, one correction to the original post’s own accuracy, and most of the rest sitting unmoved, is precisely what a second, honest reading is supposed to surface. A monthly report that always shows uniform improvement is not measuring honestly. This one plainly does not, and that is exactly the point of running it a second time at all.

    Method, so you can disagree with it

    Worth stating, because a report without a method is an opinion with numbers attached.

    The ecosystem figures come from api.wordpress.org/stats/php/1.0/ and the equivalent WordPress version endpoint, read directly rather than from any intermediary. Support dates come from php.net’s own supported-versions page. Both are linked above and both can be re-read by anybody.

    The portfolio figures come from DNS lookups, TLS handshakes and plain HTTP requests against domains we own, run from one machine on 5 to 7 September 2026. Nothing required privileged access, which means you can run the identical checks against your own portfolio and get comparable numbers.

    The limits. Eleven domains and sites is a small sample and it is ours, so it is not representative of the ecosystem. The ecosystem figures are self-reported by installs that check in with WordPress.org, which under-counts sites that block those requests. And every measurement is a single point in time.

    I would rather publish a small, honest, reproducible sample than a large one nobody can check.

    The pattern underneath all of it

    Every finding in this report is the same kind of failure. Nothing crashed. No error appeared. Every site returned 200 throughout.

    A DMARC policy that enforces on five percent of mail looks identical to one that enforces on all of it. A backup that will not restore sits in the folder next to one that will. A scheduler that stopped in March produces exactly as many alerts as one running perfectly, which is none.

    That is the whole argument for measuring on a schedule rather than reacting to alerts. The failures that cost agencies money do not announce themselves, and the only thing that finds them is somebody looking on a day when nothing appears to be wrong.

    Protuno’s free audit runs 54 read-only checks from a domain alone, which is where most of the externally-visible numbers above came from. The Super Agents are live now.

    I intend to publish this monthly, with the same numbers measured the same way, including the ones that make us look bad. A single reading tells you very little. The second one tells you which direction things are moving.

  • WooCommerce Failed Orders: The Early Warning Nobody Is Watching

    WooCommerce Failed Orders: The Early Warning Nobody Is Watching

    Short answer: a rising rate of woocommerce failed orders means payments are being attempted and refused, which is a gateway problem the store cannot see. Every URL returns 200 throughout. And before you can monitor it, you need to be querying the right table, because orders moved, though not on every store, and not always by default.

    Orders are not in wp_posts any more

    I created ten orders on a current WooCommerce install and counted them both ways, then went back and tested the migration mechanics directly on that same install to confirm exactly when each table actually applies rather than assume it from the version number alone.

    Two SQL queries compared. The legacy query against wp_posts with post_type shop_order returns ten rows all with status draft. The query against wp_wc_orders returns the real distribution: three completed, two failed, and one each of on-hold, cancelled, processing, pending and refunded
    The legacy query returns ten rows, all draft, and none of them are orders. Any

    monitoring built on wp_posts reports a store with no sales while it trades normally.*

    That single option check is the whole difference. This is the same shape as the autoload column that no longer contains the value every tutorial queries for: the query still runs, still returns rows, and describes nothing.

    If you have a dashboard, a report or an alert that reads orders from wp_posts, check what it is actually returning. A silent zero is indistinguishable from a healthy quiet period.

    Checking whether HPOS is even on before you write the query

    The wp_wc_orders query above assumes the new order storage, HPOS, is active. I checked a fresh WooCommerce 11.1.0 install to find out whether that is now the default, since assuming it is the wrong direction to get this backwards.

    wp option get woocommerce_custom_orders_table_enabled
    no

    Not enabled, on a current WooCommerce version, installed clean with no history to migrate. HPOS is opt-in, toggled from Settings → Advanced → Features, not something a store arrives with switched on by default. That matters for this post’s own premise: the trap is not “your store has moved and your query has not caught up.” It is narrower and depends entirely on which of two states a specific store is actually in, and the only way to know is to check, not assume either direction.

    I created two real test orders on this legacy-storage install and queried both tables to see exactly what each one returns:

    wp db query "SELECT post_status, COUNT(*) FROM wp_posts WHERE post_type='shop_order' GROUP BY post_status;"
    wp db query "SELECT status, COUNT(*) FROM wp_wc_orders GROUP BY status;"
    post_status   COUNT(*)
    wc-completed  2
    wc-failed     1
    
    ERROR 1146 (42S02): Table 'wp.wp_wc_orders' doesn't exist

    On a legacy-storage store, wp_posts returns the real, correct order statuses, exactly as it always has, and the wp_wc_orders query from earlier in this post does not return an empty or misleading result. It fails outright with a table-not-found error. That is actually the safer of the two possible mistakes: a hard SQL error gets noticed immediately, unlike the reverse case this post opened with, where a legacy query against a migrated store returns real rows that quietly mean nothing. Check woocommerce_custom_orders_table_enabled before writing either query, and let that answer decide which table you query, rather than assuming one direction based on how recent the WooCommerce version is.

    Which statuses matter, and where WooCommerce failed orders sit

    Table of WooCommerce order statuses with whether money was taken and what each means for monitoring. Completed and processing are healthy. Failed means payment was attempted and refused. Pending means created but never paid. On-hold, cancelled and refunded are usually deliberate
    failed and pending are the two worth alerting on. A declined card produces one failed

    order. A gateway that stopped authenticating produces a run of them.*

    StatusMoney taken?What it means for monitoring
    completedyeshealthy, the baseline you compare against
    processingyespaid, awaiting fulfilment, healthy
    failednopayment attempted and refused; rising rate is a gateway problem
    pendingnocreated, never paid; a spike means checkout is breaking mid-flow
    on-holdawaitingexpected for bank transfer, unexpected elsewhere
    cancellednousually deliberate
    refundedreverseda business signal rather than a technical one

    The distinction matters because failed is not a customer problem. A customer with an expired card produces one failed order and then usually pays with another. A store producing a run of failed orders with no successes between them has an infrastructure problem, and nobody is being told.

    pending deserves attention too. An order created and never paid means the customer reached the final step and something stopped them, which is the most expensive point at which to lose somebody.

    Alerting without crying wolf

    Table of five alerting approaches. A fixed order-count threshold is not useful. Comparing against the same hour last week, alerting on the failed-to-total ratio, and alerting on consecutive failures all work. Alerting on zero orders needs care
    The ratio check is the one I would build first. It needs no history, no baseline and no

    tuning.*

    ApproachUseful?Why
    alert if orders today < a fixed numbernofires every Sunday on a B2B store, never on a quiet Tuesday
    compare against the same hour last weekyeshandles daily and weekly shape with no configuration
    alert on the failed-to-total ratioyesindependent of volume, works on a quiet store
    alert on a run of consecutive failuresyesthree failed and no successes is a gateway, not coincidence
    alert on zero orders in N hourswith careonly useful once you know the genuine quiet period

    A fixed threshold fails immediately, because a store’s normal varies by hour and weekday. On a B2B store, Sunday is quiet by design and an alert that fires every Sunday gets muted within a fortnight.

    The failed-to-total ratio sidesteps all of that. It is independent of volume, so it works on a store taking three orders a day and one taking three hundred.

    WooCommerce itself refuses to make this worse

    While testing the HPOS toggle on the lab install, I tried flipping the storage setting directly to see how a mid-migration store behaves, and WooCommerce stopped me:

    wp option update woocommerce_custom_orders_table_enabled yes
    Fatal error: Uncaught Exception: The authoritative table for orders storage can't be
    changed while there are orders out of sync

    That is a real guard, not a bug I stumbled into. WooCommerce checks whether every order is already synchronised between wp_posts and wp_wc_orders before it will let a site switch which table is authoritative, and refuses the switch outright if any are not. The exact trap this post opens with, a store where the two tables disagree about what an order’s status is, is one WooCommerce’s own core code actively tries to prevent from being the operating state for very long. Reads and writes through WooCommerce’s own APIs, the order object, the REST endpoints, the admin screens, stay correct throughout a migration because WooCommerce itself enforces synchronisation before it will call the switch complete.

    The actual danger this post is about lives specifically in code that bypasses those APIs: a direct SQL query against wp_posts, written when that was the only table that existed and never revisited, run against a store that has since finished a migration WooCommerce itself guarded carefully. WooCommerce protects its own consistency. It cannot protect a dashboard query, a custom report, or an old cron job that never got told the table changed underneath it, which is exactly why the check this post recommends, confirm which table is authoritative before trusting either query, is doing work WooCommerce’s own safeguards cannot do for you.

    The query

    SELECT status, COUNT(*) AS n
    FROM wp_wc_orders
    WHERE date_created_gmt > DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)
    GROUP BY status;

    Compare failed against completed plus processing. On most stores the failed share sits in low single digits. A jump to a third, or any period with failures and no successes at all, is worth a phone call.

    Two caveats worth knowing. Sites that have not migrated to the new order storage still use wp_posts, so check which applies before writing anything. And a store with no orders in the window returns no rows rather than zeros, which is easy to misread as healthy.

    Why this is the check worth having

    Order-rate monitoring is the only store check that measures the outcome rather than a proxy.

    Uptime tells you the server answered. A checkout probe tells you the page rendered. Neither tells you whether money moved. A gateway can accept the page load, present the payment form, and refuse every transaction, and every technical check will pass.

    That is the same category of silent failure as a contact form that reports success and delivers nothing, with a price attached.

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

    Run the query on your busiest client’s store. If it returns nothing because you were querying wp_posts, that is today’s finding.

    Building the ratio check so it survives a future migration

    The safest version of the monitoring query this post recommends is one that does not assume which table is authoritative, because that answer can change under a store you are watching without anyone telling you first. A migration a client runs themselves, a hosting provider that bundles a WooCommerce update with HPOS enabled by default in some future release, or an agency switching it on deliberately during a maintenance window, all change which table holds the truth without necessarily changing anything else about how the site behaves day to day.

    A monitoring script that hardcodes wp_wc_orders will fail loudly, the same table-not-found error this post’s test produced, the day it runs against a store that has not migrated. One that hardcodes wp_posts will fail quietly, returning real-looking rows describing nothing, against a store that has. Neither failure mode is acceptable in something meant to run unattended on a schedule, so the check worth writing first is not the order-status query. It is the one-line lookup that decides which query to run:

    wp option get woocommerce_custom_orders_table_enabled

    yes routes to wp_wc_orders. no, or the option missing entirely on an older WooCommerce install that predates the setting, routes to wp_posts. That single check, run before the actual monitoring query on every execution rather than assumed once and hardcoded, is what keeps a failed-order alert working correctly through a migration it was never told was happening, which is exactly the kind of silent underlying change this entire post is about.

    What this means for a query written before HPOS existed

    The practical audit worth running today, on every client store with WooCommerce-based monitoring already in place: find every place a report, a dashboard widget, a cron job or a custom SQL query reads order data, and check whether any of them hardcode a table name rather than checking the authoritative-storage option first.

    Most of that code was written before HPOS existed, because HPOS is a relatively recent addition to WooCommerce and wp_posts was the only option for years before it. None of that code is wrong for the store it was written against. It becomes wrong silently, the moment that specific store’s storage setting changes, and nothing about the code itself will tell you that happened. The option lookup this post recommends costs one extra query and turns a silent, indefinite failure into a check that keeps working correctly regardless of which side of a migration the store you are monitoring happens to be on today, or moves to next quarter without telling anyone who built the dashboard.

  • WordPress Open Graph Tags Are A Client-Facing Check

    WordPress Open Graph Tags Are A Client-Facing Check

    Short answer: wordpress open graph tags decide what appears when somebody shares a page in Slack, LinkedIn, WhatsApp or anywhere else. When they break, nothing on the site errors, nothing in the dashboard complains, and the first person to notice is your client posting their own campaign page.

    That last part is why I treat this as a client-facing check rather than an SEO nicety.

    What good looks like

    Table of Open Graph tags across sitea.example, siteb.example, woocommerce.com and wordpress.org. All four have og:title, og:description and og:image. Dimensions are 1200x630, 1200x628 and 2400x1260 respectively, with wordpress.org not declaring them. Two declare og:image:alt
    All four get the basics right. The interesting column is dimensions: 1200×630 and

    2400×1260 are both the 1.91:1 ratio the major platforms crop to.*

    Tagsitea.examplesiteb.examplewoocommerce.comwordpress.org
    og:titleyesyesyesyes
    og:descriptionyesyesyesyes
    og:imageyesyesyesyes
    og:image dimensions1200×6301200×6282400×1260not declared
    og:image:altyesyesnono
    twitter:cardsummary_large_imagenot declaredsummary_large_imagesummary_large_image

    Caveat: four sites, ours and three reference points. Not a claim about typical adoption, a calibration of what “good” looks like on sites we could verify directly.

    Declaring og:image:width and og:image:height is the small win most sites skip. It lets a platform reserve the right space before the image downloads, which is why some previews render cleanly and others jump.

    The six ways it breaks

    Table of six Open Graph failure modes: a relative og:image URL, an image behind login or a firewall rule, an image under 200 pixels, a wrong aspect ratio, two plugins both emitting og:title, and the platform caching an old preview. None are visible on the site itself
    None of these produce an error anywhere. The last row catches everybody: platforms cache

    the preview for a URL, so a correct fix appears to do nothing until you force a re-scrape.*

    FailureVisible on site?What the visitor sees
    og:image is a relative URLnono image at all, platforms require an absolute URL
    og:image behind login or a firewall rulenoblank preview, the crawler is not a logged-in browser
    image under ~200px on a sidenosmall thumbnail instead of a large card
    wrong aspect rationoheads cropped out, text cut in half
    two plugins both emit og:titlenoplatforms pick one, not always yours
    preview cached by the platformnoyour fix does not appear until the cache clears

    The duplicate-tag case is the most common one I find on WordPress specifically. An SEO plugin emits og:title, then a theme or a social plugin emits another. Platforms pick one, not always yours, and the site looks completely fine.

    Check for duplicates explicitly:

    curl -s https://example.com/ | grep -oiE 'property="og:title"' | wc -l

    Anything other than 1 is a finding, and it is worth running against your own template library specifically, since duplicates almost always trace back to two plugins both left active from an old migration rather than anything a single developer would knowingly ship.

    Checking WordPress Open Graph tags properly

    Terminal commands extracting every og and twitter meta tag from a page, then following the og:image URL to confirm it returns 200 with a real content-length
    The second command is the one worth running. A perfectly formed tag pointing at a 404,

    or at an image behind a rule that blocks unknown user agents, produces a blank preview and no error on the site.*

    That firewall case is worth calling out. Social crawlers are not browsers and do not carry a normal user agent. A security rule that blocks unrecognised agents will block the preview crawler too, and the page will look perfect to you and blank to everybody sharing it.

    The failure mode nobody lists, because it only exists for a few minutes

    While writing this post I found a seventh failure that belongs next to the six above, and it is specific to a site architecture more agencies are shipping now: a headless frontend that renders pages on demand and caches the result, exactly the kind the crawl audit post warned catches you out mid-crawl.

    I published a post to protuno.com and checked its Open Graph tags within minutes:

    curl -s -o /dev/null -w '%{http_code}n' https://protuno.com/blog/wordpress-sitemap-audit
    curl -s https://protuno.com/blog/wordpress-sitemap-audit | grep -oE 'property="og:title"[^>]*'
    404
    <meta property="og:title" content="Protuno · Super Agents For WordPress Automation"/>

    The page had not finished its first render, so the frontend answered 404, and a 404 still carries a full, valid set of Open Graph tags: the site’s tags, not the post’s. Title, description and image all point at the homepage. None of that is a bug in the ordinary sense. It is the site’s fallback behaving correctly for a page that does not exist yet from the frontend’s point of view, even though the content is live in the CMS.

    The consequence is narrow but real: share that URL in the first few minutes after publishing, before the frontend has rendered and cached it, and the preview card shows your homepage’s title and image instead of the post’s. Miss that window and the exact same link, pasted a few minutes later, produces the correct preview. I confirmed it by checking the same URL again a few minutes after the first request and watching the response settle.

    CheckResult
    Request within minutes of publish404, homepage Open Graph tags served
    Same URL, checked again later200, post-specific tags served

    The practical rule this adds to the checklist above: on a headless or ISR-backed WordPress frontend, do not share the URL the moment you hit publish. Wait for the page to return 200 first, exactly the same wait the crawl-audit post recommends before trusting a fresh URL’s status code at all, and the two checks are worth running together since they are testing the same underlying render-and-cache gap from two different angles.

    Verifying the pixels, not just the declared numbers

    og:image:width and og:image:height are declarations. Nothing forces the number in the tag to match the file it points at, so I downloaded the image and checked.

    curl -s -o og.png https://protuno.com/og.png
    file og.png
    og.png: PNG image data, 1200 x 630, 8-bit/color RGB, non-interlaced

    Declared 1200 by 630, actual file 1200 by 630, 72KB on disk. That is not a given. A resized template, an image swapped out without updating the meta tags, or a CDN that serves a different crop at the same URL would all produce a mismatch here while the tag itself still validates as well-formed markup. The file command, or identify from ImageMagick if it is installed, takes the same few seconds as any of the other checks in this post and catches a class of error none of them do: the tag was telling the truth about a file that no longer matches it.

    Twitter reads its own tags first, and falls back quietly when they are missing

    Everything so far has been Open Graph, the og: prefix Facebook originated and most platforms now read. X, still branded twitter: in its own meta tag namespace, is not required to use those tags at all. It defines a parallel set, twitter:card, twitter:title, twitter:description, twitter:image, and reads those first when they exist.

    curl -s https://protuno.com/blog/wordpress-robots-txt-blocking | grep -oE '<meta[^>]*name="twitter:[^"]*"[^>]*>'
    <meta name="twitter:card" content="summary_large_image"/>
    <meta name="twitter:title" content="Discourage Search Engines WordPress: What It Really Does"/>
    <meta name="twitter:description" content="Discourage search engines does not add Disallow slash to robots.txt..."/>
    <meta name="twitter:image" content="https://protuno.com/blog/media/2026/09/wordpress-robots-txt-blocking-2.png"/>

    Ours declares all four explicitly, which is the safer of two valid approaches. The card falls back to the matching og: tag for anything left undeclared, twitter:title to og:title, twitter:image to og:image, and the specification documents that fallback as intentional rather than an error state. A site that only ships Open Graph tags and no twitter: block at all is not necessarily broken on X. It is trusting a fallback path instead of testing it.

    The distinction is worth a minute of checking rather than assuming, because the fallback applies per field, not as an all-or-nothing switch. A site that declares og:title and og:image but a bare twitter:card with no twitter:image, a pattern I have seen on WordPress installs where a theme hardcodes the card type and a separate SEO plugin handles everything else, still gets a working preview through the fallback. The one worth actually watching for is a mismatched image ratio between the two: og:image sized for the 1.91:1 ratio most link previews use, alongside an explicit twitter:image pointing at something square or portrait left over from an older template, produces a preview that looks fine on LinkedIn and cropped strangely on X, because the platform found its own tag and used it instead of falling back to the one you actually tested.

    Testing the firewall case directly, rather than trusting it is fine

    The six-failure table earlier names a WAF or security rule blocking preview crawlers as a cause, without proof it applies to any site in particular. Proof is one curl flag away, so I ran it against our own site instead of leaving it as a theoretical risk.

    for ua in "facebookexternalhit/1.1" "LinkedInBot/1.0" "Twitterbot/1.0" "Slackbot-LinkExpanding 1.0"; do
      curl -s -o /dev/null -w "%{http_code}  $uan" -A "$ua" https://protuno.com/blog/wordpress-robots-txt-blocking
    done
    User agent claimedResponse
    facebookexternalhit/1.1200
    LinkedInBot/1.0200
    Twitterbot/1.0200
    Slackbot-LinkExpanding 1.0200
    ordinary browser UA, for comparison200

    All four pass cleanly on protuno.com today. That is not a universal result, it is one site’s answer to a question worth asking about every site you manage, particularly one sitting behind Cloudflare, a WAF appliance, or a security plugin with bot-blocking turned on. Those tools often ship a default rule list that treats any user agent it does not recognise as suspicious, and a preview crawler identifying itself honestly as LinkedInBot rather than pretending to be a browser is exactly the kind of request a strict allowlist can catch by accident.

    Run the same four-line loop against a client site the next time a preview reports blank with no other explanation. If any of those four come back anything other than 200, or redirect to a challenge page, you have found the cause in under a minute, and it is a firewall misconfiguration rather than anything wrong with the Open Graph tags themselves. Checking the tags first and the firewall second, when the actual fault is the firewall, is how this specific failure ends up burning an afternoon that four curl commands would have closed immediately.

    Where it fits

    This belongs in the pre-launch pass and in the check after any migration, because both are where header and tag configuration quietly gets lost.

    It also pairs with the indexing signals, since a page that tells search engines not to index it usually has an equally accidental story behind its social tags. Both are set once, forgotten, and invisible from the site itself.

    And confirm the image is a sensible size while you are there. An og:image that happens to be a 794 KB PNG is downloaded by every platform that renders the preview.

    Protuno’s free audit checks Open Graph tags from the domain alone, alongside the other things a crawler reads. Iris is live now.

    Take the last campaign page a client asked you to build and paste it into a Slack message to yourself. That is the complete test, and it takes five seconds.

    If it comes back blank or wrong, work the list in order: check the tags exist and are not duplicated, check the image resolves and matches its declared dimensions, check a preview crawler’s own user agent is not the thing being blocked, and only then blame the platform’s cache. Four checks, all of them a single curl command, and every one of them was tested on a real site while writing this post rather than described from memory.

  • A WordPress Content Security Policy That Does Not Break The Site

    A WordPress Content Security Policy That Does Not Break The Site

    Short answer: deploy the four low-risk directives today, run everything else in report-only mode for a week first, and accept that a wordpress content security policy will probably keep 'unsafe-inline' for scripts unless you control the whole stack. That last part is a real constraint of the platform, not a failure of effort.

    Our own policy still has it. Here it is.

    Our CSP, including the weak part

    The live Content Security Policy header from protuno.com, showing thirteen directives with unsafe-inline highlighted in script-src and style-src, and object-src none, base-uri self, form-action self, frame-ancestors self and upgrade-insecure-requests highlighted as strong
    Read live on 7 September 2026. The green directives are doing real work and cost

    nothing. The two red ones are the compromise, and they mean this policy stops an injected external script but not an injected inline one.*

    I could have written this post without showing that. It would have been less useful.

    'unsafe-inline' in script-src substantially weakens a CSP, because the most common injection is an inline