Signed releases and public checksums for Ninja Tables Pro

Ninja Tables Pro 5.2.15 shipped on 21 August 2026. It is the first Ninja Tables release your site can refuse to install if it did not come from us — and the first whose every file you can audit, months later, without asking us anything.

Two things changed, and they answer two different questions.

At install time: every release is now signed with an Ed25519 key that is not on our licensing server, not on our build machine, and not on our CDN. The plugin carries the matching public key in its own source and checks the signature itself, before WordPress unzips anything.

Afterwards: every file of every release is published with an MD5 and a SHA-256 at checksums.wpmanageninja.com, in the same format WordPress.org publishes for directory plugins. No account, no license key, no request that identifies the site being checked.

This post is the long version: how commercial plugin updates actually work, what ours did before, what they do now, and where the new system deliberately stops short.

How a commercial plugin update actually works

Nearly every paid WordPress plugin — one sold outside the WordPress.org directory – updates through the same five steps. This was ours too, until this release.

The standard model. Three of these steps involve a machine you do not control, and none of them check who built the package.
  1. Twice a day, WordPress cron asks the vendor’s API what the latest version is, sending the license key and site URL.
  2. The API answers with a small JSON object: a version number and a URL to download it from.
  3. WordPress writes that answer straight into its update_plugins transient. The dashboard shows a badge.
  4. Someone clicks Update, or auto-update fires overnight. Whatever that URL serves is what arrives.
  5. It is unzipped over the existing directory and loaded on the next request as PHP, with the same privileges as the rest of your site.

Read that list looking for the step where anything proves the vendor built the zip. There isn’t one.

The only guarantee in the chain is TLS, and TLS answers a narrower question than people assume: these bytes came from whoever answers for that hostname right now. Not who made them. Not what is in them.

WordPress does ship signature-checking code — verify_file_signature(), added in 5.2 — but the plugin update path never turns it on. In WordPress 7.1 — the current release as this is written — WP_Upgrader::run() calls download_package( $package, false ), and that false is the signature check. It is on line 849 of wp-admin/includes/class-wp-upgrader.php, and it has been false for every version we checked back to 6.7. Even if it were true, the wp_signature_hosts filter (wp-admin/includes/file.php, line 1287) defaults to wordpress.orgdownloads.wordpress.org and s.w.org, so a package from a vendor’s own domain would be skipped anyway. The one integrity check that does run is a Content-MD5 header comparison, if the server happens to send one — and a server that can change the bytes can change that header too.

There is a second gap, quieter than the first. Directory plugins have published checksums at downloads.wordpress.org/plugin-checksums/, which is where hosts, scanners and wp plugin verify-checksums look. Commercial plugins have no entry there. So after an update finishes, nobody — not you, not your host, not a researcher — can answer “are these files the ones the vendor shipped?” There is nothing to compare against.

Three places to poison an update

A supply chain attack on this model does not need your password or a vulnerability in the plugin. It needs one machine between the vendor’s build and your plugins directory.

All three attacks need the same thing to succeed, and it is the one thing no server in the chain holds.

The licensing API. It decides what the package URL is. Change the answer, and every site that checks for updates fetches from wherever you say.

The download host. Same URL, same version number, different zip. Nothing on the receiving site can tell the difference.

The network. DNS, a mis-issued certificate, a hostile resolver or proxy. Same outcome, and it needs no access to the vendor at all.

In all three, the site owner did everything right — kept auto-updates on, ran a current version — and gets owned anyway. That is what makes update channels attractive: they are trusted, automated, and they run as PHP.

What our previous releases did

Ninja Tables Pro up to and including 5.2.14 used an updater derived from the Easy Digital Downloads Software Licensing sample client — the same code, with local edits, that a large part of the commercial plugin ecosystem still ships. It talked to api3.wpmanageninja.com/plugin with ?edd_action=get_version, and it had none of the protections above.

What it received back from the store was this, and nothing else:

{"new_version":"5.2.14","package":"https://…"}

