
Signed releases and public checksums for FluentCRM Pro
Last Updated:
FluentCRM Pro 3.1.13 shipped on 24 August 2026. It is the first FluentCRM 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.

- Twice a day, WordPress cron asks the vendor’s API what the latest version is, sending the license key and site URL.
- The API answers with a small JSON object: a version number and a URL to download it from.
- WordPress writes that answer straight into its
update_pluginstransient. The dashboard shows a badge. - Someone clicks Update, or auto-update fires overnight. Whatever that URL serves is what arrives.
- 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.org, downloads.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.

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.
A CRM raises the stakes of that generic problem. FluentCRM holds the whole contact database — names, email addresses, tags, segments, purchase history, everything an automation ever recorded — and it is the thing on the site that is allowed to send mail in bulk from your domain. Code injected into it does not need to escalate anywhere or go looking for interesting data. It is already sitting on the list, already holding the sending credentials, and already the reason the site sends thousands of messages nobody manually reviewed.
Four checks, three of them before anything downloads
The 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.

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 FluentCRM 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.9.5 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 fluent_crm/update_verification_failed, so support tooling can see why an update was refused instead of guessing.
The signed bytes are deliberately small and boring:
{"slug":"fluentcampaign-pro","version":"3.1.13","sha256":"2f88bf9b…","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 a value in the store 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

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, in app/Services/PluginManager/UpdateVerifier.php:
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.
It is the same key that signs FluentCommunity Pro, Ninja Tables Pro and Fluent Forms Pro. One key, one place it lives, one thing to protect.
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 whensodium_crypto_box()is missing. A host with the sodium extension andsodium_crypto_sign_verify_detachedindisable_functionsends 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 plainhttpURLs 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, FLUENTCAMPAIGN_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

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

So every release also gets a published manifest of every file it contains, hashed twice. For 3.1.13 that is 340 files.

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 — 3.1.13.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. FluentCRM Pro ships those files, they are executable PHP, and a blanket language/* rule would have covered them.
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:
curl -sO https://checksums.wpmanageninja.com/verify.sh
less verify.sh # 163 lines — read it before you run it
bash verify.sh wp-content/plugins/fluentcampaign-pro
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.

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=fluentcampaign-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:

Also in 3.1.13
Two other things in this release are worth naming, because they sit next to this work rather than inside it.
FluentCRM Pro went through a deep review by Patchstack, and the edge cases they reported are fixed in this release.
What’s next
FluentCRM Pro joins FluentCommunity Pro, Ninja Tables Pro and Fluent Forms 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 FluentCRM Pro, it no longer does.
Shahjahan Jewel
Hello, this is Jewel, CEO & Head of Ideas at WPManageNinja. I am obsessed with WordPress since 2009. My aim is to be a user-centric developer first, and a serial entrepreneur second. You will find me discussing various tech issues and trying to come up with scalable solutions on different forums when I am not busy coding.



