Skip to content

Latest commit

 

History

History
245 lines (182 loc) · 11.3 KB

File metadata and controls

245 lines (182 loc) · 11.3 KB

Concepts: Web Push, VAPID, FCM, APNs, OneSignal

The underlying problem

Your app cannot receive anything while it is closed. There is no process of yours running. So you always need a third party that is permanently awake: a service operated by the browser vendor or the OS, which keeps a persistent connection to the device and knows how to wake it up.

That is the first thing to internalise: with push you never talk directly to the user's device. You ask an intermediary to wake it. Everything else — VAPID, FCM, APNs — is a variation on who that intermediary is and how you prove you are allowed to use it.

Web Push: the open standard

An IETF standard split across three RFCs worth telling apart:

RFC Defines
8030 The delivery protocol itself (how you POST to an endpoint)
8291 Payload encryption (so the intermediary cannot read your content)
8292 VAPID — how you identify yourself as an application server

There are four actors:

┌─────────────────┐                      ┌──────────────────┐
│  YOUR BACKEND   │   1. encrypted POST  │  PUSH SERVICE    │
│  (Application   │─────────────────────>│  (the browser's: │
│     Server)     │   + VAPID-signed JWT │   Google/Mozilla/│
└─────────────────┘                      │   Apple/MS)      │
        ▲                                └──────────────────┘
        │                                          │
        │ 3. you store the                         │ 4. wakes the
        │    PushSubscription                      │    device
        │                                          ▼
┌─────────────────┐                      ┌──────────────────┐
│  YOUR FRONTEND  │  2. subscribe()      │  SERVICE WORKER  │
│                 │─────────────────────>│  'push' event    │
└─────────────────┘                      └──────────────────┘

The flow, step by step

  1. Ask for permission: Notification.requestPermission().
  2. Subscribe: registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }). The browser talks to its push service and hands you back a PushSubscription.
  3. That subscription has three fields, and they are everything you need:
{
  endpoint: "https://fcm.googleapis.com/fcm/send/eH7x...",  // the address
  keys: {
    p256dh: "BN4G...",  // the client's ECDH P-256 public key
    auth:   "tBHI..."   // a 16-byte auth secret
  }
}
  1. Send it to your backend and store it.
  2. To send: your server encrypts the payload with p256dh + auth, signs a JWT with your VAPID private key, and POSTs to the endpoint URL.

Two ideas that change how you think about it

(a) The endpoint is the address. There is no "device token" you request from Google. The URL already encodes everything: which push service to reach and which device. That is why your code never needs to know which browser it is talking to — it just POSTs where it was told.

(b) The payload is end-to-end encrypted. Google, Mozilla and Apple cannot read the contents of your notifications. That is what p256dh and auth are for: they are the client's key material. The push service sees an opaque blob and an address. This is a real, strong difference from native push.

VAPID

Voluntary Application server IDentification. An ECDSA P-256 key pair you generate once for your whole application — not per user, not per device.

  • The public key goes to the browser as applicationServerKey at subscribe time.
  • The private key signs a JWT on every send.

Look at the sizes to make it concrete: the public key is 87 characters (65 bytes — an uncompressed P-256 point — in base64url) and the private one is 43 (a 32-byte scalar). No magic, just an elliptic curve.

The JWT you sign on each send looks like this:

header:  { "typ": "JWT", "alg": "ES256" }
claims:  { "aud": "https://fcm.googleapis.com",   // origin of the push service
           "exp": <at most 24h in the future>,
           "sub": "mailto:admin@yourapp.com" }    // how to reach you

and travels as Authorization: vapid t=<jwt>, k=<base64url public key>.

What it is actually for

  1. It identifies you to the push service. The sub claim is an email or URL of yours: if you are abusing the service, they know who to write to before blocking you.
  2. It binds the subscription to you — this is the important one. A subscription created with your public key only accepts pushes signed with your private key. If someone steals an endpoint out of your database, they cannot use it to spam your users.

The trap that will bite you

If you rotate the VAPID pair, every existing subscription stops working. They were minted against the old public key, and the push service answers 403. All of your users have to subscribe again.

This is why a leaked VAPID private key is expensive: rotating is the correct fix, and the cost is that everybody loses notifications until they re-subscribe.

Useful history: before VAPID, Chrome required a Google account and a GCM/FCM sender ID for web push. VAPID is exactly what removed that requirement. If you find tutorials talking about gcm_sender_id in the manifest, they are stale.

FCM, APNs, WNS: who the intermediary is

You do not choose the push service. The browser does.

Browser Push service Endpoint you get
Chrome / Edge FCM (Google) fcm.googleapis.com/...
Firefox Mozilla autopush updates.push.services.mozilla.com/...
Safari APNs (Apple) web.push.apple.com/...

