Do these 3 things before closing this tab:
1Repair Windows errors before they cause bigger problems2Fix the driver behind crashes, sound loss and screen glitches3Clear out junk files and repair common Windows errorsA PHP backend can send browser push notifications without a notification vendor. The working pattern has three parts: store each browser’s complete push subscription, encrypt the payload for that one subscription, and send it to the subscription’s push endpoint with a VAPID-authenticated request. The VAPID JWT is signed with ES256, which is ECDSA over the NIST P-256 curve. An HTTP success from the push service means the request was accepted for delivery. It does not prove that the browser received the message or that the user saw a notification. Most silent failures come from one of six places, covered below.
How a message actually travels
Web Push does not connect PHP directly to a browser. Your server hands the message to a push service, which is run either by the browser vendor or by another provider, and the browser pulls it from there. The sequence is:
As an Amazon Associate I earn from qualifying purchases.
- The page registers a service worker and calls
registration.pushManager.subscribe()withuserVisibleOnly: trueand your VAPID public key asapplicationServerKey. - The browser contacts its push service and returns a
PushSubscriptioncontaining anendpointURL and akeysobject withp256dhandauthvalues. - The page posts that subscription to your backend, which stores it as one record.
- PHP encrypts the payload for that subscription, signs a VAPID JWT, and POSTs both to the endpoint.
- The push service queues the message or forwards it when the device can receive it.
- The browser decrypts the payload, starts the service worker, and fires a
pushevent. The worker is responsible for showing anything the user sees.
The endpoint is a capability URL. Anyone who holds it may be able to send to that subscription, so store it like a credential, avoid writing it in full to logs, and never return it in public API responses.
The Tool Desk
Outbyte Driver Updater FREEScan for outdated or missing drivers - takes under a minuteDriver Scan →Outbyte PC Repair FREERepair Windows errors before they cause bigger problemsFix Now →Use a library for the encryption and request framing rather than writing that code yourself. MDN’s Offline and background operation guide puts it this way: “The app server can use a third-party library such as web-push to take care of the protocol details.” (MDN Web Docs, Offline and background operation)
#1 Best Overall
Two sets of keys that do different jobs
Most configuration errors come from mixing up the VAPID pair with the subscription’s own keys. They are separate:
| Key material | Created by | Where it lives | What it does |
|---|---|---|---|
| VAPID private key | You, once, when generating the pair | Server only, never sent to the browser | Signs the ES256 JWT |
| VAPID public key | Derived from the private key | Sent to the browser as applicationServerKey; sent with each request in the k parameter |
Identifies your application server to the push service |
| Endpoint | The browser’s push service | Your database | Destination URL for the POST |
p256dh |
The browser, per subscription | Your database | The subscriber’s public key used to encrypt the payload |
auth |
The browser, per subscription | Your database | Authentication secret mixed into payload encryption |
Keep the VAPID pair stable. Subscriptions are bound to the public key they were created with, so rotating the pair generally means every browser must subscribe again. Do not substitute p256dh or auth for VAPID keys, and do not use the VAPID private key for anything else.
ES256 and what the VAPID JWT must contain
VAPID (Voluntary Application Server Identification, defined in RFC 8292) lets your server identify itself to the push service. The identity is a signed JWT, and RFC 8292 requires the signature to use ECDSA over P-256 with SHA-256, identified as ES256. The JWT carries three claims:
- aud: the origin of the push endpoint, meaning scheme and host only, such as
https://push.examplefor an endpoint athttps://push.example/send/abc. A path in the audience is a common reason for rejection. - exp: an expiry time as a Unix timestamp, no more than 24 hours after the request is sent.
- sub: a contact URI that must use
mailto:orhttps:.
The JOSE header is {"typ":"JWT","alg":"ES256"}. The request then carries the token in an Authorization header with the public key:
Rank #2
Authorization: vapid t=<JWT>, k=<VAPID public key, Base64URL>
One detail trips up hand-rolled signers. OpenSSL’s openssl_sign() returns ECDSA signatures in ASN.1 DER form, but a JWS ES256 signature must be the fixed-length 64-byte concatenation of R and S. A DER signature is malformed for this purpose, and the push service will reject it.
PHP setup and sending
Runtime requirements
- PHP 8.2 or newer, according to the current README of the
minishlink/web-pushproject. - The
mbstring,curl, and OpenSSL extensions, with OpenSSL built for elliptic-curve operations. bcmathand/orgmpare optional performance aids.- Composer’s autoloader loaded before the library is used.
Requirements change between library releases. Older PHP versions can use older release lines of the library, so check the README for the version you pin rather than assuming the current requirement applies to your runtime. Install with:
composer require minishlink/web-push
Storing the subscription
Save endpoint, p256dh, and auth together as one record, and size the database columns for the full values. The library’s README names undersized database fields for authentication data as a common operational problem. Refresh the record each time the page re-subscribes, and delete it when the push service reports the subscription gone.
Free tools Windows power users keep installed
One-click scans. No signup required.
Subscribing in the browser
Pass the VAPID public key to subscribe() in the form the browser’s Push API accepts; most implementations decode the Base64URL string into bytes first with a helper of their own. Then send the subscription to your server. JSON.stringify() on a PushSubscription produces the endpoint and keys in the shape your backend needs.
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY), // your helper
});
await fetch('/push/subscribe', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(subscription),
});
Sending from PHP and checking the result
<?php
require __DIR__ . '/vendor/autoload.php';
use MinishlinkWebPushSubscription;
use MinishlinkWebPushWebPush;
$webPush = new WebPush([
'VAPID' => [
'subject' => 'mailto:[email protected]',
'publicKey' => getenv('VAPID_PUBLIC_KEY'),
'privateKey' => getenv('VAPID_PRIVATE_KEY'),
],
]);
// $row is one stored subscription: endpoint, p256dh, auth
$subscription = Subscription::create([
'endpoint' => $row['endpoint'],
'publicKey' => $row['p256dh'],
'authToken' => $row['auth'],
'contentEncoding' => 'aes128gcm',
]);
$report = $webPush->sendOneNotification(
$subscription,
json_encode(['title' => 'Order shipped', 'body' => 'Track it in your account.'])
);
if ($report->isSuccess()) {
// Accepted by the push service. Record the status only.
} elseif ($report->isSubscriptionExpired()) {
// Delete this subscription row.
} else {
error_log('Web Push failed: ' . $report->getReason());
}
Keep keys in environment variables or a secret store rather than in source files. Log the status and reason, never the full endpoint. The payload content coding must match the Content-Encoding header, which is aes128gcm in the usual case, and the library handles that pairing when you let it build the request.
Reading push-service responses
| Response | Typical meaning | What to check |
|---|---|---|
| 201 Created | The push service accepted the message for delivery (RFC 8030) | Store the status; delivery to the device is a later stage |
| 400 | The request is malformed | Headers, including the TTL header, and the encryption framing |
| 401 or 403 | The VAPID authorization was rejected | Key pair match, JWT audience, ES256 signature format |
| 404 or 410 | The subscription no longer exists | Delete the stored record and resubscribe on the next visit |
| 413 | The payload is too large | Shorten the payload; push services accept at least 4,096 bytes (RFC 8030) |
| 429 | The sender is rate-limited | Back off and retry later |
These meanings describe the HTTP status categories the protocol uses; the push service’s own error bodies can add detail, so keep them in your logs.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Six places a failure can hide
“Silent” here means one of two things: your code reports a successful send, or the user sees no notification. Web Push does not suppress every error, so each failure below leaves some evidence somewhere. The six layers follow the order a message passes through the system. They are a practical way to organize the failure points in the specifications and MDN’s guides, not an official checklist.
Quick wins for a faster PC:
Clear out junk files and repair common Windows errorsFree Scan →Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →1. The browser never created a working subscription
- Push subscriptions require a secure context (HTTPS, or localhost during development) and an active service worker.
- Notification permission may be denied. Check
Notification.permissionin the page before subscribing. subscribe()can reject. Catch the error and recorderr.nameanderr.messagein the browser.- Omitting
userVisibleOnly: truecauses the call to fail in browsers that require it.
Look here first. Browser-side errors appear before any PHP log line exists.
Rank #4
2. The subscription was created with a different VAPID public key
If the browser subscribed with one applicationServerKey and your server signs with a different private key, authorization fails. Compare the key in PushSubscription.options.applicationServerKey in the browser with the public key your server is configured to use. Both must come from the same pair.
3. The VAPID JWT is malformed or aimed at the wrong audience
- The
audclaim must be the endpoint’s origin, with no path. - The signature must be ES256 in raw R||S form, not DER.
- The
expclaim must fall within 24 hours of the request. A server clock running fast or slow can make a valid expiry look wrong, so check the clock too. - The
subclaim must be amailto:orhttps:URI.
4. The saved subscription is stale or incomplete
A truncated auth value or a missing p256dh produces a message the browser cannot decrypt, which looks like silence to the user. Verify that the stored values match a freshly serialized subscription. Replace the record when the page re-subscribes, and remove endpoints the push service reports as expired or gone.
The pushsubscriptionchange event can signal refresh or revocation, but MDN marks it as not available in some widely used browsers. Use it as one reconciliation path, never the only one.
5. Encryption or runtime setup is wrong
- Confirm the
p256dhandauthvalues reached PHP intact, with no double-decoding or whitespace changes. - Confirm the required extensions are loaded in the same PHP binary your web server uses. CLI and FPM often differ.
- If the library throws a TLS error, fix the certificate trust store or CA bundle. Disabling peer verification may make the error go away, but it leaves the connection open to interception, so do not use it as the fix.
- Catch exceptions and log the message alongside the status code.
6. The push event arrives but no notification appears
The worker must register a push listener, parse event.data, and keep the event alive until the notification is created. Call event.waitUntil() around registration.showNotification(). Calling event.data.json() on a payload that is not JSON throws, so guard it.
self.addEventListener('push', (event) => {
const data = event.data ? event.data.json() : {};
event.waitUntil(
self.registration.showNotification(data.title || 'Update', {
body: data.body || '',
})
);
});
The Push specification allows a “silent” push that displays nothing. MDN reports that browsers do not support that behavior because of privacy concerns, so for user-facing notifications, every push should result in a visible notification.
Troubleshooting order
- In the browser, confirm HTTPS or localhost, an activated service worker, granted notification permission, and a current subscription.
- Compare the subscription’s application-server key with the server’s VAPID public key, and confirm the matching private key is the one signing the JWT.
- Compare the stored endpoint,
p256dh, andauthwith a freshly serialized subscription, without printing the endpoint in logs. - Send one test message. Capture the status code, response body, and exception from the library.
- Add logging to the worker’s
pushhandler and confirm theshowNotification()promise resolves. - Trigger a resubscription and confirm the server record is replaced rather than duplicated.
Work through the list in order. A failure at an earlier step makes the later steps look broken.
Choosing your implementation approach
There is no vendor comparison to make for a self-hosted setup, so decide on these points instead: whether you rely on the library’s protocol handling or maintain your own; whether your PHP runtime meets the library’s current requirements; how subscription records are refreshed and expired endpoints removed; what your telemetry records (ideally the push-service status and the worker’s handling separately); and whether every push produces a confirmed notification.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.