A version number and a URL. No field in that response could be checked against anything; there was no record of what the release was supposed to contain, and so no way for the plugin to form an opinion about the zip it was about to unzip. That is not a bug in the old updater — it is the whole design, and it is what the ecosystem has been shipping for a decade.

That whole directory is gone as of 5.2.15, replaced by the licensing and update layer that FluentCommunity Pro has been running on.

Line by line, what a Ninja Tables Pro update looked like up to 5.2.14 and what it looks like from 5.2.15.

Migrating it had a hard constraint: nobody re-enters a license key. A site licensed before the update stays licensed after it, with no customer action. The new layer reads the option the old one wrote, seeds itself once, mirrors status back for older free versions that read it directly, and keeps answering the legacy AJAX endpoints. If a single customer had to paste their key again, the migration would have been wrong.

Four checks, three of them before anything downloads

The new verifier hooks upgrader_pre_download, which runs for the Update button, for auto-updates, for cron and for WP-CLI alike — an unguarded path is the one that gets used.

A forged manifest costs a signature check, not a multi-megabyte transfer.

1. Signature. The update response carries a small manifest and a detached Ed25519 signature over it. The signature is verified against a public key compiled into the plugin, over the manifest bytes exactly as they arrived — never decoded and re-encoded first, because any change in key order or whitespace produces different bytes and a signature that will not verify.

2. Product. The signed slug must be this plugin. This closes a subtle one: a genuine, correctly signed package for a different product of ours, replayed as a Ninja Tables update.

3. Version. The signed release must not be older than the version being advertised, and not older than the version installed. That closes the downgrade replay — a compromised API offering “9.9.0 available” so everyone clicks, then serving a genuinely signed 2.5.0 with a known hole.

4. File hash. Only now does the download happen, and we do it ourselves rather than letting WordPress do it. The file on disk is hashed and compared against the SHA-256 inside the signed manifest. One byte out and the file is deleted, not installed.

If any check fails, a WP_Error is returned, nothing is unzipped, and the version you are running keeps running. The failure is also broadcast on an action hook, so support tooling can see why an update was refused instead of guessing.

The signed bytes are deliberately small and boring:

{"slug":"ninja-tables-pro","version":"5.2.15","sha256":"0b1e4f45…","key_id":"wpmn-pub-key"}

Four fields, in that order, with no timestamp. That omission is load-bearing: it makes the output reproducible from the zip alone, forever. Signing the same zip again reprints the same manifest and signature byte for byte, which is what lets the value stored in the licensing system be diffed against a fresh run.

One piece of real-world messiness is handled explicitly. A site working from a cached update check will sometimes fetch bytes its cached manifest predates — routine every time a release lands, not evidence of tampering. So on a hash mismatch the plugin asks the API once, uncached, and re-checks the file already in hand. No second download, and the bytes still have to be signed for this plugin, at a version no older than the installed one.

The key is not on any server

Four machines an attacker would go after. None of them can produce a valid update.

The secret half of the release key lives in a password manager. It is typed in by hand when a release is cut, prompted for with the terminal echo off, held in memory for as long as signing takes, and never written to disk, printed, logged, or passed on a command line. Routine publishing never asks for it — only a release run needs it in memory.

Before anything is signed, the entered secret is checked against the pinned public half, so a wrong key fails as an error rather than as a signature that every customer’s update rejects.

The public half is hardcoded in the plugin:

const TRUSTED_KEYS = [
    'wpmn-pub-key' => '0b0c3b880d064696f2636d8c72d8e4094303fc6b7907b24ea25a1745f23463c0',
];

Hardcoded on purpose. A key fetched over the network is a key that whoever controls the network can replace, which would defeat the entire scheme. It is never read from an option, an API response, or a filter. Rotation works by shipping a new key alongside the old one, waiting for adoption, then signing with the new one and dropping the old entry a release later.

So: our licensing API can hand out downloads but cannot vouch for them. Our build machine can produce a zip but cannot sign it. Our CDN can move bytes but cannot change what those bytes must hash to. Compromising any of them — or all of them — does not produce an update your site will accept.

