<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Market My Casa Engineering]]></title><description><![CDATA[Market My Casa Engineering]]></description><link>https://marketmycasa.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Market My Casa Engineering</title><link>https://marketmycasa.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 19 Sep 2026 19:00:55 GMT</lastBuildDate><atom:link href="https://marketmycasa.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Web push from a PHP backend with no vendor: VAPID, ES256, and the six ways it fails silently]]></title><description><![CDATA[Originally published on DEV.
I ship a WordPress plugin that real estate agents use to send postcards. When somebody scans the QR code on a card and lands on the agent's page, the agent should know wit]]></description><link>https://marketmycasa.hashnode.dev/web-push-from-a-php-backend-with-no-vendor-vapid-es256-and-the-six-ways-it-fails-silently</link><guid isPermaLink="true">https://marketmycasa.hashnode.dev/web-push-from-a-php-backend-with-no-vendor-vapid-es256-and-the-six-ways-it-fails-silently</guid><category><![CDATA[PHP]]></category><category><![CDATA[webdev]]></category><category><![CDATA[PWA]]></category><dc:creator><![CDATA[Market My Casa]]></dc:creator><pubDate>Wed, 16 Sep 2026 12:07:24 GMT</pubDate><content:encoded><![CDATA[<p><em>Originally published on <a href="https://dev.to/marketmy_casa/web-push-from-a-php-backend-with-no-vendor-vapid-es256-and-the-six-ways-it-fails-silently-34a5">DEV</a>.</em></p>
<p>I ship a WordPress plugin that real estate agents use to send postcards. When somebody scans the QR code on a card and lands on the agent's page, the agent should know within seconds — on their phone, with a Call button — because the first agent to ring usually gets the listing.</p>
<p>That is a push notification. And I did not want Firebase, OneSignal, or a Node sidecar. The whole product is one PHP plugin on a shared host, and it should stay that way.</p>
<p>It turns out the Web Push protocol is small enough to implement in a few hundred lines of PHP with OpenSSL. The crypto took an afternoon. Getting a notification to actually appear on a phone took a week, and none of that week was crypto. This post is the week.</p>
<h2>The shape of it</h2>
<p>Web push has three parties: your server, the browser's push service (Google's FCM for Chrome, Mozilla's autopush for Firefox, Apple's for Safari), and the service worker running in the user's browser.</p>
<ol>
<li>Your page asks <code>Notification.requestPermission()</code>, then <code>registration.pushManager.subscribe()</code> with your <strong>VAPID public key</strong>. The browser hands back a subscription: an endpoint URL on the push service, plus two keys.</li>
<li>You store the endpoint.</li>
<li>To notify, your server does an HTTP POST to that endpoint, signed with a <strong>VAPID JWT</strong> so the push service knows it's you.</li>
<li>The push service wakes the service worker, which shows a notification.</li>
</ol>
<p>VAPID is "Voluntary Application Server Identification." It's an ECDSA P-256 keypair. The public key goes to the browser; the private key signs a JWT on every send.</p>
<h2>The crypto, in PHP</h2>
<p>Generate the keypair once and store it:</p>
<pre><code class="language-php">$key = openssl_pkey_new( array(
    'private_key_type' =&gt; OPENSSL_KEYTYPE_EC,
    'curve_name'       =&gt; 'prime256v1',
) );

openssl_pkey_export( $key, $pem );
$details = openssl_pkey_get_details( $key );

// The browser wants the raw uncompressed point, base64url, no padding.
$public = base64url( $details['ec']['x'] . $details['ec']['y'] );
</code></pre>
<p>Wait — that public key is 64 bytes and the browser wants 65. The uncompressed point format is <code>0x04 || X || Y</code>. Missing that leading byte was my first silent failure: <code>subscribe()</code> throws <code>InvalidAccessError</code>, which your promise chain probably swallows.</p>
<pre><code class="language-php">$public = base64url( "\x04" . $details['ec']['x'] . $details['ec']['y'] );
</code></pre>
<p>The JWT is standard ES256. The claims matter:</p>
<pre><code class="language-php">$audience = parse_url( $endpoint, PHP_URL_SCHEME ) . '://' . parse_url( $endpoint, PHP_URL_HOST );

