Discuss your project

Web Push and VAPID: Breaking Down the End-to-End Flow

/* by - February 6, 2026 */

Web Push allows a server to deliver notifications to the browser, even when the application is not open. Once permission is granted, messages can be delivered without an active page or a live connection.

What enables this is not a single API call, but coordination between the client, the backend, and a push service in the middle. Two primitives sit at the center of this coordination: subscription-based encryption keys that ensure message privacy, and a VAPID key pair that establishes the identity and authorization of the sending server.

The Actors (Who Does What)

Before code, we need to know who is responsible for what.

ActorResponsibility
Frontend (FE)Ask permission, initiate subscription
Browser RuntimeTalks to push service, manages keys
Push Service (PS)Verifies sender, delivers messages
Backend (BE)Encrypts payload, signs request
Service Worker (SW)Receives push, shows notification
Web Push lifecycle showing frontend, backend, push service, and service worker interactions.

Step 1: VAPID Keys (Server Identity)

VAPID (Voluntary Application Server Identification) provides a way for your backend to prove its identity to the push service. It establishes that your server is authorized to send push messages for this application.

We generate one VAPID key pair:

  • Public key that is shared with frontend
  • Private key that stays on backend

NOTE: VAPID does not encrypt messages.


Step 2: Frontend Subscribes the Browser

The frontend fetches the public VAPID key from your backend and provides it to the browser during subscription:

const permission =awaitNotification.requestPermission()
if (permission !=='granted')return
const reg =await navigator.serviceWorker.ready
const subscription =await reg.pushManager.subscribe({
userVisibleOnly:true,
applicationServerKey: vapidPublicKeyUint8Array,
})

What happens internally:

  • The browser talks to the push service
  • The push service:
    • creates an endpoint
    • generates encryption keys
  • The browser returns a PushSubscription object
{
"endpoint": "<https://fcm.googleapis.com/>...",
"keys": {
"p256dh": "...",
"auth": "..."
}
}

Step 3: Frontend Sends Subscription to Backend

The subscription object is not a token. It contains:

  • the endpoint (address),
  • the encryption keys needed for payload privacy.

Send it to your backend:

await fetch('/api/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
})

The backend stores this subscription as-is for future use.


Why There Are Two Cryptographic Steps

Web Push uses two distinct forms of cryptography for different purposes. Understanding this distinction is central to a correct implementation.

1. Payload Encryption (Privacy)

Ensure the push service cannot read your message.

  • Uses subscription keys (p256dh, auth)
  • Generated by the push service during subscription
  • Only the browser can decrypt

This protects message content.

2. VAPID Signing (Authentication)

Prove to the push service that you are allowed to send.

  • Backend signs a JWT with private VAPID key
  • Push service verifies with public VAPID key

This protects sender identity.


Step 4: Backend Sends a Push Notification

When you want to deliver a notification, the backend performs two key tasks:

  1. Encrypt the payload using the subscription’s encryption keys
  2. Sign the request with your private VAPID key

Conceptually:

// pseudo-code
encryptPayload(subscription.keys, payload)
signRequestWithVapid(privateKey)
POST encryptedPayload to subscription.endpoint

The backend never communicates directly with the browser; it always sends through the push service.


Step 5: Push Service Verifies and Delivers

Upon receiving the request from your backend, the push service:

  1. Verifies the VAPID JWT
  2. Confirms the endpoint is valid
  3. Queues the encrypted payload
  4. Delivers it to the browser

If any step fails, you get:

  • 403 means invalid VAPID
  • 410 means subscription expired
  • Silent drops (yes, really)

Step 6: Browser Receives the Push

When the push arrives at the browser, even if the site is closed. the service worker handles it:

self.addEventListener('push',event => {
const data = event.data.json()
self.registration.showNotification(data.title, {
body: data.body,
})
})

The browser decrypts the payload using the subscription keys, wakes the service worker, and displays the notification.

Conclusion

Web Push works because each part of the system has a clear responsibility. VAPID establishes the identity of the sender, subscription keys protect the message content, and the push service takes care of delivery. Viewed this way, the system becomes easier to reason about.