Where it deliberately fails open

This is the part we would rather explain than have discovered.

Verification is skipped entirely, and updates behave exactly as they did before signing existed, in three cases:

  • No usable key is compiled in. A placeholder or malformed key leaves the gate dormant rather than blocking every update on the site.
  • The server cannot check an Ed25519 signature at all. Normally it can — WordPress bundles sodium_compat — but core only loads that polyfill when sodium_crypto_box() is missing. A host with the sodium extension and sodium_crypto_sign_verify_detached in disable_functions ends up with neither the extension function nor the polyfill.
  • The package is a local path rather than an http(s) URL — an admin uploading a zip by hand. Note that plain http URLs are not waved through: anything remote is verified.

The reasoning is the same in each case: refusing would leave the site unable to install anything ever again, security fixes included. That is strictly worse than the unsigned updates this replaces. None of these are reachable from the network — the second depends on PHP configuration, and anyone who can change that already has server access and has no need of the update channel.

There is also a site-owner escape hatch, NINJA_TABLES_PRO_SKIP_UPDATE_VERIFY, for the rare case where something on a host makes verification impossible and support needs a way through.

How a release is actually cut

Six stages. Exactly one of them needs the secret, and it is the only one performed by a person.

The zip is built. Every file inside it is fingerprinted with MD5 and SHA-256, and the zip gets a SHA-256 of its own. The release is signed by hand. The manifest and signature go onto the release row in the store next to that exact zip. The file manifest is committed to a repository and deployed as an immutable static asset.

Publishing a manifest being a commit is a deliberate choice over an instant API write. Every checksum arrives through a reviewable diff, and git log answers “when did this appear, and who added it”. For a file-integrity service, that audit trail is worth more than saving thirty seconds.

Two rules keep the record honest:

  • A published version is never rewritten. Re-dropping a rebuilt zip under the same version does not silently replace the manifest that existing installs verify against.
  • A rebuilt zip for a published version is refused, loudly. If the zip no longer hashes to what was published under that number, the tool exits without signing. Signing the new bytes would break every install that already has the old ones; signing the old ones would attest to a zip nobody has. Neither is recoverable from the client side. The answer is a new version number.

The second half: public checksums

A signature protects one moment — the install. It says nothing about what happened to the files afterwards, and that is where most real incidents live: a webshell dropped into a plugin directory weeks later, through an unrelated hole somewhere else on the server.

Every file, hashed twice, compared by name. Added, missing and modified files all come back individually.

So every release also gets a published manifest of every file it contains, hashed twice.

The register, filtered to one product. Fourteen products, twenty-one versions and 3,638 files at the time of writing.

The format is WordPress.org’s own, deliberately. Anything that already parses downloads.wordpress.org/plugin-checksums/ can consume ours by changing a base URL rather than writing a new integration. Manifests are served as static assets with a one-year immutable cache — they never change, so they never need revalidating.

Three properties matter more than the format:

  • It is anonymous. No account, no license key, no telemetry, no request that identifies the site being checked. Even the product logos on the register are self-hosted rather than hotlinked, because a remote image would leak a visitor’s IP to another host. Verifying a site should never require identifying it.
  • It is immutable. A published version manifest is a permanent public statement about what was shipped under that number. Replacing one is a deliberate, recorded act.
  • It is signed. Each new manifest has a detached signature beside it — 5.2.15.json.sig — from the same offline key, over the manifest exactly as served. A verifier can check the response body it just downloaded against the pinned public key, which means it does not have to trust TLS, DNS, our Cloudflare account, or the repository the file lives in.

