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' }),
});
})());
});
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.
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.
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
- 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. - A
401during rotation is usually not the rotation. Signing with the correct key but a stale or over-longexpclaim produces401 Unauthorized, not the403a 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.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. - VAPID JWT Expired 401 Unauthorized Errors — telling an expired token apart from the key mismatch a bad rotation causes.
- Pruning Expired Push Subscriptions From Your Database — clearing dead endpoints so the old-key cohort count reflects real subscribers.
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 returns403 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 onkey_version decide the retirement date rather than a calendar entry.