Rotating VAPID Keys Without Losing Subscribers

Rotating your VAPID key pair carelessly silently breaks every existing subscriber, because each subscription is cryptographically bound to the public key it was created with — you cannot just swap the key and keep sending.

Quick answer

Every push subscription is bound to the applicationServerKey (your VAPID public key) supplied at subscribe() time, so a new key cannot send to old subscriptions. To rotate without losing subscribers, run a dual-key overlap period: keep signing each subscription with the key it was created under, issue all new subscriptions with the new key, and migrate old subscribers by triggering a re-subscribe — either proactively in the client or via the pushsubscriptionchange event. Store both key pairs in environment variables (never hardcode the VAPID public key server-side) and retire the old key only once its subscriber count reaches zero.

Why this happens

When the browser calls pushManager.subscribe({ applicationServerKey }), it embeds your VAPID public key into the subscription it creates with the push service. From then on, every push to that endpoint must be signed with a JWT whose key matches — the push service validates the VAPID signature against the key the subscription was registered with and rejects mismatches with 403 Forbidden. There is no server-side rebinding: you cannot tell the push service “this subscription now belongs to a new key.” The binding lives in the subscription itself.

That is why a naive rotation — generate a new pair, update the env vars, restart — instantly silences every existing subscriber. Their endpoints were signed with the old key, your sends now use the new one, and the push service refuses them. The only way to move a subscriber to a new key is to have the browser create a new subscription with the new applicationServerKey. This is the same binding that makes VAPID key generation and rotation a careful operational process, and it sits at the heart of the Core Protocols & Browser Implementation auth model.

It is worth being precise about what is and is not bound. The VAPID private key signs the JWT in the Authorization header on each send; the VAPID public key is what the browser embedded as the applicationServerKey at subscribe time. The push service stores the public key against the subscription and, on every delivery, verifies that the JWT was signed by the matching private key. So rotation is really about the public key the subscription remembers — the private key is only the thing you must keep in sync with it. This distinction explains why you cannot “just rotate the private key”: a new private key produces signatures that no longer verify against the public key the subscription still references.

There is a legitimate reason to rotate beyond hygiene. If your VAPID private key leaks, an attacker can sign valid requests to your subscribers’ endpoints and inject notifications. Rotation is your remediation, but because old subscriptions stay bound to the compromised public key until they re-subscribe, you face a genuine trade-off: a hard cutover protects subscribers immediately but drops everyone who has not migrated, while an overlap keeps everyone reachable but leaves old subscriptions exposed to the leaked key until they roll over. For a routine rotation, favor the overlap; for a confirmed key compromise, accept the subscriber loss and cut over fast.

VAPID dual-key overlap rotation Old-key subscriptions are served by the old key while new subscriptions use the new key; migration moves subscribers to the new key before the old key is retired. Old key pair signs old subs New key pair signs new subs Overlap period both keys live re-subscribe migrates New key only old key retired
During the overlap period both key pairs sign their own subscriptions; re-subscribing migrates users to the new key before the old key is retired.

Dual-key send and migration

Store both pairs in the environment and pick the signing key per subscription based on which key created it. Track the key version alongside each stored subscription.

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

// Both pairs from env — never hardcode the VAPID public key server-side
const KEYS = {
  v1: { pub: process.env.VAPID_PUBLIC_KEY_V1, priv: process.env.VAPID_PRIVATE_KEY_V1 },
  v2: { pub: process.env.VAPID_PUBLIC_KEY_V2, priv: process.env.VAPID_PRIVATE_KEY_V2 },
};

async function sendWithCorrectKey(subscription, keyVersion, payload) {
  const k = KEYS[keyVersion]; // sign with the key the subscription was created under
  return webpush.sendNotification(subscription, JSON.stringify(payload), {
    TTL: 86400,
    vapidDetails: {
      subject: 'mailto:ops@yourdomain.com',
      publicKey: k.pub,
      privateKey: k.priv,
    },
  });
}

On the client, re-subscribe existing users to the new key when they next visit, and handle the browser-initiated pushsubscriptionchange in the service worker.

// Service worker: re-subscribe on browser-triggered change, with the CURRENT key
self.addEventListener('pushsubscriptionchange', (event) => {
  event.waitUntil((async () => {
    const newKey = await (await fetch('/vapid-public-key')).text(); // current key from server
    const sub = await self.registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(newKey),
    });
    await fetch('/subscriptions', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ subscription: sub, keyVersion: 'v2' }),
    });
  })());
});

What “without losing subscribers” actually requires

The phrase hides three separate guarantees you must hold simultaneously. First, continuity for current subscribers: every existing endpoint must keep receiving pushes throughout the rotation, which is what the per-subscription signing key delivers. Second, correct binding for new subscribers: every subscription created after the cutover must use the new public key, so you serve the new key from your subscribe endpoint the moment the overlap begins. Third, eventual convergence: the population of old-key subscriptions must trend to zero so you can safely retire the old key, which is what the re-subscribe migration drives. Lose any one of the three and you either drop subscribers, strand new ones, or can never finish the rotation.

The migration is the slowest part because it depends on users returning to your site. A subscriber who never visits again stays on the old key indefinitely. This is acceptable for routine rotation — you simply keep the old key alive until that cohort decays naturally — but it means rotation is not an event with a fixed end date; it is a process measured by the shrinking count of old-key rows in your subscription store. Instrument that count and let it, not a calendar, decide when to retire the old key. Pairing this with a sensible TTL on stored subscriptions and prompt 410 Gone cleanup keeps the old-key cohort shrinking from both ends: migration moves active users forward, and expiry removes dead endpoints.

Rotation steps

  1. Generate the new pair offline and load it into the environment as VAPID_PUBLIC_KEY_V2 / VAPID_PRIVATE_KEY_V2. Keep the old pair (_V1) in place.
  2. Record a key version on every stored subscription. Backfill existing rows as v1 so the sender knows which key to sign with.
  3. Switch new subscriptions to the new key. Serve the new public key from /vapid-public-key so all fresh subscribe() calls bind to v2.
  4. Send per-subscription with the matching key during the overlap, as in sendWithCorrectKey above. Old subscribers keep receiving pushes signed with v1.
  5. Migrate old subscribers. On their next visit, call pushManager.getSubscription(), unsubscribe() the v1 subscription, and subscribe() again with v2, then update the server. Also handle pushsubscriptionchange for browser-initiated migrations.
  6. Retire the old key. Once the v1 subscriber count reaches zero (or an acceptable floor), remove the _V1 env vars and delete the old pair.

Gotchas and edge cases

  • You cannot rebind an existing subscription. There is no API to move an endpoint to a new key; the browser must create a fresh subscription. Plan for re-subscription, not a swap.
  • pushsubscriptionchange payload is inconsistent. Some browsers omit the old subscription details, so re-subscribe from scratch using the current key rather than relying on event.oldSubscription.
  • A hard cutover sends 403 Forbidden. If you delete the old key while v1 subscriptions exist, every send to them fails the VAPID signature check. Always overlap.
  • Hardcoded keys make rotation impossible. If the public key is baked into a server literal or a built client bundle, you cannot rotate cleanly. Source it from process.env and /vapid-public-key so a rotation is a config change.
  • Unsubscribe before re-subscribe with a new key. A subscription is bound to its original applicationServerKey; calling subscribe() with a different key while the old one exists can throw. Unsubscribe first.

Back to VAPID Key Generation & Rotation