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.
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
- 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. - Record a key version on every stored subscription. Backfill existing rows as
v1so the sender knows which key to sign with. - Switch new subscriptions to the new key. Serve the new public key from
/vapid-public-keyso all freshsubscribe()calls bind tov2. - Send per-subscription with the matching key during the overlap, as in
sendWithCorrectKeyabove. Old subscribers keep receiving pushes signed withv1. - Migrate old subscribers. On their next visit, call
pushManager.getSubscription(),unsubscribe()thev1subscription, andsubscribe()again withv2, then update the server. Also handlepushsubscriptionchangefor browser-initiated migrations. - Retire the old key. Once the
v1subscriber count reaches zero (or an acceptable floor), remove the_V1env 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.
pushsubscriptionchangepayload is inconsistent. Some browsers omit the old subscription details, so re-subscribe from scratch using the current key rather than relying onevent.oldSubscription.- A hard cutover sends
403 Forbidden. If you delete the old key whilev1subscriptions 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.envand/vapid-public-keyso a rotation is a config change. - Unsubscribe before re-subscribe with a new key. A subscription is bound to its original
applicationServerKey; callingsubscribe()with a different key while the old one exists can throw. Unsubscribe first.
Related
- VAPID Key Generation & Rotation — generating the key pairs and the broader rotation lifecycle.
- VAPID vs APNs Authentication Differences — how the VAPID auth model compares to Apple’s, relevant when rotating across vendors.
- Service Worker Registration Patterns — handling
pushsubscriptionchangeand re-subscription in the worker.
Back to VAPID Key Generation & Rotation