The Web Push Protocol¶
The Web Push protocol is the server-to-server half of push notifications: your application server sends an HTTPS POST to a URL that the browser's push service handed out, and the push service stores and forwards the message to the device. Four IETF RFCs define it: RFC 8030 for the HTTP request, RFC 8291 and RFC 8188 for end-to-end payload encryption, and RFC 8292 (VAPID) for identifying your server. The browser-side PushManager API is only half the system. If you run your own sender, debug delivery failures or handle millions of subscriptions, you need to know exactly what goes over the wire. This page covers each header, each key derivation step and each response code, and builds a complete sender with nothing but node:crypto.
Key takeaways
- A push is one
POSTto the subscription'sendpointwith a mandatoryTTLheader, an optionalUrgencyandTopic,Content-Encoding: aes128gcmwhen there is a body, andAuthorization: vapid t=<JWT>, k=<public key>. - Payloads are encrypted per subscription: ECDH on P-256 with a fresh ephemeral key, HKDF-SHA-256 mixing in the 16-byte
authsecret, then AES-128-GCM in a single RFC 8188 record. The largest plaintext that fits in the 4096-byte guaranteed body is 3993 bytes. - The VAPID JWT is ES256-signed. It carries
aud(the push service origin),exp(no more than 24 hours ahead) andsub(mailto:orhttps:). Its signature is rawR || S, not DER. 201means the push service accepted the message, not that it was delivered.404and410mean you should delete the subscription.429and5xxmean you should back off and honorRetry-After.400,401,403and413are bugs in your sender.- Subscriptions are bound to the VAPID public key used at
subscribe()time. To rotate keys, each browser has to resubscribe, so store the key ID with each subscription. - The endpoint is a URL that the client supplies. Validate its host against known push services before your server
POSTs to it, or you have built an SSRF primitive.
How the four RFCs fit together¶
Web Push is a stack of small specifications, each solving one problem. The W3C Push API defines what JavaScript sees (PushManager, PushSubscription, the push event). The IETF RFCs define the network protocol and the cryptography underneath it.
| Specification | Title | What it defines | Who implements it |
|---|---|---|---|
| RFC 8030 | Generic Event Delivery Using HTTP Push | Push resources, the POST request, TTL, Urgency, Topic, receipts, status codes | Push services and your application server |
| RFC 8291 | Message Encryption for Web Push | ECDH + auth secret key agreement, key derivation info strings, the single-record rule, the 3993-byte limit | Your application server (encrypt) and the browser (decrypt) |
| RFC 8188 | Encrypted Content-Encoding for HTTP | The aes128gcm content coding: header layout, rs, padding delimiters, CEK and nonce derivation | Same as RFC 8291 |
| RFC 8292 | Voluntary Application Server Identification (VAPID) | The vapid auth scheme, JWT claims, subscription restriction to a public key | Your application server (sign) and the push service (verify) |
| Push API | W3C Push API | subscribe(), applicationServerKey, userVisibleOnly, push and pushsubscriptionchange events | Browsers |
RFC 8030 describes both sides of the push service: the application server → push service interface (Section 5) and the push service → user agent interface. In practice only the first one is interoperable. Browsers talk to their vendor's push service over proprietary channels. Firefox holds a WebSocket to Mozilla's autopush (its dom.push.serverURL preference is wss://push.services.mozilla.com/), Chrome uses the Firebase Cloud Messaging connection that chrome://gcm-internals exposes, and Safari rides on the Apple Push Notification service. From your server's point of view none of this matters: you speak RFC 8030 to an HTTPS URL, and the push service handles the rest.
The end-to-end message flow¶
sequenceDiagram
participant Page
participant UA as Browser
participant PS as Push service
participant AS as Your server
Page->>UA: pushManager.subscribe(applicationServerKey)
UA->>PS: create subscription (restricted to VAPID key)
PS-->>UA: endpoint URL
UA-->>Page: PushSubscription with endpoint, p256dh, auth
Page->>AS: POST /api/push/subscriptions
Note over AS: store endpoint + keys + VAPID key id
AS->>AS: encrypt payload (RFC 8291) and sign JWT (RFC 8292)
AS->>PS: POST endpoint with TTL, Urgency, Topic, Authorization
PS-->>AS: 201 Created (Location of message resource)
PS->>UA: deliver when the device is reachable and TTL not expired
UA->>UA: decrypt with private key + auth secret
UA->>Page: push event in the service worker The subscription side (subscribe(), permission, the push event handler) is covered in Push Notifications. This page picks up at the moment your server holds a PushSubscription and wants to send a message.
Anatomy of a push subscription¶
PushSubscription.toJSON() produces the only data your server needs:
{
"endpoint": "https://fcm.googleapis.com/fcm/send/dG9rZW4...:APA91bH...",
"expirationTime": null,
"keys": {
"p256dh": "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4",
"auth": "BTBZMqHH6r4Tts7J_aSIgg"
}
}
| Field | Encoding | Size | Meaning |
|---|---|---|---|
endpoint | HTTPS URL | varies (often 150–500 characters) | The push resource. POST to it to send a message. It is a capability URL: anyone who holds it and can satisfy the VAPID check can push to this browser. |
expirationTime | DOMHighResTimeStamp or null | — | When the push service will expire the subscription. Almost always null in practice; treat a non-null value as a hint to refresh early. |
keys.p256dh | base64url, no padding | 65 bytes decoded | The browser's ECDH public key for this subscription: an uncompressed P-256 point that starts with 0x04. RFC 8291 calls it ua_public. |
keys.auth | base64url, no padding | 16 bytes decoded | The authentication secret (auth_secret), mixed into key derivation so that only holders of the subscription data can produce messages the browser accepts. |
The private half of p256dh never leaves the browser. That is why a push service, or anyone who compromises one, cannot read your payloads.
Push service endpoints by browser¶
The endpoint's origin tells you which push service you are talking to, which in turn tells you its limits and error format. Treat the path as opaque. Mozilla's documentation warns that it "reserves the right to change the endpoint at any time" and not to store only the last path segment.
| Browser | Push service | Typical endpoint origin | Notes |
|---|---|---|---|
| Chrome, and most Chromium browsers that include Google's push integration | Firebase Cloud Messaging (FCM) | https://fcm.googleapis.com | Chrome requires an applicationServerKey, so every subscription is VAPID-restricted. |
| Firefox (desktop and Android) | Mozilla autopush | https://updates.push.services.mozilla.com | Paths start with /wpush/. The best error messages of any push service (JSON with an errno). |
| Safari on macOS 13+ and iOS/iPadOS 16.4+ Home Screen web apps | Apple Push Notification service | https://web.push.apple.com | Apple asks you to allow https://*.push.apple.com through egress firewalls. |
| Edge on Windows | Windows Push Notification Services (WNS) | https://*.notify.windows.com | Microsoft tells WNS clients to validate that channel URIs use the notify.windows.com domain. |
Validate endpoints before you POST to them¶
The endpoint comes from JavaScript running on a client, so an attacker can register any URL they like: http://169.254.169.254/latest/meta-data/, an internal admin API, or a victim's server that you will then hammer with signed requests. Your push sender runs inside your network with your egress permissions. Accepting arbitrary endpoints is a textbook server-side request forgery (SSRF) vulnerability.
The push endpoint is untrusted input
Validate every endpoint when you store it and again when you send: require https:, reject IP literals and credentials in the URL, and match the hostname against a suffix allowlist of known push services. Log and alert on rejections. Push services can change hostnames, and a silently rejected subscription looks exactly like a user who never subscribed.
// Hostname suffixes of the push services in use by major browsers (September 2026).
// Match on suffix so that new subdomains keep working; alert when anything is rejected.
const ALLOWED_SUFFIXES = [
"fcm.googleapis.com", // Chrome, Chromium-based browsers
"push.services.mozilla.com", // Firefox (updates.push.services.mozilla.com)
"push.apple.com", // Safari: web.push.apple.com and other *.push.apple.com hosts
"notify.windows.com", // Edge on Windows (WNS)
];
export function validatePushEndpoint(raw) {
let url;
try {
url = new URL(raw);
} catch {
return { ok: false, reason: "not a URL" };
}
if (url.protocol !== "https:") return { ok: false, reason: "endpoint must be https" };
if (url.username || url.password) return { ok: false, reason: "credentials in URL" };
if (url.port && url.port !== "443") return { ok: false, reason: "non-default port" };
// IPv4 / IPv6 literals never belong to a push service.
if (/^\d+\.\d+\.\d+\.\d+$/.test(url.hostname) || url.hostname.startsWith("[")) {
return { ok: false, reason: "IP literal" };
}
const host = url.hostname.toLowerCase();
const allowed = ALLOWED_SUFFIXES.some((s) => host === s || host.endsWith(`.${s}`));
return allowed ? { ok: true, url } : { ok: false, reason: `unknown push service: ${host}` };
}
Sending a push message: the RFC 8030 request¶
Here is a complete request, the same shape every push service accepts:
POST /fcm/send/dG9rZW4...:APA91bH... HTTP/1.1
Host: fcm.googleapis.com
TTL: 86400
Urgency: normal
Topic: inbox_count
Content-Encoding: aes128gcm
Content-Type: application/octet-stream
Content-Length: 359
Authorization: vapid t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL2ZjbS5nb29nbGVhcGlzLmNvbSIsImV4cCI6MTc5MDQwNzI2Mywic3ViIjoibWFpbHRvOnB1c2hAZXhhbXBsZS5jb20ifQ.WN9g1Cajw_vX1FKZDawWXIcitv1chRaKutYtNUt4QfTm8auMMafZDzZ8JVvw3VxpYhc2FvijKoSqrlwd4ifEtQ, k=BH_mxdxTl0bgS44dlf7A7dr7EmQtPuqLFEXsVziA2WqTTxqMTZ43AuBy0wOLV0Vem3kB3mZ2opHBhk0nREX_WLQ
<binary aes128gcm body: 86-byte header + ciphertext + 16-byte tag>
Request headers reference¶
| Header | Required | Value | Defined by | Notes |
|---|---|---|---|---|
TTL | Yes | Non-negative integer, seconds | RFC 8030 §5.2 | "A push service MUST return a 400 (Bad Request) status code in response to requests that omit the TTL header field." |
Urgency | No | very-low, low, normal, high | RFC 8030 §5.3 | Defaults to normal. More than one value → 400. The push service must not forward it to the device. |
Topic | No | Up to 32 characters of the base64url alphabet | RFC 8030 §5.4 | Replaces an undelivered message with the same topic. Invalid → 400. |
Content-Encoding | With a body | aes128gcm | RFC 8291 §4 | Exactly one value. No compression: "content encodings that compress could result in leaking of push message contents." |
Content-Type | No | application/octet-stream is conventional | — | Push services ignore it. The browser does not expose it. |
Authorization | Effectively yes | vapid t=<JWT>, k=<public key> | RFC 8292 §3 | Required by every push service for restricted subscriptions, and Chrome and Safari only create restricted ones. |
Prefer | No | respond-async | RFC 8030 §5.1 | Requests delivery receipts. Mostly unsupported (see below). |
Nothing else is needed. Headers you add are not delivered to the browser: the push service forwards only the body, and RFC 8291 explicitly warns user agents to treat any HTTP header fields they do see as coming from the push service, not from you.
TTL: how long the push service holds your message¶
TTL is the number of seconds the push service may keep trying to deliver the message while the device is offline, asleep or out of coverage. When the TTL elapses, "the push service MUST NOT attempt to deliver the push message". The value you choose is a statement about how long the message stays meaningful:
| Message type | Sensible TTL | Why |
|---|---|---|
| Incoming call, OTP code, "your ride is here" | 0–60 | Useless after a minute. TTL: 0 means deliver now or never. |
| Chat message | 86400 (1 day) to a few days | The user still wants it when they come back online, but consider collapsing with Topic. |
| Calendar reminder for a 14:00 meeting | Seconds until 14:00 | A late reminder is worse than no reminder. |
| Weekly digest | 604800 (7 days) | Low urgency, long shelf life. |
Details that matter:
TTL: 0has special semantics. The message "is immediately delivered if the user agent is available to receive the message" and otherwise "expires and is never delivered". The push service may also delete a zero-TTL message before the browser acknowledges it, so you will never get a receipt for one.- Push services cap the TTL. RFC 8030 lets a push service keep a message for less time than you asked for. If it does, it reports the TTL it actually applied in a
TTLresponse header. Mozilla autopush silently caps values above2592000seconds (30 days); its source code comments "Enforce a maximum TTL, but don't error". It rejects negative or unparseable values with400. FCM documents a maximum lifespan of2419200seconds (28 days). Apple says it "may store the notification for 30 days or fewer, depending on the value you specify". Clamp your values and read the response header if you care. - Stored messages are limited in number, not just time. Apple notes that "the number of notifications the push services stores while the device is offline is limited". Do not use a long TTL to build a message queue. Use
Topicto collapse, and have the app fetch missed state from your API when it opens. - Parse safety. The RFC says a recipient that receives a TTL larger than it can represent must treat it as 2^31 (2147483648). Send an integer string with no sign, decimals or whitespace.
Urgency: delivery priority and battery¶
Urgency lets the device and push service defer low-value messages until delivery is cheap. RFC 8030 gives this table (illustrative, not normative):
| Urgency | Device state in which the message is delivered | Example use |
|---|---|---|
very-low | On power and Wi-Fi | Advertisements (per the RFC) |
low | On power or Wi-Fi | Topic updates, news |
normal (default) | On neither power nor Wi-Fi | Chat or calendar message |
high | Even on low battery | Incoming call, time-sensitive alert |
The protocol also lets the user agent tell the push service the lowest urgency it wants to receive while it is monitoring for messages, for example only high while the battery is critical. That filtering happens between the browser and the push service, invisible to you, which is why a very-low message can sit on the push service for hours. Push services map Urgency onto their own delivery priority schemes. Apple documents that you should "specify high" to "attempt to deliver the notification immediately". Do not mark everything high. Platforms that throttle high-priority traffic look at how often your high-priority messages lead to user-visible notifications.
Topic: replacing messages that have not been delivered yet¶
A Topic header gives a stored message an identity. When a new message with the same topic arrives for the same subscription before the first one was delivered, the push service deletes the old message and keeps the new one, including its TTL, urgency and receipt subscription:
POST /wpush/v2/gAAAAAB... HTTP/1.1
Host: updates.push.services.mozilla.com
TTL: 86400
Topic: inbox_count
Content-Encoding: aes128gcm
Authorization: vapid t=..., k=...
<encrypted {"unread": 7}>
If the device was offline while you sent "3 unread", "5 unread" and "7 unread" with Topic: inbox_count, it receives one message ("7 unread") when it reconnects. Things to know:
- Syntax. RFC 8030 allows at most 32 characters from the URL- and filename-safe base64 alphabet (
A–Z,a–z,0–9,-,_), and push services must reject anything else with400. Mozilla's autopush validates exactly that (its error page still describes the older, alphanumeric-only rule under errno 113). Hash longer identifiers into the alphabet:createHash("sha256").update(id).digest("base64url").slice(0, 32). - Only undelivered messages are replaced. Once a message reaches the device, a later message with the same topic is a new message. To replace the notification the user sees, use the notification
tagin your service worker (see Notifications API). Topic and tag solve the same problem at two different layers, so use both. - Topics are per subscription. They do not fan out. There is no "send to topic" in Web Push, unlike FCM's native topic messaging.
Push message receipts (and why you should not rely on them)¶
RFC 8030 §5.1 lets you ask for a delivery receipt with Prefer: respond-async. The push service then answers 202 Accepted with a Link: <...>; rel="urn:ietf:params:push:receipt" header, and you monitor the receipt subscription (over HTTP/2 server push) for a 204 (delivered and acknowledged) or 410 (delivery failed). In practice the ecosystem never adopted this. Mozilla's documentation says autopush "cannot support the Push Message Receipt at this time, so Autopush should only return a 201 response". If you need delivery confirmation, have the service worker fetch() an acknowledgement endpoint from its push handler, or better, record when the user interacts in notificationclick.
Payload-less pushes¶
You can send a push with no body at all. Omit Content-Encoding and the body, and keep TTL and Authorization. The service worker receives a push event whose event.data is null, and has to fetch whatever it needs to show a notification. That avoids encryption cost and the 3993-byte limit, at the price of a network round trip on the device while the push event is running, and a failure mode when that fetch fails on a flaky connection. It is still useful for "sync now" signals. Remember that Chrome, Safari and Edge require every push to result in a visible notification (see Push Notifications). Apple's documentation says the Content-Encoding header may be omitted "if the payload is empty".
Response codes and what to do about them¶
A 201 Created means the push service accepted the message into storage. It says nothing about delivery to the device, which might be off for a week and let the TTL expire. Every other code needs a specific reaction. Getting this table right keeps your subscription database clean and keeps you on good terms with the push services.
| Status | Meaning | Typical causes | What your sender should do |
|---|---|---|---|
201 Created | Accepted. Location points to the push message resource. | — | Record success (last_success_at), reset the failure counter. |
202 Accepted | Accepted with a receipt subscription (Prefer: respond-async). | Only if you asked for receipts. | Same as 201. |
400 Bad Request | Malformed request. | Missing TTL, invalid Urgency/Topic, bad encryption header, same key used for ECDH and VAPID. | Do not retry. Log the body and fix the sender. |
401 Unauthorized | Authentication missing. | No Authorization header on a restricted subscription (RFC 8292 §4.2). | Do not retry. Configuration bug. |
403 Forbidden | Authentication invalid. | JWT signed with the wrong key, aud ≠ endpoint origin, exp more than 24 h away or already past, VAPID key ≠ key used to subscribe. | Do not retry. If only some subscriptions return 403, they were created with a different VAPID key (see key rotation below). |
404 Not Found | The subscription no longer exists or never did. | Expired or invalid subscription. | Delete the subscription. |
410 Gone | The subscription was valid but is now permanently unusable. | User unsubscribed, revoked permission, cleared site data, uninstalled the app. | Delete the subscription. |
413 Payload Too Large | Body exceeds the push service limit. | Plaintext over 3993 bytes, or padding too generous. | Do not retry. Shrink the payload and send IDs instead of content. |
429 Too Many Requests | Rate limited. | Too many messages to one device, or overall. | Retry after the Retry-After delay, with backoff. Slow down that push service. |
500, 502, 503 | Push service error or maintenance. | Transient. | Retry with exponential backoff and jitter, honoring Retry-After if present. |
404 and 410 are the two codes you will see most often in a healthy system. Every uninstalled PWA, cleared browser profile and revoked permission eventually shows up as one of them. A sender that does not prune on them wastes a growing share of its requests on dead subscriptions.
Apple push service reason codes¶
Apple returns a JSON body with a reason key on errors, plus an apns-id response header that uniquely identifies your request. It helps to log both.
| HTTP | reason | Meaning (from Apple's documentation) |
|---|---|---|
| 400 | BadTtl | The TTL header is missing or isn't a positive number. (Apple's wording says "positive", so test TTL: 0 against Apple before you depend on it.) |
| 400 | BadUrgency | Urgency is present but isn't very-low, low, normal or high. |
| 400 | BadWebPushRequest | The request doesn't conform to the encryption rules. |
| 400 | BadWebPushTopic | Topic is present but doesn't conform to the specification. |
| 403 | BadJwtToken | JWT missing, signed with the wrong key, sub not a URL or mailto:, aud not the push service origin, or exp more than one day in the future. |
| 403 | BadVapidPublicKey | k missing, not base64url, or not the right key type. |
| 403 | BadAuthorizationHeader | The Authorization header doesn't conform to the specification. |
| 400 | IdleTimeout | The connection timed out. |
| 403 | VapidPkHashMismatch | The VAPID key in the request doesn't match the one passed to PushManager.subscribe(). |
| 404 | BadPath | Invalid :path. |
| 405 | MethodNotAllowed | Only POST is allowed. |
| 410 | — | The device token has expired. |
| 413 | PayloadTooLarge | The payload is over the limit of 4 KB. |
| 429 | TooManyRequests | Too many consecutive requests to the same device token. |
| 500 / 503 | InternalServerError, ServiceUnavailable, Shutdown | Server-side problem. Retry later. |
Apple also documents connection-level rules: it supports HTTP/1.1 and HTTP/2, requires TLS with SNI, allows HTTP/1.1 pipelining but asks you not to send "more than 100 unacknowledged push requests over the connection", and for HTTP/2 tells you not to exceed the peer's SETTINGS_MAX_CONCURRENT_STREAMS.
Mozilla autopush error numbers¶
Autopush returns a JSON body: {"code": 404, "errno": 102, "error": "Not Found", "message": "..."}. The errno is stable and more specific than the status:
| HTTP | errno | Meaning |
|---|---|---|
| 400 | 101 | Missing necessary crypto keys |
| 400 | 110 | Invalid crypto keys specified |
| 400 | 111 | Missing required header (for example TTL or crypto headers) |
| 400 | 108 | Router type is invalid |
| 400 | 112 | Invalid TTL header value (for example negative). Values above 2592000 are capped, not rejected. |
| 400 | 113 | Invalid Topic header value |
| 401 | 109 | Invalid authentication (VAPID) |
| 404 | 102 | Invalid URL endpoint: do not use again |
| 410 | 103, 105, 106 | Expired endpoint, endpoint became unavailable during the request, invalid subscription |
| 413 | 104 | Data payload too large |
| 429 | 201 | Rate limited by an upstream bridge. Use exponential backoff and honor Retry-After. |
| 503 | 201 / 202 | Service unavailable: back off (201) or immediate retry OK (202) |
| 502 | 900–903 | Bridge problems (misconfiguration, bridge authentication, connection error, timeout). Retry with backoff. |
| 500 | 999 | Unknown error |
When a push fails and you cannot tell why, try the same subscription flow in Firefox. The team behind Chrome's push documentation recommends exactly this, because autopush's messages are far more descriptive than FCM's.
FCM-specific behavior¶
Chrome's push service returns terse errors. For VAPID problems (missing Authorization, a key that does not match the subscription, an expired or over-long exp, a malformed JWT) the Chrome team documents that FCM answers with an UnauthorizedRegistration error page (the documented example carries status 400, not 403), so do not rely on the status code alone to separate authentication problems from other malformed requests. Treat any 4xx from fcm.googleapis.com other than 404/410/413/429 as a configuration error, and decode your own JWT to check aud, exp and the key before you suspect FCM.
VAPID: identifying your application server¶
VAPID (RFC 8292) solves two problems. First, it gives the push service a stable identity for the sender, so it can contact you (sub) or throttle you instead of the whole internet. Second, it lets the browser restrict a subscription to one sender. When a page calls subscribe({ applicationServerKey }), the browser passes that public key to the push service, which then rejects any message not signed by the matching private key. A leaked endpoint alone is then useless to an attacker.
Generating a VAPID key pair¶
A VAPID key is an ordinary ECDSA P-256 key pair. The ecosystem uses two raw encodings rather than PEM:
- Public key: the 65-byte uncompressed point
0x04 || X || Y, base64url-encoded (87 characters). You pass this tosubscribe()asapplicationServerKeyand send it in thek=parameter. - Private key: the 32-byte scalar
d, base64url-encoded (43 characters).
import { generateVapidKeys } from "../lib/vapid.js";
const { publicKey, privateKey } = generateVapidKeys();
// Store the private key in your secret manager, never in the client bundle or git.
console.log(`VAPID_PUBLIC_KEY=${publicKey}`);
console.log(`VAPID_PRIVATE_KEY=${privateKey}`);
Generate VAPID keys once per application (or per environment), not per user or per deploy. Every subscription is bound to the public key that was current when it was created.
The JWT: header, claims and signature¶
The token is a compact JWS. The header is always {"typ":"JWT","alg":"ES256"}. The claims are:
| Claim | Required | Value | Rules |
|---|---|---|---|
aud | Yes | The origin of the endpoint, e.g. https://fcm.googleapis.com | "MUST include the Unicode serialization of the origin … of the push resource URL". Scheme and host only, no path and no trailing slash. One token is reusable for every endpoint on that origin. |
exp | Yes | Unix time in seconds | "MUST NOT be more than 24 hours from the time of the request." Apple rejects anything more than one day ahead with BadJwtToken. |
sub | Recommended (required in practice) | mailto:[email protected] or https://example.com/contact | The RFC says MAY/SHOULD. Apple rejects tokens whose sub isn't a URL or mailto:, and Mozilla requires sub when a VAPID key is present. Always send it. |
Two implementation traps catch almost everyone who writes this by hand:
- The signature format. JWS ES256 signatures are the fixed-length 64-byte concatenation
R || S(RFC 7518 §3.4). OpenSSL, and therefore Node'scrypto.sign(), produces ASN.1 DER by default (70–72 bytes). PassdsaEncoding: "ieee-p1363"or every push service will reject the token. expin milliseconds.Date.now()is milliseconds. A JWTexpofDate.now() + 43200lies thousands of years in the future and fails the 24-hour check. Divide by 1000.
Also keep the clock skew budget in mind. A token that expires in exactly 24 hours can look like 24 hours plus a few seconds to a push service whose clock runs behind yours. Use 12 hours, which is also what the web-push library defaults to.
The Authorization header¶
RFC 8292 defines the vapid HTTP authentication scheme with two parameters:
Authorization: vapid t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA, k=BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs
t is the JWT and k is the base64url public key that verifies it. The push service verifies the signature with k, then compares k with the key stored for the subscription. RFC 8292 lists exactly when "vapid" authentication is invalid: token or key missing, bad signature, the current time is past exp or more than 24 hours before it, the endpoint origin is missing from aud, or the key does not match the one used at subscription time.
Legacy: WebPush scheme and Crypto-Key: p256ecdsa=
Before RFC 8292 was finalized, drafts used Authorization: WebPush <JWT> together with a Crypto-Key: p256ecdsa=<public key> header, and the old aesgcm payload encoding put its ECDH key in the same header (Crypto-Key: dh=...; p256ecdsa=...) plus an Encryption: salt=... header. Much older tutorials, including the original Google Web Fundamentals material, still show that form. Current push services accept the RFC form, which is simpler. Use it for every new implementation.
Token reuse and caching¶
RFC 8292 encourages reuse: "Application servers are encouraged to reuse tokens, which permits the push service to cache the results of signature validation." Apple is more direct: "Don't refresh your JWT more frequently than once per hour." Signing a fresh ES256 token for every one of a million requests wastes CPU on your side and theirs. Cache one token per aud origin, which in practice means one token per push service, and rotate it well before exp.
Implementation: VAPID signing with node:crypto¶
This module has no dependencies. It generates keys, rebuilds a KeyObject from the raw encodings, and signs and caches one token per push service origin.
// RFC 8292 VAPID key generation and JWT signing with node:crypto only.
import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto";
const b64url = (buf) => Buffer.from(buf).toString("base64url");
const fromB64url = (str) => Buffer.from(str, "base64url");
/**
* Generate a P-256 key pair in the encodings the Web Push ecosystem uses:
* - publicKey: 65-byte uncompressed point (0x04 || X || Y), base64url.
* This is the applicationServerKey you pass to subscribe().
* - privateKey: 32-byte scalar "d", base64url. Keep it secret.
*/
export function generateVapidKeys() {
const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
const pub = publicKey.export({ format: "jwk" });
const priv = privateKey.export({ format: "jwk" });
const raw = Buffer.concat([Buffer.from([0x04]), fromB64url(pub.x), fromB64url(pub.y)]);
return { publicKey: b64url(raw), privateKey: priv.d };
}
/** Rebuild a KeyObject from the raw base64url encodings. */
function importVapidPrivateKey(publicKeyB64, privateKeyB64) {
const pub = fromB64url(publicKeyB64);
const d = fromB64url(privateKeyB64);
if (pub.length !== 65 || pub[0] !== 0x04) {
throw new TypeError("VAPID public key must be a 65-byte uncompressed P-256 point");
}
if (d.length !== 32) {
throw new TypeError("VAPID private key must be a 32-byte P-256 scalar");
}
// createPrivateKey validates that the point is on the curve and matches d.
return createPrivateKey({
format: "jwk",
key: {
kty: "EC",
crv: "P-256",
x: b64url(pub.subarray(1, 33)),
y: b64url(pub.subarray(33, 65)),
d: b64url(d),
},
});
}
export class VapidSigner {
/**
* @param {object} opts
* @param {string} opts.publicKey base64url, 65-byte uncompressed point
* @param {string} opts.privateKey base64url, 32-byte scalar
* @param {string} opts.subject "mailto:..." or "https://..." contact URI
* @param {number} [opts.ttlSeconds=43200] token lifetime; must be <= 86400
*/
constructor({ publicKey, privateKey, subject, ttlSeconds = 12 * 60 * 60 }) {
if (!/^(mailto:|https:)/.test(subject)) {
// Apple rejects tokens whose sub is not a mailto: or https: URI (BadJwtToken).
throw new TypeError("VAPID subject must be a mailto: or https: URI");
}
if (ttlSeconds > 24 * 60 * 60) {
throw new RangeError("RFC 8292: exp must be no more than 24 hours in the future");
}
this.publicKey = publicKey;
this.subject = subject;
this.ttlSeconds = ttlSeconds;
this.key = importVapidPrivateKey(publicKey, privateKey);
this.cache = new Map(); // audience -> { header, exp }
}
/** Build (or reuse) the Authorization header value for an endpoint. */
authorizationFor(endpoint, now = Math.floor(Date.now() / 1000)) {
const audience = new URL(endpoint).origin; // aud = origin of the push resource
const cached = this.cache.get(audience);
// Reuse a token until it has less than an hour left: push services can
// cache signature verification, and Apple asks you not to refresh more
// often than once per hour.
if (cached && cached.exp - now > 60 * 60) return cached.header;
const exp = now + this.ttlSeconds;
const header = b64url(JSON.stringify({ typ: "JWT", alg: "ES256" }));
const claims = b64url(JSON.stringify({ aud: audience, exp, sub: this.subject }));
const signingInput = `${header}.${claims}`;
// JWS ES256 signatures are the raw 64-byte R || S concatenation, not DER.
const signature = sign("sha256", Buffer.from(signingInput), {
key: this.key,
dsaEncoding: "ieee-p1363",
});
const value = `vapid t=${signingInput}.${b64url(signature)}, k=${this.publicKey}`;
this.cache.set(audience, { header: value, exp });
return value;
}
}
Never reuse the VAPID key for payload encryption
RFC 8292 §3.2 says an application server "MUST select a different private key for the key exchange … and signing the authentication token", and push services "SHOULD reject push messages that have identical values" with 400. The encryption code below generates a fresh ephemeral ECDH key for every message, so the two can never coincide.
Payload encryption: RFC 8291 on top of aes128gcm¶
Every push payload is encrypted end to end, from your server to the browser. The push service sees only ciphertext plus metadata (size, timing, which server talks to which subscription). Encryption also authenticates: a message only decrypts if it was produced with the subscription's auth secret, so someone who learns only the endpoint cannot inject content.
The inputs¶
| Name in RFC 8291 | Where it comes from | Size |
|---|---|---|
ua_public | subscription.keys.p256dh | 65 bytes |
auth_secret | subscription.keys.auth | 16 bytes |
as_private, as_public | A fresh ECDH P-256 key pair you generate for this message, then discard | 32 / 65 bytes |
salt | 16 random bytes you generate for this message | 16 bytes |
plaintext | Your payload (usually UTF-8 JSON) | 0–3993 bytes |
Key derivation step by step¶
The derivation is two chained HKDF-SHA-256 computations: RFC 8291 turns the ECDH secret into input keying material (IKM), and RFC 8188 turns the IKM plus salt into a content encryption key (CEK) and nonce.
flowchart TD
A["ECDH(as_private, ua_public)"] --> S[ecdh_secret 32 bytes]
S --> E1["HKDF-Extract salt=auth_secret"]
AU[auth_secret 16 bytes] --> E1
E1 --> P1[PRK_key]
P1 --> X1["HKDF-Expand info='WebPush: info' 0x00 ua_public as_public, L=32"]
X1 --> IKM[IKM 32 bytes]
IKM --> E2["HKDF-Extract salt=random 16 bytes"]
E2 --> PRK[PRK]
PRK --> X2["HKDF-Expand info='Content-Encoding: aes128gcm' 0x00, L=16"]
PRK --> X3["HKDF-Expand info='Content-Encoding: nonce' 0x00, L=12"]
X2 --> CEK[CEK]
X3 --> N[NONCE]
CEK --> G["AES-128-GCM(plaintext 0x02 padding)"]
N --> G In pseudocode, exactly as RFC 8291 §3.4 writes it:
ecdh_secret = ECDH(as_private, ua_public)
PRK_key = HMAC-SHA-256(auth_secret, ecdh_secret) # HKDF-Extract
key_info = "WebPush: info" || 0x00 || ua_public || as_public
IKM = HMAC-SHA-256(PRK_key, key_info || 0x01) # HKDF-Expand, L=32
PRK = HMAC-SHA-256(salt, IKM) # HKDF-Extract
cek_info = "Content-Encoding: aes128gcm" || 0x00
CEK = HMAC-SHA-256(PRK, cek_info || 0x01)[0..15] # HKDF-Expand, L=16
nonce_info = "Content-Encoding: nonce" || 0x00
NONCE = HMAC-SHA-256(PRK, nonce_info || 0x01)[0..11] # HKDF-Expand, L=12
Every output fits in a single SHA-256 block, so each HKDF-Expand is one HMAC call with the counter byte 0x01 appended. RFC 8188 derives the per-record nonce as NONCE XOR SEQ, but a push message is always a single record with sequence number zero, so the nonce is used unchanged. Note the asymmetry in the info strings: "WebPush: info" is followed by a zero byte and then binary keys, while the two Content-Encoding: strings end with the zero byte.
The aes128gcm body layout¶
The request body is a header followed by exactly one encrypted record:
| Offset | Length | Field | Value in Web Push |
|---|---|---|---|
| 0 | 16 | salt | Random per message |
| 16 | 4 | rs | Record size, unsigned 32-bit big-endian. Usually 4096. "Values smaller than 18 are invalid." |
| 20 | 1 | idlen | 65 (0x41) |
| 21 | 65 | keyid | as_public, the sender's ephemeral public key (uncompressed, starts with 0x04) |
| 86 | n + 1 + p + 16 | record | AES-128-GCM(plaintext || 0x02 || 0x00 × p) followed by the 16-byte tag |
The plaintext of the record is your data, then a padding delimiter byte, then zero or more zero bytes of padding. RFC 8188 uses 0x02 to mark the last record and 0x01 for any other record. Since Web Push has exactly one record, the delimiter is always 0x02, and RFC 8291 requires the browser to discard the message if it is anything else.
Record size, padding and the 3993-byte limit¶
RFC 8030 only guarantees that push services accept bodies up to 4096 bytes ("Push services MUST NOT return a 413 status code in responses to an entity body that is 4096 bytes or less"). Subtract the overhead:
4096 maximum body a push service must accept
- 86 aes128gcm header (16 salt + 4 rs + 1 idlen + 65 keyid)
- 16 AES-GCM authentication tag
- 1 padding delimiter (0x02)
= 3993 maximum plaintext bytes
That is the figure RFC 8291 gives: "at most, 3993 octets of plaintext". Measure it in bytes after UTF-8 encoding, not in JavaScript string length. Two further rules apply:
rsmust be larger than the record: RFC 8291 requires it to exceed "the sum of the lengths of the plaintext, the padding delimiter (1 octet), any padding, and the authentication tag (16 octets)". Settingrs = 4096always satisfies that for a body that fits in 4096 bytes.- Padding hides length. Ciphertext length equals plaintext length plus a constant, so a push service or network observer can tell "Your code is 123456" from "New comment on your post" by size alone. RFC 8291's security considerations call this out. Padding every message up to a bucket size (256, 1024, …) removes that signal at the cost of bandwidth. Padding bytes count toward the 4096-byte limit.
Some push services have tighter internal limits. Mozilla notes that bridged delivery through FCM to Firefox for Android base64-encodes the data, which "means that data may be limited to only 2744 bytes instead of the normal 4096 bytes". Autopush's error table separately describes the maximum as "4028 characters". If Firefox on Android matters to you, keep payloads comfortably under 2 KB. That is good advice anyway: send identifiers and a short preview, and fetch the rest when the user opens the app.
Implementation: aes128gcm encryption with node:crypto¶
The following module is byte-for-byte compatible with RFC 8291. The test after it reproduces the RFC's Appendix A vector exactly.
// RFC 8291 message encryption using the RFC 8188 aes128gcm content coding.
import { createCipheriv, createECDH, createHmac, randomBytes } from "node:crypto";
const hmac = (key, data) => createHmac("sha256", key).update(data).digest();
// HKDF (RFC 5869) with a single output block is all Web Push needs: every
// derived value is <= 32 bytes, so T(1) = HMAC(PRK, info || 0x01) suffices.
const hkdfExtract = (salt, ikm) => hmac(salt, ikm);
const hkdfExpand = (prk, info, length) =>
hmac(prk, Buffer.concat([info, Buffer.from([0x01])])).subarray(0, length);
const TAG_LENGTH = 16;
const HEADER_LENGTH = 16 + 4 + 1 + 65; // salt + rs + idlen + keyid = 86
/**
* Encrypt a payload for one push subscription.
*
* @param {Buffer|string} plaintext
* @param {{ p256dh: string, auth: string }} keys from PushSubscription.toJSON().keys
* @param {object} [opts]
* @param {number} [opts.padTo] pad the plaintext up to this many bytes (hides length)
* @param {Buffer} [opts.salt] 16 random bytes; only inject for test vectors
* @param {Buffer} [opts.asPrivateKey] ephemeral private key; only inject for test vectors
* @param {number} [opts.recordSize=4096]
* @returns {Buffer} the complete request body (header + single record)
*/
export function encryptPayload(plaintext, keys, opts = {}) {
const data = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext, "utf8");
const uaPublic = Buffer.from(keys.p256dh, "base64url");
const authSecret = Buffer.from(keys.auth, "base64url");
if (uaPublic.length !== 65 || uaPublic[0] !== 0x04) {
throw new TypeError("p256dh must be a 65-byte uncompressed P-256 point");
}
if (authSecret.length !== 16) {
throw new TypeError("auth secret must be 16 bytes");
}
const salt = opts.salt ?? randomBytes(16);
const recordSize = opts.recordSize ?? 4096;
// 1. Ephemeral application-server key pair: a fresh one for every message.
const ecdh = createECDH("prime256v1");
if (opts.asPrivateKey) ecdh.setPrivateKey(opts.asPrivateKey);
else ecdh.generateKeys();
const asPublic = ecdh.getPublicKey(); // 65 bytes, uncompressed
// 2. ECDH shared secret. computeSecret() throws ERR_CRYPTO_ECDH_INVALID_PUBLIC_KEY
// if ua_public is not a valid point on P-256 (RFC 8291 section 7 requires this check).
const ecdhSecret = ecdh.computeSecret(uaPublic);
// 3. RFC 8291 section 3.3: mix in the auth secret and both public keys.
const keyInfo = Buffer.concat([Buffer.from("WebPush: info\0", "latin1"), uaPublic, asPublic]);
const prkKey = hkdfExtract(authSecret, ecdhSecret);
const ikm = hkdfExpand(prkKey, keyInfo, 32);
// 4. RFC 8188 sections 2.2 and 2.3: derive the content-encryption key and nonce.
const prk = hkdfExtract(salt, ikm);
const cek = hkdfExpand(prk, Buffer.from("Content-Encoding: aes128gcm\0", "latin1"), 16);
const nonce = hkdfExpand(prk, Buffer.from("Content-Encoding: nonce\0", "latin1"), 12);
// 5. Single record: plaintext || 0x02 (last-record delimiter) || zero padding.
const padTo = Math.max(opts.padTo ?? 0, data.length);
const paddingLength = padTo - data.length;
const recordPlain = Buffer.concat([data, Buffer.from([0x02]), Buffer.alloc(paddingLength)]);
if (recordPlain.length + TAG_LENGTH > recordSize) {
throw new RangeError(`record (${recordPlain.length + TAG_LENGTH} bytes) exceeds rs=${recordSize}`);
}
if (HEADER_LENGTH + recordPlain.length + TAG_LENGTH > 4096) {
// Push services only have to accept 4096-byte bodies (RFC 8030 section 7.2).
throw new RangeError("encrypted body would exceed 4096 bytes; max plaintext is 3993 bytes");
}
// 6. AES-128-GCM; SEQ is 0 for the only record, so the nonce is used as-is.
const cipher = createCipheriv("aes-128-gcm", cek, nonce);
const ciphertext = Buffer.concat([cipher.update(recordPlain), cipher.final(), cipher.getAuthTag()]);
// 7. aes128gcm header: salt(16) | rs(uint32 BE) | idlen(1) | keyid(as_public).
const header = Buffer.alloc(HEADER_LENGTH);
salt.copy(header, 0);
header.writeUInt32BE(recordSize, 16);
header.writeUInt8(asPublic.length, 20);
asPublic.copy(header, 21);
return Buffer.concat([header, ciphertext]);
}
Node's built-in crypto.hkdfSync() would work too, but it runs Extract and Expand together. Writing the two HMAC steps out keeps the code aligned with the RFC's pseudocode and makes intermediate values easy to compare when you debug.
Verifying against the RFC 8291 test vector¶
Hand-written crypto is only trustworthy once it matches a published test vector. RFC 8291 Appendix A fixes every random input (as_private, salt) so the output is deterministic. This test passes with the module above:
import assert from "node:assert/strict";
import { test } from "node:test";
import { encryptPayload } from "../lib/encrypt.js";
import { decryptPayload } from "../lib/decrypt.js";
const b = (s) => Buffer.from(s, "base64url");
// All values from RFC 8291 Appendix A.
const vector = {
plaintext: "V2hlbiBJIGdyb3cgdXAsIEkgd2FudCB0byBiZSBhIHdhdGVybWVsb24",
asPrivate: "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw",
uaPublic: "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4",
uaPrivate: "q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94",
auth: "BTBZMqHH6r4Tts7J_aSIgg",
salt: "DGv6ra1nlYgDCS1FRnbzlw",
body:
"DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN",
};
test("encryptPayload reproduces RFC 8291 Appendix A", () => {
const body = encryptPayload(b(vector.plaintext), { p256dh: vector.uaPublic, auth: vector.auth }, {
salt: b(vector.salt),
asPrivateKey: b(vector.asPrivate),
});
assert.equal(body.toString("base64url"), vector.body);
});
test("decryptPayload round-trips the vector", () => {
const plain = decryptPayload(b(vector.body), b(vector.uaPrivate), b(vector.auth));
assert.equal(plain.toString("utf8"), "When I grow up, I want to be a watermelon");
});
Run it with node --test. Decoding the expected body by hand is instructive: AAAQAEE after the salt is 00 00 10 00 41, meaning rs = 4096 and idlen = 65. It is followed by BP4z9KsN…, which is as_public from the vector.
Implementation: decryption for tests and debugging¶
Your server never needs to decrypt, but a decryptor lets you unit-test the whole pipeline and inspect what a real browser would see. It also documents the checks a receiver performs:
// The user-agent side of RFC 8291, useful for tests and debugging.
import { createDecipheriv, createECDH, createHmac } from "node:crypto";
const hmac = (key, data) => createHmac("sha256", key).update(data).digest();
const expand = (prk, info, len) => hmac(prk, Buffer.concat([info, Buffer.from([1])])).subarray(0, len);
/**
* @param {Buffer} body the aes128gcm request body
* @param {Buffer} uaPrivateKey 32-byte subscription private key
* @param {Buffer} authSecret 16-byte auth secret
*/
export function decryptPayload(body, uaPrivateKey, authSecret) {
const salt = body.subarray(0, 16);
const rs = body.readUInt32BE(16);
const idlen = body.readUInt8(20);
const asPublic = body.subarray(21, 21 + idlen);
const record = body.subarray(21 + idlen);
if (idlen !== 65) throw new Error("keyid must be the 65-byte sender public key");
if (rs < 18) throw new Error("rs values smaller than 18 are invalid");
if (record.length > rs) throw new Error("Web Push messages must be a single record");
const ecdh = createECDH("prime256v1");
ecdh.setPrivateKey(uaPrivateKey);
const uaPublic = ecdh.getPublicKey();
const ecdhSecret = ecdh.computeSecret(asPublic);
const keyInfo = Buffer.concat([Buffer.from("WebPush: info\0", "latin1"), uaPublic, asPublic]);
const ikm = expand(hmac(authSecret, ecdhSecret), keyInfo, 32);
const prk = hmac(salt, ikm);
const cek = expand(prk, Buffer.from("Content-Encoding: aes128gcm\0", "latin1"), 16);
const nonce = expand(prk, Buffer.from("Content-Encoding: nonce\0", "latin1"), 12);
const decipher = createDecipheriv("aes-128-gcm", cek, nonce);
decipher.setAuthTag(record.subarray(record.length - 16));
const plain = Buffer.concat([decipher.update(record.subarray(0, record.length - 16)), decipher.final()]);
// Strip padding: scan back over zeros to the delimiter, which must be 0x02.
let end = plain.length - 1;
while (end >= 0 && plain[end] === 0x00) end--;
if (end < 0 || plain[end] !== 0x02) throw new Error("invalid padding delimiter");
return plain.subarray(0, end);
}
The legacy aesgcm encoding¶
Before RFC 8188 was published, browsers implemented a draft content coding called aesgcm. It put the salt in an Encryption header and the sender key in Crypto-Key, used different info strings and two-byte padding lengths. PushManager.supportedContentEncodings tells you what a browser can decrypt. Chromium still returns ["aes128gcm", "aesgcm"]. Firefox added the property in Firefox 134, and by default it lists only aes128gcm (a dom.push.indicate_aesgcm_support.enabled preference controls whether aesgcm is advertised, though decryption of aesgcm still works). Every browser that supports push today supports aes128gcm. Send aes128gcm unconditionally and record the encoding per subscription only if you still carry pre-2018 subscriptions.
A complete sender¶
This module performs one request and classifies the response. It uses Node's global fetch (Node 18 and later), so it has no dependencies.
// One RFC 8030 push request, with the response classified for callers.
import { encryptPayload } from "./encrypt.js";
const URGENCIES = new Set(["very-low", "low", "normal", "high"]);
const TOPIC_RE = /^[A-Za-z0-9_-]{1,32}$/; // RFC 8030 section 5.4: <= 32 base64url chars
/** Parse Retry-After (delta-seconds or HTTP-date) into milliseconds. */
export function parseRetryAfter(value, now = Date.now()) {
if (!value) return null;
if (/^\d+$/.test(value.trim())) return Number(value) * 1000;
const date = Date.parse(value);
return Number.isNaN(date) ? null : Math.max(0, date - now);
}
/**
* Send one push message.
*
* @param {{endpoint: string, keys?: {p256dh: string, auth: string}}} subscription
* @param {string|Buffer|null} payload null sends a payload-less "tickle"
* @param {object} opts
* @param {import("./vapid.js").VapidSigner} opts.vapid
* @param {number} [opts.ttl=86400] seconds the push service may hold the message
* @param {"very-low"|"low"|"normal"|"high"} [opts.urgency]
* @param {string} [opts.topic] replaces an undelivered message with the same topic
* @param {number} [opts.padTo] pad plaintext to hide its length
* @param {number} [opts.timeoutMs=10000]
* @param {object} [opts.dispatcher] optional undici dispatcher (connection pooling / HTTP/2)
*/
export async function sendPush(subscription, payload, opts) {
const { vapid, ttl = 86_400, urgency, topic, padTo, timeoutMs = 10_000, dispatcher } = opts;
if (!Number.isInteger(ttl) || ttl < 0) throw new RangeError("TTL must be a non-negative integer");
if (urgency && !URGENCIES.has(urgency)) throw new RangeError(`invalid Urgency: ${urgency}`);
if (topic && !TOPIC_RE.test(topic)) throw new RangeError(`invalid Topic: ${topic}`);
const headers = {
TTL: String(ttl),
Authorization: vapid.authorizationFor(subscription.endpoint),
};
if (urgency) headers.Urgency = urgency;
if (topic) headers.Topic = topic;
let body;
if (payload != null) {
if (!subscription.keys?.p256dh || !subscription.keys?.auth) {
throw new TypeError("a payload requires the subscription's p256dh and auth keys");
}
body = encryptPayload(payload, subscription.keys, { padTo });
headers["Content-Encoding"] = "aes128gcm";
headers["Content-Type"] = "application/octet-stream";
}
let response;
try {
response = await fetch(subscription.endpoint, {
method: "POST",
headers,
body,
redirect: "error", // a push service never redirects; do not follow one to an internal host
signal: AbortSignal.timeout(timeoutMs),
...(dispatcher ? { dispatcher } : {}),
});
} catch (error) {
// DNS failure, reset connection, TLS error or timeout: always retryable.
return { status: 0, outcome: "retry", retryAfterMs: null, location: null, body: String(error) };
}
const text = await response.text().catch(() => "");
return {
status: response.status,
outcome: classify(response.status),
retryAfterMs: parseRetryAfter(response.headers.get("retry-after")),
location: response.headers.get("location"),
body: text.slice(0, 2_000), // keep error bodies for logs, bounded
};
}
/** Map a push service status code to what the caller must do next. */
export function classify(status) {
if (status === 201 || status === 202) return "delivered"; // accepted by the push service
if (status === 404 || status === 410) return "gone"; // delete the subscription
if (status === 413) return "too-large"; // a bug in your payload builder
if (status === 429) return "retry"; // rate limited: honor Retry-After
if (status >= 500) return "retry"; // push service trouble: back off
if (status === 401 || status === 403) return "auth"; // VAPID problem or key mismatch
return "invalid"; // 400 and anything unexpected: do not retry blindly
}
Usage for a single message:
import { VapidSigner } from "../lib/vapid.js";
import { sendPush } from "../lib/send.js";
const vapid = new VapidSigner({
publicKey: process.env.VAPID_PUBLIC_KEY,
privateKey: process.env.VAPID_PRIVATE_KEY,
subject: "mailto:[email protected]",
});
const subscription = JSON.parse(process.argv[2]); // PushSubscription.toJSON() output
const payload = JSON.stringify({
title: "Build finished",
body: "main passed in 4m 12s",
url: "/builds/8841",
tag: "build-8841",
});
const result = await sendPush(subscription, payload, {
vapid,
ttl: 3600,
urgency: "normal",
topic: "build8841",
});
console.log(result.status, result.outcome, result.body);
The payload is your own JSON contract. A common shape carries the notification fields, the URL to open on click, and a tag the service worker passes to showNotification(). The service worker side lives in Push Notifications and Notifications API.
Declarative Web Push payloads (Safari 18.4+)
Safari on iOS/iPadOS 18.4 and macOS (Safari 18.5) can show a notification without running a service worker if the decrypted payload is JSON with the magic member "web_push": 8030 and a notification object (title and navigate are required; body, lang, dir, silent, app_badge and others are optional). Transport and encryption are identical: it is still one RFC 8030 POST with an RFC 8291-encrypted body. Only the plaintext format changes, and browsers without declarative support deliver the same JSON to your push handler. See Web Push on iOS & Safari.
Sending at scale¶
One message to one device is easy. Two million personalized messages in a few minutes, without losing any, duplicating any or getting rate limited, is where most push systems struggle.
Architecture: decouple producing from sending¶
flowchart LR
E[Domain event] --> F[Fan-out job]
F --> DB[(Subscriptions)]
F --> Q[[Durable queue]]
Q --> W1[Sender worker]
Q --> W2[Sender worker]
W1 --> FCM[FCM]
W1 --> MOZ[autopush]
W2 --> APN[web.push.apple.com]
W2 --> WNS[WNS]
W1 --> R[Result handler]
W2 --> R
R --> DB - The request path only enqueues. A comment is posted and your API writes one "notify user 42 about comment 9001" job. It never talks to a push service inline, because push services are slow compared with your database (tens to hundreds of milliseconds per request), and a slow push service must not slow your API down.
- Fan-out resolves recipients. A fan-out job expands the event to users, and users to their subscriptions (one per browser/device), and enqueues one send job per subscription or per small batch. Stream through subscriptions with a keyset-paginated query rather than loading them all.
- Sender workers do the crypto and HTTP. Encryption needs an ECDH operation per message, because each subscription has its own keys and every message needs a fresh ephemeral key. A personalized broadcast is therefore CPU-bound on your side as much as network-bound. Scale workers horizontally.
- A result handler updates state. It deletes subscriptions on
404/410, bumpslast_success_at, increments failure counters and dead-letters permanent failures.
Use whatever durable queue you already operate (SQS, Pub/Sub, Kafka, a Postgres table with SELECT … FOR UPDATE SKIP LOCKED, BullMQ). What matters is at-least-once delivery with visibility timeouts, so a crashed worker's jobs are retried.
Concurrency and connection reuse¶
Opening a new TLS connection per message wastes most of your time on handshakes. Keep a pool of persistent connections per push service origin and cap in-flight requests per origin:
- HTTP/2 multiplexing. FCM and Apple support HTTP/2. One connection can carry many concurrent streams, up to the server's
SETTINGS_MAX_CONCURRENT_STREAMS, which Apple explicitly tells you not to exceed. With Node, theundicipackage'sAgentcan negotiate HTTP/2 via ALPN (allowH2: true), and you pass it tofetchas thedispatcheroption. - HTTP/1.1 pipelining. Apple allows it but caps you at 100 unacknowledged requests per connection. Most HTTP clients do not pipeline by default, which is fine.
- Per-origin limits. A burst of 429s from one push service should slow down only that push service. Keep a separate concurrency limit and pause state per origin.
// Shared connection pool with HTTP/2 where the push service offers it.
// npm install undici
import { Agent } from "undici";
export const pushAgent = new Agent({
allowH2: true, // negotiate HTTP/2 via ALPN (FCM and Apple support it)
connections: 8, // sockets per origin
keepAliveTimeout: 60_000, // keep idle sockets for a minute between bursts
connectTimeout: 5_000,
});
Retries, backoff and Retry-After¶
Retry only what can succeed on a retry: network errors, 429 and 5xx. Use exponential backoff with full jitter (a random delay between 0 and the exponential cap) so that thousands of workers that failed at the same moment do not retry at the same moment. Never retry sooner than Retry-After. Mozilla's documentation notes its Retry-After values are deliberately jittered and that "senders should honor the value they were given rather than rounding it". Cap total attempts, and once the message's TTL has passed a retry is pointless anyway.
A retried push can be delivered twice. The push service may have accepted the first request right before the connection dropped. Make duplicates harmless:
- Put a message ID in the payload and have the service worker skip IDs it has already shown (keep a small ring buffer in IndexedDB).
- Use a notification
tagso a duplicate replaces the first notification instead of adding a second one. - Use
Topicso a duplicate that is still queued at the push service collapses there.
Implementation: a bounded-concurrency dispatcher¶
This dispatcher is independent of any particular queue. Feed it subscriptions from a job, and it handles per-origin concurrency, 429 pauses, backoff and pruning callbacks:
// Bounded-concurrency fan-out with retries, backoff and pruning.
import { sendPush } from "./send.js";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** A tiny counting semaphore: at most `limit` holders at once. */
class Semaphore {
#queue = [];
#active = 0;
constructor(limit) {
this.limit = limit;
}
async acquire() {
if (this.#active < this.limit) {
this.#active++;
return;
}
await new Promise((resolve) => this.#queue.push(resolve));
}
release() {
const next = this.#queue.shift();
if (next) next(); // hand the slot straight to the next waiter
else this.#active--;
}
}
export class PushDispatcher {
/**
* @param {object} opts
* @param {import("./vapid.js").VapidSigner} opts.vapid
* @param {number} [opts.perOriginConcurrency=50] in-flight requests per push service
* @param {number} [opts.maxAttempts=5]
* @param {number} [opts.baseDelayMs=1000]
* @param {number} [opts.maxDelayMs=300000]
* @param {(sub: object, result: object) => Promise<void>} opts.onGone delete the subscription
* @param {(sub: object, result: object) => Promise<void>} [opts.onFailure] log / dead-letter
* @param {(sub: object, result: object) => Promise<void>} [opts.onSuccess] update last_success_at
* @param {object} [opts.dispatcher] optional undici Agent shared by all requests
*/
constructor(opts) {
this.opts = { perOriginConcurrency: 50, maxAttempts: 5, baseDelayMs: 1000, maxDelayMs: 300_000, ...opts };
this.semaphores = new Map(); // push service origin -> Semaphore
this.pausedUntil = new Map(); // push service origin -> epoch ms (after a 429)
}
#semaphoreFor(origin) {
let sem = this.semaphores.get(origin);
if (!sem) {
sem = new Semaphore(this.opts.perOriginConcurrency);
this.semaphores.set(origin, sem);
}
return sem;
}
/** Full-jitter exponential backoff, never shorter than Retry-After. */
#backoff(attempt, retryAfterMs) {
const exp = Math.min(this.opts.maxDelayMs, this.opts.baseDelayMs * 2 ** attempt);
const jittered = Math.random() * exp;
return Math.max(jittered, retryAfterMs ?? 0);
}
/** Deliver one message to one subscription, retrying transient failures. */
async deliver(subscription, payload, sendOpts = {}) {
const origin = new URL(subscription.endpoint).origin;
const sem = this.#semaphoreFor(origin);
for (let attempt = 0; attempt < this.opts.maxAttempts; attempt++) {
const pause = (this.pausedUntil.get(origin) ?? 0) - Date.now();
if (pause > 0) await sleep(pause); // a 429 on this origin pauses everyone
await sem.acquire();
let result;
try {
result = await sendPush(subscription, payload, {
vapid: this.opts.vapid,
dispatcher: this.opts.dispatcher,
...sendOpts,
});
} finally {
sem.release();
}
switch (result.outcome) {
case "delivered":
await this.opts.onSuccess?.(subscription, result);
return result;
case "gone":
await this.opts.onGone(subscription, result);
return result;
case "retry": {
const delay = this.#backoff(attempt, result.retryAfterMs);
if (result.status === 429) this.pausedUntil.set(origin, Date.now() + delay);
await sleep(delay);
continue;
}
default: // "auth", "too-large", "invalid": retrying cannot help
await this.opts.onFailure?.(subscription, result);
return result;
}
}
const exhausted = { status: 0, outcome: "exhausted", retryAfterMs: null, location: null, body: "" };
await this.opts.onFailure?.(subscription, exhausted);
return exhausted;
}
/** Fan one message out to many subscriptions; resolves when all settle. */
async broadcast(subscriptions, payloadFor, sendOpts = {}) {
const results = await Promise.allSettled(
subscriptions.map((sub) => this.deliver(sub, payloadFor(sub), sendOpts)),
);
const summary = { delivered: 0, gone: 0, failed: 0 };
for (const r of results) {
if (r.status === "fulfilled" && r.value.outcome === "delivered") summary.delivered++;
else if (r.status === "fulfilled" && r.value.outcome === "gone") summary.gone++;
else summary.failed++;
}
return summary;
}
}
Wiring it to storage:
import { PushDispatcher } from "../lib/dispatcher.js";
import { VapidSigner } from "../lib/vapid.js";
import { pushAgent } from "../lib/agent.js";
import { db } from "../lib/db.js"; // your database client
const vapid = new VapidSigner({
publicKey: process.env.VAPID_PUBLIC_KEY,
privateKey: process.env.VAPID_PRIVATE_KEY,
subject: "mailto:[email protected]",
});
const dispatcher = new PushDispatcher({
vapid,
dispatcher: pushAgent,
perOriginConcurrency: 100,
onGone: (sub) => db.query("DELETE FROM push_subscriptions WHERE id = $1", [sub.id]),
onSuccess: (sub) =>
db.query(
"UPDATE push_subscriptions SET last_success_at = now(), failure_count = 0 WHERE id = $1",
[sub.id],
),
onFailure: (sub, result) =>
db.query(
`UPDATE push_subscriptions
SET failure_count = failure_count + 1, last_failure_status = $2, last_failure_at = now()
WHERE id = $1`,
[sub.id, result.status],
),
});
/** Process one queued job: { userId, message } */
export async function handleJob({ userId, message }) {
const { rows } = await db.query(
"SELECT id, endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = $1",
[userId],
);
const subs = rows.map((r) => ({ id: r.id, endpoint: r.endpoint, keys: { p256dh: r.p256dh, auth: r.auth } }));
return dispatcher.broadcast(subs, () => JSON.stringify(message), {
ttl: message.ttl ?? 86_400,
urgency: message.urgency ?? "normal",
topic: message.topic,
padTo: 512, // hide length differences between message types
});
}
Multiple VAPID keys are handled by looking up the signer by vapid_key_id per subscription (see key rotation below). Keep perOriginConcurrency modest to start (tens to low hundreds) and raise it while you watch 429 rates. None of the push services publish a hard per-sender request rate for the Web Push endpoint, so treat 429s and Retry-After as the source of truth.
Rate limits and good citizenship¶
- Per-device limits exist. Apple's
TooManyRequestsreason is defined as "too many consecutive requests to the same device token". Collapse bursts per user on your side (debounce: "3 new comments" instead of three pushes) before they reach the push service. - Browsers throttle too. Firefox gives each subscription a budget of pushes without a visible notification (at most 16, recomputed from how recently the user visited the site), and after a push, if no notification is showing for that origin after three seconds, the budget shrinks. At zero the subscription is dropped. Chrome shows a generic "This site has been updated in the background." notification when a push leaves nothing visible and the site has no budget left. Safari revokes permission. The details are in Push Notifications.
- Quiet hours and preferences belong on your server. The push protocol has no scheduling, so schedule jobs and let TTL expire stale ones.
Storing subscriptions¶
A subscription row has to support four operations: upsert on subscribe, look up by user, delete on 404/410, and migrate between VAPID keys.
CREATE TABLE push_subscriptions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE, -- NULL for anonymous subscribers
endpoint TEXT NOT NULL,
endpoint_hash BYTEA NOT NULL, -- sha256(endpoint): fixed-size unique key
p256dh TEXT NOT NULL, -- base64url, 65 bytes decoded
auth TEXT NOT NULL, -- base64url, 16 bytes decoded (secret!)
vapid_key_id TEXT NOT NULL, -- which application server key this is bound to
content_encoding TEXT NOT NULL DEFAULT 'aes128gcm',
push_service TEXT NOT NULL, -- origin of endpoint, for per-service metrics
user_agent TEXT, -- for debugging only; never for targeting
expiration_time TIMESTAMPTZ, -- PushSubscription.expirationTime, usually NULL
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_success_at TIMESTAMPTZ,
last_failure_at TIMESTAMPTZ,
last_failure_status INTEGER,
failure_count INTEGER NOT NULL DEFAULT 0,
CONSTRAINT push_subscriptions_endpoint_hash_key UNIQUE (endpoint_hash)
);
CREATE INDEX push_subscriptions_user_idx ON push_subscriptions (user_id);
CREATE INDEX push_subscriptions_vapid_idx ON push_subscriptions (vapid_key_id);
Design notes:
- The endpoint is the natural key. Browsers return the same endpoint from
getSubscription()until it changes, so upsert on it. Hash it for the unique index, because endpoints can be several hundred bytes long. - One user, many subscriptions. A user with a laptop, a phone and an installed PWA has three rows. Never store the subscription on the user row.
- Re-point on login changes. If a different user logs in on the same browser, the upsert moves the endpoint to the new
user_id. Otherwise user A's device keeps receiving user B's notifications. On logout, delete the row and callsubscription.unsubscribe()on the client. authis a secret. Together with the endpoint it lets anyone who can also produce a valid VAPID token deliver content to that browser. Protect the table like a credentials table and consider application-level encryption ofauth.- Prune proactively. Besides deleting on 404/410, expire rows with a high
failure_countand no success in, say, 60 days. Mozilla's bridge documentation, for example, says router records expire "after 60 days without check-in".
The subscribe endpoint validates and upserts:
import { createHash } from "node:crypto";
import { validatePushEndpoint } from "../lib/endpoint-allowlist.js";
import { db } from "../lib/db.js";
const B64URL = /^[A-Za-z0-9_-]+$/;
/** POST /api/push/subscriptions body: { subscription, vapidKeyId } */
export async function upsertSubscription(req, res) {
const { subscription, vapidKeyId } = req.body ?? {};
const check = validatePushEndpoint(subscription?.endpoint);
if (!check.ok) return res.status(400).json({ error: check.reason });
const { p256dh, auth } = subscription.keys ?? {};
if (!B64URL.test(p256dh ?? "") || Buffer.from(p256dh, "base64url").length !== 65) {
return res.status(400).json({ error: "invalid p256dh" });
}
if (!B64URL.test(auth ?? "") || Buffer.from(auth, "base64url").length !== 16) {
return res.status(400).json({ error: "invalid auth" });
}
if (!["current", "previous"].includes(vapidKeyId)) {
return res.status(400).json({ error: "unknown vapid key id" });
}
const endpointHash = createHash("sha256").update(subscription.endpoint).digest();
await db.query(
`INSERT INTO push_subscriptions
(user_id, endpoint, endpoint_hash, p256dh, auth, vapid_key_id, push_service, user_agent, expiration_time)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, to_timestamp($9 / 1000.0))
ON CONFLICT (endpoint_hash) DO UPDATE
SET user_id = EXCLUDED.user_id, p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth,
vapid_key_id = EXCLUDED.vapid_key_id, expiration_time = EXCLUDED.expiration_time,
updated_at = now(), failure_count = 0`,
[
req.user?.id ?? null,
subscription.endpoint,
endpointHash,
p256dh,
auth,
vapidKeyId,
check.url.origin,
req.get("user-agent")?.slice(0, 512) ?? null,
subscription.expirationTime ?? null,
],
);
res.status(204).end();
}
Map vapidKeyId to real key IDs on the server ("current" → "2026-09"). The client only needs to report which public key it subscribed with.
Keeping subscriptions fresh from the client¶
Subscriptions change without telling your server: the browser can rotate them, and users clear data. The robust pattern is to reconcile on every app start: call getSubscription(), and if the result differs from what the server last saw (or is missing while permission is granted), resubscribe and upload. The service worker's pushsubscriptionchange event covers some cases. Firefox fires it when it drops an expired subscription, and Chrome 138 added a narrow case: an origin whose subscription was revoked by a permission change and is then re-granted permission. In that case the event carries no old or new subscription, so reconciling on app start stays necessary. Both flows are in Push Notifications.
Rotating VAPID keys¶
Rotation is where VAPID's security property bites back. A subscription is restricted to the public key given at subscribe() time. RFC 8292: "An application server that needs to replace its signing key needs to request the creation of a new subscription by the user agent that is restricted to the updated key. Application servers need to remember the key that was used when requesting the creation of a subscription." You cannot re-key existing subscriptions from the server side. Each browser has to resubscribe, which requires your code to run in that browser.
stateDiagram-v2
[*] --> OnOldKey: subscribed with key 2025-01
OnOldKey --> OnOldKey: server signs with key 2025-01
OnOldKey --> Migrating: app opens and sees old key
Migrating --> OnNewKey: unsubscribe, subscribe with key 2026-09, upload
OnOldKey --> Dropped: 404 or 410
OnNewKey --> [*]
Dropped --> [*] A safe rotation plan:
- Store the key ID per subscription (the
vapid_key_idcolumn). Without it you cannot tell which key to sign with, and a mixed population returns 403s for the part signed with the wrong key. - Deploy both keys to the sender. Keep a map
keyId → VapidSignerand sign each message with the key its subscription was created with. - Ship the new public key to clients and migrate on app start (code below). Browsers that never open your app again keep their old subscription until it dies or you retire the key.
- Retire the old key once its subscription count is small enough to accept losing, or after a fixed deadline. Delete those rows, and do not leave them failing forever.
For an emergency rotation after a private key leak, the same steps apply. The leaked key can only push to subscriptions bound to it, and without the per-subscription auth secrets and endpoints (which live in your database) it cannot encrypt a payload the browser will accept. Payload-less pushes are still possible for anyone who also holds endpoints. Rotate the database secrets as part of the incident if the table might be exposed.
// Runs on app start in the page. Moves the subscription to the current VAPID key if needed.
const CURRENT_KEY_ID = "current";
const CURRENT_VAPID_KEY = "BH_mxdxTl0bgS44dlf7A7dr7EmQtPuqLFEXsVziA2WqTTxqMTZ43AuBy0wOLV0Vem3kB3mZ2opHBhk0nREX_WLQ";
function base64UrlToBytes(b64url) {
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(b64url.length / 4) * 4, "=");
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}
function sameKey(buffer, bytes) {
if (!buffer) return false;
const a = new Uint8Array(buffer);
return a.length === bytes.length && a.every((v, i) => v === bytes[i]);
}
export async function ensureCurrentPushKey() {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return;
if (Notification.permission !== "granted") return; // never prompt from a migration
const registration = await navigator.serviceWorker.ready;
const existing = await registration.pushManager.getSubscription();
const currentKey = base64UrlToBytes(CURRENT_VAPID_KEY);
if (existing && sameKey(existing.options.applicationServerKey, currentKey)) return;
// Subscribing with a different key while a subscription exists rejects with
// InvalidStateError, so unsubscribe first.
if (existing) await existing.unsubscribe();
const fresh = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: currentKey,
});
const response = await fetch("/api/push/subscriptions", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
subscription: fresh.toJSON(),
vapidKeyId: CURRENT_KEY_ID,
replaces: existing?.endpoint ?? null, // let the server delete the old row
}),
});
if (!response.ok) throw new Error(`subscription upload failed: ${response.status}`);
}
Permission is already granted, so subscribe() does not show a prompt. Browsers differ on whether subscribe() still needs a user gesture in that case: MDN's compatibility data notes a gesture requirement in Firefox 72 and later, and Apple's documentation ties subscription requests to gestures. Test the silent path on every engine you support, and if it rejects, run the migration from the next click on your notification settings control instead.
Security considerations¶
- SSRF via endpoints. Covered above: allowlist hosts, refuse redirects (
redirect: "error"in the sender) and send from an egress-restricted network segment. - Metadata is visible. Encryption hides content, not the fact that your server messaged a given subscription at a given time with a given ciphertext size. Pad sensitive message types, and avoid sending pushes whose mere timing reveals something (for example, one immediately after a specific medical appointment is booked).
- Do not put secrets in payloads anyway. The payload is decrypted in the browser, sits in service worker memory and may be written into notification data that other code on your origin can read with
getNotifications(). Send references, not credentials. - Validate on the receiving side. In the service worker, parse the payload defensively, allowlist the URLs you open on click (same origin only), and never
evalor inject HTML from it. See Service Worker Security. - Protect the VAPID private key like any signing key: secret manager, no client bundles, no logs. The public key is public by design.
Debugging push delivery¶
When a notification does not appear, work from the server towards the device:
- Did the push service accept it? Log status,
Locationorapns-id, and the error body for every request. A201moves the investigation to the device side. - Decode your JWT. Paste the
t=value into any JWT decoder and checkaud(exactly the endpoint's origin),exp(seconds, less than 24 h ahead, not in the past) andsub. Then check thatk=matches the key the subscription was created with. - Round-trip your encryption with a decryptor like the one above, or against the RFC test vector. A body that does not decrypt is dropped silently in the browser: the push service accepted it, and the
pushevent never fires. - Test with DevTools. In Chromium, Application → Service workers → Push dispatches a
pushevent locally with a text payload, bypassing the network, which isolates your service worker code from your server. Application → Background services → Push messaging records real push events once you enable recording.chrome://gcm-internalsshows whether Chrome's connection to FCM is up and logs received messages. - Test in Firefox for descriptive autopush errors, and check the Browser Console for push decryption errors.
- Check the device. OS-level notification settings, Focus / Do Not Disturb, battery saver and the browser app's own notification permission all suppress display after a successful delivery. See Browser DevTools.
To test your sender without a browser, create a subscription-shaped object from a key pair you control and point endpoint at a local HTTP server that decrypts the body with the decryptor above. That makes the full pipeline testable in CI.
Common pitfalls¶
- DER signatures. Node's
crypto.sign()default output is DER. JWS needsieee-p1363. expin milliseconds or more than 24 h ahead. You get 403 orBadJwtToken, orUnauthorizedRegistrationfrom FCM.audwith a path or trailing slash. It must be exactlynew URL(endpoint).origin.- Missing
sub. Apple rejects it, Mozilla requires it with VAPID, and the RFC's "MAY" does not protect you. - Measuring payload size in characters.
"é".lengthis 1, but it is 2 bytes in UTF-8. CheckBuffer.byteLength()against 3993 before encrypting (the module above throws instead of sending a 413). - Reusing the ephemeral ECDH key or the salt. Both must be fresh per message. Reusing them with the same subscription reuses the AES-GCM key and nonce, which breaks GCM's security completely.
- Deleting subscriptions on 403. A 403 usually means your VAPID configuration is wrong for that subscription. Mass-deleting on 403 after a bad deploy wipes your subscriber base. Delete only on 404/410.
- Retrying 400s forever. A malformed request stays malformed. Dead-letter it and alert.
- Assuming 201 means delivered. It means stored. Devices can be offline beyond the TTL.
- Storing only the last path segment of the endpoint as a "token". Push services change paths and hosts.
- Sending from the request path. A slow push service then becomes a slow API.
- Rotating the VAPID key in one deploy. Every existing subscription starts failing with 403. Use the dual-key migration above.
- Trusting
userVisibleOnly: false. Chrome rejects it, and Safari and Edge require visible notifications. Silent data pushes do not exist on the open web. Use Background Sync or Periodic Background Sync for background data needs.
Browser and push service support¶
| Capability | Chrome / FCM | Edge / WNS | Firefox / autopush | Safari / Apple |
|---|---|---|---|---|
Push API (PushManager.subscribe) | ✅ 42 | ✅ 17 | ✅ 44 (Android 48) | ✅ 16.1 on macOS 13 Ventura (MDN lists 16); iOS/iPadOS 16.4 Home Screen apps |
aes128gcm payloads | ✅ | ✅ | ✅ | ✅ |
applicationServerKey (VAPID) required | ✅ required | ✅ required | ⚠️ optional | ✅ required |
userVisibleOnly: true required | ✅ | ✅ | ❌ (quota instead) | ✅ |
PushManager.supportedContentEncodings | ✅ 60 | ✅ 17 | ✅ 134 | ✅ 16 |
PushSubscription.expirationTime | ✅ 60 | ✅ 17 | ✅ 96 | ✅ 16 |
pushsubscriptionchange event | ⚠️ 138 (re-grant case only) | ⚠️ see note | ✅ 44 (oldSubscription/newSubscription since 137) | ✅ 16 macOS, ❌ iOS |
| Maximum TTL honored | 2,419,200 s (28 days) | — | 2,592,000 s (30 days; larger values are capped) | "30 days or fewer" |
| Guaranteed body size | 4096 bytes | 4096 bytes | 4096 bytes (less when bridged to Android) | 4 KB |
Topic replacement (documented by the push service) | — | — | ✅ | ✅ |
Delivery receipts (Prefer: respond-async) | ❌ | — | ❌ | ❌ |
| Declarative Web Push payloads | ❌ | ❌ | 🧪 behind dom.push.declarative.enabled | ✅ iOS 18.4, macOS Safari 18.5 |
Support data as of September 2026. "—" means the vendor does not document the behavior. Edge supported pushsubscriptionchange in EdgeHTML 17–18 and dropped it with the move to Chromium. Chromium-based Edge generally inherits Chrome's behavior, but MDN does not list the Chrome 138 re-grant case for Edge. Firefox on Android follows the desktop version numbers for the Push API since version 48. Check live data on MDN's Push API reference and caniuse.com.
Further reading¶
On this site
- Push Notifications: subscribing, the
pushevent and the visible-notification rule - Notifications API:
showNotification()options, actions, tags and click handling - Web Push on iOS & Safari: Home Screen requirements and Declarative Web Push
- Badging API: updating the app icon badge from a push
- Background Sync: deferring work until the device is online
- Permissions: how the notifications permission is granted and revoked
- Service Worker Security: validating what a push brings in
- Browser DevTools: simulating pushes and inspecting background services
External references
- RFC 8030: Generic Event Delivery Using HTTP Push
- RFC 8291: Message Encryption for Web Push
- RFC 8188: Encrypted Content-Encoding for HTTP
- RFC 8292: Voluntary Application Server Identification (VAPID) for Web Push
- W3C Push API
- Apple: Sending web push notifications in web apps and browsers
- Mozilla autopush: HTTP endpoints and error codes
- web.dev: The Web Push Protocol
- FCM: Setting the lifespan of a message
- web-push-libs: libraries for Node.js, Python, Java, PHP, C# and more