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' }),
    });
  })());
});

Two details in that handler matter more than they look. The worker fetches the public key from the server rather than reading a bundled constant, so the day you rotate again the same deployed worker picks up the new key without a release. And it posts the resulting subscription together with its keyVersion, which is what lets the sender pick the matching private key later — without that field the server has no way to know which key signs for this endpoint. The broader event handling, including the browsers that fire pushsubscriptionchange on their own schedule, is covered in handling pushsubscriptionchange on the client.

Re-subscribe sequence on pushsubscriptionchange The push service fires pushsubscriptionchange at the service worker, which fetches the current public key from your API, calls subscribe with it, receives a new endpoint bound to the new key, and posts that endpoint back with its key version so the sender signs future messages correctly. Service worker Push service Your API 1. pushsubscriptionchange fires 2. GET /vapid-public-key 3. current public key (v2) 4. subscribe with the v2 key 5. new endpoint bound to v2 6. POST /subscriptions with keyVersion v2 The old row is replaced, never rebound — delete the v1 endpoint only after step 6 succeeds.
The browser-initiated migration path: the worker always asks the server which key is current, so the same code survives every future rotation.

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. The purge half of that equation is worth automating before you start the rotation — pruning expired push subscriptions from your database and handling 410 Gone responses at scale between them stop dead v1 rows from making the old-key cohort look larger than it is.

Instrumenting the migration

Because retirement is a data question rather than a calendar question, the rotation needs exactly one metric: the share of live subscriptions still bound to the old key. Query it on the same schedule you review delivery health.

SELECT key_version,
       COUNT(*)                                        AS subs,
       ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct
FROM push_subscriptions
WHERE revoked_at IS NULL
GROUP BY key_version
ORDER BY key_version;

Plot that percentage over time and the shape is always the same: a steep initial drop as your daily-active users return and re-subscribe, then a long shallow tail of infrequent visitors. The steep part is done in days; the tail is what decides your retirement date.

Old-key subscription share over the overlap period Stacked bars for weeks zero, one, two, four, six and eight show the old-key share of live subscriptions falling from 100 percent to 62, 41, 19, 8 and finally 3 percent as returning users re-subscribe onto the new key. A dashed line marks a five percent retirement floor. Old-key share of live subscriptions during the overlap a steep drop from returning users, then a slow decay from infrequent visitors 100% 50% 0% 100% wk 0 62% wk 1 41% wk 2 19% wk 4 8% wk 6 3% wk 8 5% floor old key (v1) new key (v2) retire when the v1 share crosses your floor, not on a fixed date
A representative overlap: the old-key share collapses in the first fortnight, then decays slowly — the tail, not the calendar, sets the retirement date.

Pick the floor deliberately. A 5% floor on a million subscribers still strands 50,000 people, so for a large base express the floor in absolute subscribers rather than percent, and weigh what those users are worth against the cost of keeping a second key pair live. If the rotation was triggered by a suspected leak, the calculus inverts entirely: cut the old key immediately and accept the loss.

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.
  • A 401 during rotation is usually not the rotation. Signing with the correct key but a stale or over-long exp claim produces 401 Unauthorized, not the 403 a key mismatch gives you — VAPID JWT expired 401 Unauthorized errors separates the two before you roll anything back.
  • 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

FAQ

Can I rotate only the VAPID private key and leave the public key alone? No. The push service verifies each send against the public key the subscription was created with, so a new private key produces signatures that no longer validate and every send returns 403 Forbidden. A key pair rotates as a pair; the public half is what the subscription remembers, which is why migration always means a fresh subscribe() call in the browser.
How long should the dual-key overlap period last? Until the old-key cohort reaches a floor you have chosen deliberately — typically a small percentage of live subscriptions, or an absolute subscriber count on a large base. The first fortnight clears most active users; the remainder decays at the rate your infrequent visitors return. Let the query on key_version decide the retirement date rather than a calendar entry.
What happens to subscribers who never return before I retire the old key? They are lost. Their subscription stays bound to the old public key, so once you delete that key pair every send to them fails the signature check and the endpoint is effectively dead. Retiring the old key is a deliberate trade: keep it alive longer to preserve the tail, or cut it now — the correct answer for a confirmed key compromise.