With standard Web Push this is transparent: you use VAPID and the same library against all three. You configure nothing with Google or Apple.

"FCM" means two different things

This is what confuses everyone:

  • FCM as Chrome's push service → invisible plumbing. If your endpoint says fcm.googleapis.com, you are already "using FCM" without a Firebase account and without knowing it.
  • FCM as a product/SDK → Firebase Cloud Messaging, Google's platform with its own SDK for Android, iOS and web. There you get a registration token per device and send via the HTTP v1 API, authenticated with a Google service account.

The second is a layer on top of the first. And on iOS, FCM-the-product is literally a wrapper over APNs: you upload your APNs key to Firebase and Firebase relays. It does not save you the Apple account, it saves you the code.

A practical note: the legacy FCM API (the "server key" one) was shut down in June 2024. It is HTTP v1 with OAuth2 now. A great many tutorials are out of date on this.

APNs

Apple's system, and the only route to native iOS/iPadOS/macOS. Authentication by token (.p8) — conceptually almost identical to VAPID: an ES256 JWT signed with your key — or by certificate (.p12), which is the old way. Use .p8: one key covers all your apps and it does not expire.

What matters most if you are doing web: Safari has supported standard Web Push since macOS Ventura (Safari 16.1) and iOS 16.4. But iOS adds a requirement that breaks a lot of plans:

On iOS the site must be installed to the Home Screen as a PWA. From a regular Safari tab there is no push. Full stop.

OneSignal and the commercial layer

OneSignal is a SaaS platform wrapping Web Push + FCM + APNs behind one SDK and a dashboard. It is not a protocol or a technical competitor: it is a product layer.

What it gives you that you would otherwise build:

  • Audience segmentation, scheduled sends, A/B testing
  • Delivery and open-rate analytics (this alone is substantial work)
  • Retries, unsubscribe handling, deduplication
  • A dashboard so marketing can run campaigns without touching your code
  • In-app messages, email and SMS through the same channel

The cost: you hand your subscriber list and message content to a third party, and you are tied to the vendor. The free tier is generous.

Others in the same space: Pusher Beams, Airship, Braze, Iterable, Knock, and Novu (open source, self-hostable).

Recommendation for learning: start with raw Web Push. Once you have done it by hand you understand exactly what OneSignal is selling you and can decide whether you need it. It does not work the other way round: start with OneSignal and you never learn which part is the standard and which part is their product.

web-push (the Node library)

The server-side implementation of the protocol. Maintained by the web-push-libs org, it is the de-facto reference in the JS ecosystem (equivalents exist as pywebpush for Python, web-push-php, and others for Go).

It does three things for you, and they are exactly the three you do not want to hand-roll:

  1. Encrypts the payload per RFC 8291 (aes128gcm) using p256dh and auth.
  2. Builds and signs the VAPID JWT (RFC 8292).
  3. POSTs to the endpoint, whether it belongs to Google, Mozilla or Apple.

That third point is the good part: one library, zero per-browser branching.

const webpush = require('web-push');

// Once in your life, and you keep the result:
const keys = webpush.generateVAPIDKeys();  // { publicKey, privateKey }

webpush.setVapidDetails(
  'mailto:you@yourapp.com',   // the "sub" claim
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY,
);

await webpush.sendNotification(
  subscription,                     // the stored {endpoint, keys} object
  JSON.stringify({ title, body }),  // payload, ~4KB max
  { TTL: 3600, urgency: 'high', topic: 'chat-42' },
);

The three options almost nobody uses and you should

Option What it does
TTL Seconds the push service holds the message while the device is offline. Library default: four weeks. For a chat you want something short — a "new message" alert from three weeks ago is garbage.
urgency very-low / low / normal / high. The device may defer low-urgency messages to save battery.
topic Collapse key: a new push with the same topic replaces an undelivered earlier one. Ideal for "you have 3 new messages" instead of three stacked notifications.

Status codes: the part that matters most

sendNotification throws a WebPushError carrying statusCode. Handling these correctly is the difference between a system that works and one that destroys itself.

Code Meaning What to do
404 / 410 The subscription is dead (uninstalled, data cleared, revoked) Delete the row. The only case where deleting is right.
400 Malformed request Your bug. Log it. Do not delete.
401 / 403 VAPID problem: wrong keys, expired JWT, mismatch Your bug. Never delete.
413 Payload > 4096 bytes Shrink it. Send an id, not the whole content.
429 Rate limited Honour Retry-After and back off.

Deleting on 400/401/403 is a real and surprisingly common bug. A misconfigured VAPID pair returns 401/403 on every send, so the first attempt wipes your entire table of valid subscriptions — precisely when you already had a problem.