I’m building an agency dashboard for my monitoring plugin. The shape is simple to describe: the free plugin runs on however many client sites, and each of those sites pushes its health reports to a central hub — a separate premium plugin the agency installs on a WordPress site of their own.
Which means N sites need to authenticate to one endpoint, repeatedly, unattended, forever.
WordPress has a built-in answer for this. Application Passwords have been in core since 5.6, they’re well understood, and they work over Basic Auth with the REST API. Reaching for them would have been the obvious call.
I went with HMAC-signed requests instead. Here’s the reasoning, because the trade-offs took me a while to work through and I think the Application Passwords default is wrong for this specific shape of problem.
What each one actually proves
This is the distinction that decided it.
Application Passwords authenticate the sender. The request carries a credential. The server checks that the credential is valid and concludes: this request came from someone who holds that password.
An HMAC signature authenticates the sender and the payload. The client computes a signature over the request body using a shared secret. The server recomputes it. If they match, the server knows both that the sender holds the secret and that not a single byte of the body has changed since it was signed.
For a monitoring product, the second property is the one that matters. My hub receives health reports and makes decisions on them — flagging sites as unhealthy, sending alerts, driving a dashboard an agency uses to decide where to spend their morning. A report that arrived authenticated but altered is worse than no report at all, because it looks trustworthy.
With Basic Auth I get transport integrity from TLS and nothing else. That’s usually fine. But “usually fine” is doing load-bearing work in a sentence about a security model, and integrity of the payload is cheap to get if you sign it.
The secret stops travelling
An Application Password goes over the wire on every single request. It’s a bearer credential — possession is authorization — so it has to be presented each time, base64-encoded in a header. TLS protects it in transit, but the credential itself is repeatedly exposed at both endpoints and anywhere in between that terminates TLS.
With HMAC, the shared secret is exchanged once, during pairing. After that it never travels again. What goes over the wire is a signature: a derivative that proves you have the secret without revealing it.
That’s a meaningful reduction in exposure surface for a credential that’s going to be used, unattended, thousands of times over its lifetime. Reverse proxies log headers. Debug tooling captures requests. Shared hosting environments are shared. A secret that only moves once is a secret with far fewer opportunities to leak.
Replay protection comes free, and doesn’t otherwise
Here’s a scenario Basic Auth has no answer for.
Someone captures a legitimate report POST — from a proxy log, a misconfigured debug dump, wherever. It’s a valid request with valid credentials. They replay it. And they keep replaying it, on a timer.
The server has no way to distinguish that from the real site reporting in. The credential is valid. The body is valid. To the hub, a compromised site now looks permanently healthy, because it keeps receiving the last good report forever. For a monitoring product, that’s close to a worst case: the failure is silent and the dashboard is green.
You can bolt replay protection onto Basic Auth, but you’re building it yourself, and it’s awkward because there’s nothing signed to bind a timestamp to.
With HMAC you get it structurally, because the signature covers whatever you include in it:
- A timestamp inside the signed payload. The server rejects anything outside a narrow window — a few minutes, allowing for clock drift. An old capture is dead on arrival.
- A nonce, also signed. The server keeps a short-lived store of nonces it has seen and rejects duplicates. Within the freshness window, each request can be used exactly once.
The timestamp bounds how long a captured request is useful. The nonce bounds it to a single use. Together they close replay entirely, and neither costs anything beyond a signed field and a small cache.
The important part is that both are inside the signature. A timestamp in an unsigned header is a suggestion; an attacker just edits it. Signed, it’s a constraint.
The practical reason: shared hosting eats Authorization headers
This is the least interesting argument and possibly the most decisive one.
Basic Auth over the WordPress REST API depends on the Authorization header arriving intact. On a great many shared hosts — particularly PHP running under CGI or FastCGI — it doesn’t. The header gets stripped before PHP ever sees it.
This is such a persistent problem that the standard workaround is a .htaccess rewrite to smuggle the header through under a different name, and WordPress’s own Application Passwords documentation acknowledges the issue.
Now think about what that means for a product. My users are agencies with client sites spread across whatever hosting those clients happened to buy. If I ship Application Passwords, some meaningful percentage of installs fail at pairing with an authentication error, and my support burden becomes explaining .htaccess edits to people who may not have access to the .htaccess file on a server they don’t control.
An HMAC signature goes in a header I name. Nothing strips X-CSMON-Signature, because nothing has an opinion about it.
I want to be clear that this isn’t a security argument. It’s a deliverability argument. But a security model that doesn’t survive contact with real hosting isn’t a security model, it’s a plan.
What the implementation actually looks like
The shape, roughly:
Pairing. The hub generates a random secret and a site identifier, and the pair is entered on the satellite (or passed through a pairing endpoint once). Both ends store it. This is the only moment the secret exists in transit.
Signing a report. The satellite builds the payload, adds a timestamp and a nonce, serializes it, and computes hash_hmac( 'sha256', $body, $secret ). That goes in a header alongside the site identifier — the identifier tells the hub which secret to check against.
Verifying. The hub looks up the secret by site ID, recomputes the HMAC over the raw body, and compares. Two things that are easy to get wrong here:
- Compare with
hash_equals(), not===. A short-circuiting string comparison leaks timing information about how many leading bytes matched.hash_equals()is constant-time. This is a one-word fix that people skip constantly. - Sign the raw body, not a re-serialized version of it. If you decode JSON and re-encode it before verifying, key ordering or float formatting can differ and your signature check fails for reasons that will take you an afternoon to find.
Then the checks in order: signature valid → timestamp within window → nonce unseen. Reject at the first failure, and don’t tell the caller which one failed.
At rest. The stored secrets are encrypted rather than sitting in plaintext in wp_options — libsodium where it’s available, OpenSSL as fallback. If someone gets read access to the hub’s database, they shouldn’t walk away with every satellite’s signing key.
When Application Passwords are the right call
I don’t want this to read as a blanket verdict, because for most things they’re correct and I’d use them.
Use Application Passwords when a human is authorizing a third-party tool to act on their behalf — a desktop app, a script, a service integration. That’s exactly what they were designed for, they’re revocable per-application from the user profile screen, they work with the full permissions model, and they require no key management from you.
They’re a poor fit when it’s machine-to-machine, unattended, high-frequency, and payload integrity matters. That’s the case where the credential travels constantly, replay is a real risk, and you have no human present to re-authenticate when something breaks.
Mine is the second case. If yours is the first, use the thing that’s already in core.
The summary
Four reasons, in the order I’d rank them:
- Signing authenticates the payload, not just the sender. Reports my hub acts on can’t be altered in flight.
- The secret travels once, at pairing, and never again.
- Replay protection is structural — signed timestamp plus signed nonce — rather than something I’d have to invent on top of a bearer token.
- It survives shared hosting, where the
Authorizationheader frequently does not.
The cost is that I own the key management: generation, storage, encryption at rest, rotation. That’s real work, and it’s the honest trade. For a dashboard where an agency is trusting the green checkmarks to tell them which of thirty client sites to look at this morning, I’d rather own that work than own a green checkmark that came from a replayed request.
Canary Site Monitor is free on WordPress.org. The agency hub described here is in development.