Skip to content

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 POST to the subscription's endpoint with a mandatory TTL header, an optional Urgency and Topic, Content-Encoding: aes128gcm when there is a body, and Authorization: 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 auth secret, 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) and sub (mailto: or https:). Its signature is raw R || S, not DER.
  • 201 means the push service accepted the message, not that it was delivered. 404 and 410 mean you should delete the subscription. 429 and 5xx mean you should back off and honor Retry-After. 400, 401, 403 and 413 are 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:

subscription.json
{
  "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.

lib/endpoint-allowlist.js
// 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:

push-request.http
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: 0 has 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 TTL response header. Mozilla autopush silently caps values above 2592000 seconds (30 days); its source code comments "Enforce a maximum TTL, but don't error". It rejects negative or unparseable values with 400. FCM documents a maximum lifespan of 2419200 seconds (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 Topic to 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:

topic-replacement.http
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 with 400. 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 tag in 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 to subscribe() as applicationServerKey and send it in the k= parameter.
  • Private key: the 32-byte scalar d, base64url-encoded (43 characters).
scripts/generate-vapid-keys.js
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:

  1. The signature format. JWS ES256 signatures are the fixed-length 64-byte concatenation R || S (RFC 7518 §3.4). OpenSSL, and therefore Node's crypto.sign(), produces ASN.1 DER by default (70–72 bytes). Pass dsaEncoding: "ieee-p1363" or every push service will reject the token.
  2. exp in milliseconds. Date.now() is milliseconds. A JWT exp of Date.now() + 43200 lies 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-header.http
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.

lib/vapid.js
// 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:

rfc8291-derivation.txt
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:

payload-budget.txt
  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:

  • rs must 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)". Setting rs = 4096 always 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.

lib/encrypt.js
// 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:

test/rfc8291.test.js
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:

lib/decrypt.js
// 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.

lib/send.js
// 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:

examples/send-one.js
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
  1. 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.
  2. 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.
  3. 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.
  4. A result handler updates state. It deletes subscriptions on 404/410, bumps last_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, the undici package's Agent can negotiate HTTP/2 via ALPN (allowH2: true), and you pass it to fetch as the dispatcher option.
  • 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.
lib/agent.js
// 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 tag so a duplicate replaces the first notification instead of adding a second one.
  • Use Topic so 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:

lib/dispatcher.js
// 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:

workers/push-worker.js
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 TooManyRequests reason 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.

migrations/001_push_subscriptions.sql
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 call subscription.unsubscribe() on the client.
  • auth is 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 of auth.
  • Prune proactively. Besides deleting on 404/410, expire rows with a high failure_count and 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:

routes/push-subscriptions.js
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:

  1. Store the key ID per subscription (the vapid_key_id column). Without it you cannot tell which key to sign with, and a mixed population returns 403s for the part signed with the wrong key.
  2. Deploy both keys to the sender. Keep a map keyId → VapidSigner and sign each message with the key its subscription was created with.
  3. 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.
  4. 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.

public/push-migrate.js
// 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 eval or 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:

  1. Did the push service accept it? Log status, Location or apns-id, and the error body for every request. A 201 moves the investigation to the device side.
  2. Decode your JWT. Paste the t= value into any JWT decoder and check aud (exactly the endpoint's origin), exp (seconds, less than 24 h ahead, not in the past) and sub. Then check that k= matches the key the subscription was created with.
  3. 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 push event never fires.
  4. Test with DevTools. In Chromium, Application → Service workers → Push dispatches a push event 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-internals shows whether Chrome's connection to FCM is up and logs received messages.
  5. Test in Firefox for descriptive autopush errors, and check the Browser Console for push decryption errors.
  6. 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 needs ieee-p1363.
  • exp in milliseconds or more than 24 h ahead. You get 403 or BadJwtToken, or UnauthorizedRegistration from FCM.
  • aud with a path or trailing slash. It must be exactly new 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. "é".length is 1, but it is 2 bytes in UTF-8. Check Buffer.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

External references