Web Platform · Progressive Web
Web Push: Subscription, Encryption, and Delivery
The four parties involved in a push message, how the aes128gcm envelope hides the payload from the push service, and which delivery headers decide whether a message is shown or dropped.
Push delivery involves four parties that never all trust each other: the page, the service worker, the browser vendor’s push service, and the application’s own server. The design constraint follows from the third: the push service must be able to route a message without being able to read it, and the application server must be able to prove which application a message belongs to without holding a per-user secret. Everything else in the protocol follows from those two requirements.
Four parties and what each can see
The page asks the browser for a subscription. The browser contacts its push service and receives an endpoint URL, a capability that identifies this browser instance, plus two key material values. The page sends that subscription to the application server, which stores it against the user. Later, the application server encrypts a payload and POSTs it to the endpoint; the push service queues it and wakes the browser, which decrypts it and dispatches a push event to the service worker.
The push service sees the endpoint, the timing and size of each message, and the headers. It does not see the payload, which is encrypted to keys held only by the browser. The application server sees the subscription’s public key but never the private key, and never learns when the message is actually displayed unless the client reports back through some other channel. The service worker sees the decrypted payload and decides whether to display anything — a decision the protocol leaves to the application but the platform may police.
// applicationServerKey takes a BufferSource, not the base64url string.
function urlBase64ToUint8Array(base64) {
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '=');
const raw = atob(padded.replace(/-/g, '+').replace(/_/g, '/'));
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, // Required by Chromium.
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
await fetch('/api/push/subscriptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
userVisibleOnly: true is a promise that every push message results in a user-visible notification. Chromium requires it and rejects a subscription without it; the flag exists because the alternative — silent background wakeups — was considered an unacceptable tracking channel.
Subscription keys and the aes128gcm envelope
The subscription carries endpoint, an optional expirationTime, and a keys object with p256dh and auth. p256dh is the browser’s P-256 public key in uncompressed point form, 65 bytes encoded as base64url. auth is a 16-byte shared secret. Both are per-subscription, which is why a subscription cannot be moved between browsers or reused after the user clears site data.
Encryption follows RFC 8291 and uses the aes128gcm content coding defined in RFC 8188. The application server generates its own ephemeral P-256 key pair, performs ECDH against p256dh, and derives a symmetric key through two HKDF steps: the auth secret salts the derivation of an intermediate key from the ECDH output, and a random 16-byte salt then derives the content-encryption key. The result encrypts the payload with AES-128-GCM. The message body is a header block — a 16-byte salt, a 4-byte record size, and a key identifier holding the sender’s 65-byte P-256 public key — followed by the ciphertext and a 16-byte authentication tag. This is exactly the aes128gcm framing that HTTP content encoding uses, which is why the wire format is named after the coding rather than after push.
The consequence for payload design is a hard size budget. The framing adds roughly 100 bytes of overhead, and push services cap the body — commonly in the region of 4 KiB — so a message is a few kilobytes at most. Anything larger must be fetched by the service worker after the wake-up, which means the push message carries a reference rather than the content. The reference has to be meaningful without the payload, because the worker may run minutes later and the server state may have moved on.
Delivery headers that decide the outcome
The application server sends the encrypted body to the endpoint with three headers that shape what happens if the browser is not reachable. TTL is mandatory: it is the number of seconds the push service should retain the message. A TTL of 0 means deliver now or discard. Topic collapses messages: a new message with the same topic replaces any pending message with that topic at the push service, which is how a “new message in conversation X” alert avoids arriving five times after a reconnect. Urgency is a hint — very-low, low, normal, or high — that a push service may use to decide whether to wake a device on battery.
The response codes matter more than they appear. A 201 Created means the message was accepted for delivery, not that it was displayed. 404 Not Found and 410 Gone mean the subscription is no longer valid and must be removed from the application’s store; retrying wastes quota and, in aggregate, gets senders rate-limited. 429 Too Many Requests requires honouring the Retry-After header. A 413 means the payload exceeded the service’s limit, which varies by vendor and is not discoverable in advance — another reason to keep messages small.
Subscriptions also carry their own lifetime. expirationTime is a timestamp in milliseconds after which the subscription is guaranteed dead; a null value means the browser does not advertise one. Push services may invalidate a subscription earlier, without notice, when the browser profile is reset or the user revokes notification permission.
Application server identity
A push service must know which application is sending, or the endpoint would be usable by anyone who obtained it. VAPID, RFC 8292, supplies that: the application server holds an EC P-256 key pair and signs a JWT with the private key, placing the public key and the token in the Authorization header as vapid t=<jwt>, k=<public-key>. The JWT’s aud claim is the origin of the push service, not the application’s origin, and sub is a contact identifier such as a mailto: URL. The push service verifies the signature against k.
The keys are application-wide, not per-user, but a subscription is bound to the applicationServerKey that created it: a push signed with a different key is rejected by the push service for that subscription. Rotation therefore invalidates existing subscriptions unless the old key is retained for sending, and deployments that rotate need a resubscription path for clients holding an old endpoint.
The push event and visible outcomes
The push event is dispatched to the service worker. event.data carries the decrypted payload as a PushMessageData, or null if the sender used an empty body. The handler must call event.waitUntil() with a promise that resolves when the notification has been shown, or the worker may be terminated before the notification appears.
self.addEventListener('push', (event) => {
const data = event.data ? event.data.json() : {};
event.waitUntil(self.registration.showNotification(data.title ?? 'Update', {
body: data.body ?? '',
tag: data.tag, // Same tag replaces an existing notification.
data: { url: data.url ?? '/' }
}));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url));
});
Showing a notification requires notification permission. If permission has been revoked, showNotification() rejects; if the handler does nothing at all, Chromium displays a generic notification indicating that the site updated in the background, and repeated silent pushes can lead to the push permission being revoked. userVisibleOnly is enforced by that policy rather than by a check the page can observe.
Expiry, resubscription, and drift
A pushsubscriptionchange event exists to report that a subscription has been invalidated, but it is not reliably delivered across implementations and can arrive with an expired subscription. The dependable approach is to verify the subscription on every application start: read registration.pushManager.getSubscription(), compare its endpoint with the one the server holds, and re-subscribe or re-register when they differ.
Between two visits the endpoint can change silently while the old one still returns success. Messages sent to the old endpoint may be accepted by the push service and never delivered, so the failure is invisible from the sender’s side until a 410 arrives much later. Storing the subscription with a timestamp and refreshing it whenever the application is opened bounds that drift.
The interaction with the rest of the worker’s state is the usual one: a push handler that needs data to render its notification must read it from a cache or from IndexedDB, and must tolerate both being empty when the message arrives after the caches were evicted. The eviction rules are in Storage Quota, Persistence, and Eviction, and the lifecycle guarantees that apply to the worker itself, including termination between events, are in Service Worker Lifecycle and the Registration Scope.
References
- RFC 8030: Generic Event Delivery Using HTTP Push — IETF
- RFC 8291: Message Encryption for Web Push — IETF
- RFC 8292: Voluntary Application Server Identification (VAPID) for Web Push — IETF
- Push API — W3C Web Applications Working Group