Build1 publisher3 min readPublished
Three in four VAPID signatures return 401 until the DER wrapper comes off
A WordPress plugin author implemented Web Push against VAPID and ES256 in a few hundred lines of PHP with OpenSSL. The crypto took an afternoon; the week went to a missing 0x04 byte, a wrong JWT audience and DER-encoded signatures.
The Engineer · Build desk

What happened
- A developer shipping a WordPress plugin for real estate postcards built web push without Firebase, OneSignal or a Node sidecar, keeping the product to one PHP plugin on a shared host.
- The Web Push protocol turned out to be implementable in a few hundred lines of PHP with OpenSSL, covering the VAPID keypair, the ES256 JWT and the POST to the push service endpoint.
- The browser's subscribe() call threw InvalidAccessError because the VAPID public key was sent as 64 bytes of X and Y without the 0x04 prefix that makes an uncompressed point 65 bytes.
- FCM returned 403 when the JWT audience claim was the full subscription endpoint instead of the push service origin, https://fcm.googleapis.com.
- Signing with the DER blob OpenSSL returns, instead of raw 32-byte R and S, left roughly one in four signatures verifying and the rest rejected with 401.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- contradiction The post frames these as silent failures, but two of the three named cases are explicit rejections from the push service; the response body went unlogged, so instrumentation is the cheap fix before any crypto work.
- constraint With no SDK in the path, key encoding and claim contents reach the wire unchecked. A format error surfaces at the call site only as a vendor status code.
- decision Skipping payload encryption moves the notification text into a fetch at display time. The server then has to be able to answer what is new for a given subscription, on demand.
- capability A shared host with PHP and OpenSSL can address a push service directly: one POST, three headers, no body, no sidecar process to keep alive.
An unread response body is what made two of these look silent. FCM answers a wrong JWT audience with a 403 and a body that explains itself, and by the author's account only the status code was logged [9]. The browser-side case behaves the same way one layer up: the failure arrives as a rejection in a promise chain that probably swallows it [8]. A log line for the response body would have caught both, no debugger required.
The post's author wrote: "The crypto took an afternoon. Getting a notification to actually appear on a phone took a week, and none of that week was crypto." [4] The crypto in question is ECDSA on P-256: one keypair, generated once, whose private half signs a JWT on every send [7].
`openssl_sign` hands back a DER-encoded signature. JWS wants R and S raw, each zero-padded to exactly 32 bytes, and the DER form runs 70, 71 or 72 bytes depending on whether R or S has its high bit set [10]. Raw is 64 bytes, so 6 to 8 bytes of the DER blob is framing and sign bytes [17]. Base64 the DER and roughly one in four sends verify [11], which means three in four come back 401 [18]. One in four passing is worse to debug than none passing, because a retry looks like a fix. The rate is also what you would expect if the passing cases are the ones where neither integer has its high bit set, at half by half [19].
The decision I would defend in review is the empty body. Encrypting a payload means ECDH against the subscription's p256dh key, HKDF, AES-128-GCM, and the aes128gcm content encoding with its salt and record framing [14]. The send here is an HTTP POST with an Authorization header carrying `t=` and `k=`, a TTL of 86400, `Content-Length: 0`, and no body [13]. An empty push still wakes the service worker, which fetches `/wp-json/myplugin/v1/push/latest` over its normal session cookie and gets back a title, a body and a URL [15].
That transfers on two conditions. The server has to be able to re-derive what the notification should say from the subscription alone, and the browser has to still hold a valid session for your origin at the moment the worker runs. A plugin whose users are agents on the site that sent the alert fits both [1]. If the text is known only at send time, or the recipient is anonymous, the ECDH and AES-128-GCM path comes back.
The JWT expiry is set to 12 hours where the claim allows at most 24 [12], half the ceiling [20]. The post numbers three silent failures (the missing 0x04 prefix, the audience claim, the DER signature) and its text as supplied stops inside the service worker's push handler, three short of the six in its title [21].
What to watch
- Whether the empty-body send behaves the same on Mozilla's autopush and Apple's push service as it does on FCM, the one service whose 403 behaviour the post describes.
- What happens to a woken service worker when its authenticated fetch for the notification text comes back unauthorized.
- The remaining three of the six silent failures, which sit past the point where the supplied text of the post stops.