A note on what the manifest tolerates. Compiled translation catalogues (.mo and .po files under language/ or languages/) are excluded, because translation plugins write them into the plugin directory after installation and every translated site would otherwise report phantom files. readme.txt is treated as a soft change, skipped unless you pass --strict. That list is kept as short as honestly possible, because every entry is a hole something could hide in — which is exactly why *.l10n.php, the PHP translation format WordPress 6.5 introduced, is not excluded. It is executable PHP, and a blanket languages/* rule would have covered it.

Checking it yourself

The verifier is published next to the manifests, at checksums.wpmanageninja.com/verify.sh — no account, no download page, nothing in front of it. Open it in the browser and read it, then run it:

It needs curl and python3, sends nothing but that one request for a public manifest, and exits 0 when clean and 1 when anything differs, so it drops straight into a monitoring job.

Both runs are real, against an install that was on 5.2.14 at the time. The one on the right is the same install with three edits; all three come back by name.

Downloading and running a script is, of course, exactly the class of thing this post is about. So read it first; it is short, and it does nothing but fetch a manifest and hash files. Or skip it entirely — the whole check is twenty lines, and this is all of them:

SLUG=ninja-tables-pro
DIR=wp-content/plugins/$SLUG
VER=$(grep -m1 -i '^[ */#]*Version:' "$DIR/$SLUG.php" | sed 's/.*: *//; s/[[:space:]]*$//')

curl -s "https://checksums.wpmanageninja.com/plugin-checksums/$SLUG/$VER.json" | python3 -c '
import hashlib, json, os, sys, fnmatch
root, man = sys.argv[1], json.load(sys.stdin)
skip = ("*/language?/*.mo", "*/language?/*.po", "readme.txt")
files, found = man["files"], 0
for rel, h in sorted(files.items()):
    p = os.path.join(root, rel)
    if not os.path.isfile(p):
        print("missing ", rel); found += 1
    elif hashlib.sha256(open(p, "rb").read()).hexdigest() != h["sha256"]:
        print("modified", rel); found += 1
for d, _, names in os.walk(root):
    for n in names:
        rel = os.path.relpath(os.path.join(d, n), root)
        if rel not in files and not any(fnmatch.fnmatch("/" + rel, s) for s in skip):
            print("added   ", rel); found += 1
print("\n%d finding(s) across %d files, %s %s" % (found, len(files), man["plugin"], man["version"]))
' "$DIR"

That reports the same findings as the script, minus its soft-change list and exit codes. A WP-CLI command and a check inside the plugin’s own admin are both on the way; neither is needed to answer the question today.

And the manifest itself can be checked against the key, with nothing but curl and a runtime that can do Ed25519:

The 64 hex characters in that snippet are the same ones in the plugin’s UpdateVerifier.php. Compare them.

What this does not solve

Being clear about the edges is part of the point.

  • Coverage starts on 21 August 2026. We publish checksums from the current release of each product forward, not for versions released before the service existed. If a version is not listed, we have no manifest for it — that is a gap in our coverage, not a finding about your files.
  • A compromised server can lie about its own files. Any verifier running on the box it is checking is only as trustworthy as that box. Run it from outside, from your host’s tooling, or against a backup, when it matters.
  • Versions published before signing existed have no .sig yet. They need a re-sign pass, and until nothing is missing one, a client cannot treat an absent signature as evidence of tampering.
  • The indexes are not signed. The list of which versions exist still rests on TLS, even where the manifests themselves do not.
  • WP-CLI cannot be pointed at us. wp plugin verify-checksums resolves a hardcoded class constant with no filter or config override, so consuming these checksums from WP-CLI means shipping our own command. That is on the list.

What’s next

Ninja Tables Pro joins FluentCommunity Pro, Fluent Boards Pro and FluentAffiliate Pro on this pipeline, and the rest of the range is following. Ahead of that: a file-integrity check inside the plugin itself, as a Site Health test and an admin screen, so a site owner can answer the question from the dashboard rather than from a terminal; a WP-CLI command; and signed indexes.

Updating a plugin should not require trusting every server between you and us. For Ninja Tables Pro, it no longer does.

Ninja Tables Pro 5.2.15 was released on 21 August 2026 with 1,109 files fingerprinted. The register at checksums.wpmanageninja.com currently covers 14 products.

Similar Posts

Add your first comment to this post