$header = base64url( json_encode( array( 'typ' =&gt; 'JWT', 'alg' =&gt; 'ES256' ) ) );
$claims = base64url( json_encode( array(
    'aud' =&gt; $audience,
    'exp' =&gt; time() + 12 * 3600,
    'sub' =&gt; 'mailto:you@example.com',
) ) );

openssl_sign( "$header.$claims", $der, $pem, OPENSSL_ALGO_SHA256 );
</code></pre>
<p>Second silent failure: <code>aud</code> must be the origin of the push service, not the subscription endpoint. <code>https://fcm.googleapis.com</code>, not <code>https://fcm.googleapis.com/fcm/send/abc123</code>. Get it wrong and FCM returns 403 with a body you will never read because you only logged the status code.</p>
<p>Third: OpenSSL gives you a DER-encoded signature. JWS wants raw <code>R || S</code>, each exactly 32 bytes, zero-padded. DER can be 70, 71 or 72 bytes depending on whether R or S has a high bit set. If you just base64 the DER, roughly one in four signatures will verify and the rest will 401, which is a wonderful thing to debug.</p>
<pre><code class="language-php">function der_to_raw( $der ) {
    // SEQUENCE { INTEGER r, INTEGER s }
    $pos = 2;
    $out = '';
    foreach ( array( 'r', 's' ) as $part ) {
        $pos++;
        $len = ord( $der[ $pos++ ] );
        $val = substr( $der, $pos, $len );
        $pos += $len;
        $val = ltrim( $val, "\x00" );
        $out .= str_pad( $val, 32, "\x00", STR_PAD_LEFT );
    }
    return $out;
}

$jwt = "$header.$claims." . base64url( der_to_raw( $der ) );
</code></pre>
<p>Then the request:</p>
<pre><code class="language-php">wp_remote_post( $endpoint, array(
    'headers' =&gt; array(
        'Authorization' =&gt; 'vapid t=' . $jwt . ', k=' . $public,
        'TTL'           =&gt; 86400,
        'Content-Length'=&gt; 0,
    ),
    'body'    =&gt; '',
) );
</code></pre>
<p>Note the empty body. I send <strong>no payload at all</strong>.</p>
<h2>Why no payload</h2>
<p>Encrypting a payload for web push means ECDH against the subscription's <code>p256dh</code> key, HKDF, AES-128-GCM, and the <code>aes128gcm</code> content encoding with its salt and record framing. It's all doable in PHP. It's also all unnecessary for my case.</p>
<p>An empty push still wakes the service worker. The worker can then fetch <code>/wp-json/myplugin/v1/push/latest</code> — over its normal session cookie — and ask the server what happened. The server knows exactly what's new for that subscription and answers with a title, a body and a URL.</p>
<pre><code class="language-js">self.addEventListener('push', function (event) {
  event.waitUntil(
    fetch('/wp-json/myplugin/v1/push/latest', { credentials: 'include' })
      .then(r =&gt; r.json())
      .then(n =&gt; self.registration.showNotification(n.title, {
        body: n.body,
        icon: n.icon,
        data: { url: n.url },
      }))
  );
});
</code></pre>
<p>This has a property I've come to like: the push service never sees anything but "wake up." No lead's name, no address, nothing to encrypt because nothing is sent. And the payload-size limit (4 KB) stops mattering.</p>
<p>The one gotcha: <strong>service workers don't have your REST nonce.</strong> WordPress's cookie auth for REST requires <code>X-WP-Nonce</code>, and the worker has no page to read it from. So <code>/push/latest</code> authenticates by the subscription endpoint the worker sends in the query string, matched against what's stored. The endpoint is a 200-character unguessable URL; treating it as a bearer token for this one read-only route is fine.</p>
<h2>Where the week went</h2>
<p>Everything above verified in tests before the first real send. Then the real sends did nothing, and the tests kept passing. Here is the list, in the order I found them.</p>
<h3>1. The subscription was never stored</h3>
<p>The page called <code>subscribe()</code>, got a subscription, and POSTed it to <code>/wp-json/myplugin/v1/push/subscribe</code> — which returned 401 because I'd forgotten <code>X-WP-Nonce</code> on the fetch. The promise chain had a <code>.catch</code> that removed the "turn on" bar so it wouldn't nag. From the user's side: tap Turn on, bar disappears, done. From the server's side: nothing arrived, ever.</p>
<p><strong>Fix:</strong> send the nonce; and on failure, say so on screen rather than tidying up.</p>
<h3>2. The audience was almost right</h3>
<p>I built <code>aud</code> from the full endpoint on the first pass. FCM's 403 body says <code>the aud claim is invalid</code>. I was logging <code>wp_remote_retrieve_response_code()</code> and not the body.</p>
<p><strong>Fix:</strong> log the body on any non-2xx. Every push service returns a readable reason.</p>
<h3>3. The wrong people</h3>
<p>Notifications go to "every seated agent on this account." My audience query fell through to an empty set when the account had a single user with no team under them — which was every solo account. The send loop ran zero times and reported success.</p>
<p><strong>Fix:</strong> the audience for a solo account is the owner. Obvious afterwards.</p>
<h3>4. The right people, the wrong app</h3>
<p>This one cost the most and is the most useful.</p>
<p>On Android, a web push notification is attributed to <strong>whatever registered the subscription</strong>. If the user tapped Turn on in a Chrome tab, the notification shows Chrome's icon, "Chrome • yourdomain" as the sender, and Chrome's own "Unsubscribe" button. If they tapped it inside the installed PWA (added to home screen), it shows your icon and your app's name.</p>
<p>Worse: subscribe from both and you have two subscriptions, and the user gets every notification twice.</p>
<p><strong>Fix:</strong> only offer the subscribe control when <code>window.matchMedia('(display-mode: standalone)').matches</code> — i.e. inside the installed app. In a tab, tell them to install it first.</p>
<h3>5. The PWA scope was the whole site</h3>
<p>My manifest had <code>"scope": "/"</code>. That means every URL on the domain opens inside the app once it's installed. So when an agent tested by scanning their own postcard, the <em>homeowner's</em> landing page opened inside the agent's app, full-screen, no address bar. Not a push bug, but I found it while chasing one.</p>
<p><strong>Fix:</strong> scope the manifest to the app's path. And because scope is baked in at install time, users must remove and re-add the app for the change to take.</p>
<h3>6. Stale subscriptions look like failures</h3>
<p>The push service returns <strong>410 Gone</strong> for a subscription the browser has dropped — the user cleared site data, or revoked permission, or subscribed from a tab and then installed the app. My first send loop counted these as errors and reported "1 of 2 failed."</p>
<p><strong>Fix:</strong> treat 404 and 410 as "delete this subscription and move on," and report them separately from real failures.</p>
<h2>The diagnostic that ended it</h2>
<p>The single most valuable thing I built was a <strong>Test notifications</strong> button in the app's account menu that sends one push to the current user and prints, in plain English, what happened:</p>
<pre><code>1 of 2 accepted it. If nothing appears within a few seconds,
the phone is holding it back rather than the server: check
notifications are allowed for the app in your phone settings,
and that it is not in a focus or do-not-disturb mode.
fcm.googleapis.com: 410 — the subscription has expired
fcm.googleapis.com: accepted (201)
</code></pre>
<p>It reports per endpoint: the HTTP status, and a one-line reason. It distinguishes "never subscribed" from "the server sent it and the phone ate it" — which, once the server side works, is where every remaining support question lives.</p>
<p>If you build one thing from this post, build that.</p>
<h2>What I'd tell myself at the start</h2>
<ul>
<li>The <code>0x04</code> byte, the <code>aud</code> origin, and DER→raw are the three crypto bugs. They're each one line.</li>
<li>Log the response <strong>body</strong>. Every push service tells you why.</li>
<li>Don't encrypt a payload you don't need. Wake the worker and let it ask.</li>
<li>Only subscribe from the installed app. Tabs produce Chrome-branded notifications and duplicates.</li>
<li>Treat 410 as cleanup, not failure.</li>
<li>A diagnostic button that reports per endpoint in plain words will save you more time than any of the above.</li>
</ul>
<p>The full plugin is closed, but everything here is the generic shape of it — the same code that runs in production, with the product-specific bits taken out. It's about 300 lines of PHP and 60 of JavaScript. No dependencies.</p>
<hr />
<p><em>I run <a href="https://marketmy.casa">Market My Casa</a>, postcard and lead-page software for real estate agents. The push notifications go to a phone app that's a PWA, because I did not want an App Store listing for a tool only existing customers use — that's a post for another day.</em></p>
]]></content:encoded></item></channel></rss>