VAPID Key Generation & Rotation: Secure Implementation Guide

VAPID (RFC 8292) authorizes every web push request with a signed ECDSA P-256 JWT. This guide covers generating compliant key pairs, storing the private key safely, and rotating without invalidating the subscriber base. It sits inside the broader Core Protocols & Browser Implementation stack, where the public key binds each subscription to your origin.

Prerequisites

  • web-push library (or an equivalent VAPID-capable library).
  • .env in version control.
  • process.env.VAPID_PUBLIC_KEY / process.env.VAPID_PRIVATE_KEY.
  • exp drift > 300 s causes rejection).

1. VAPID Architecture & Key Lifecycle Fundamentals

Voluntary Application Server Identification (VAPID) supersedes legacy vendor-specific push credentials by establishing a standardized, cross-browser authentication framework. A robust Core Protocols & Browser Implementation strategy hinges on understanding VAPID keys as asymmetric ECDSA P-256 pairs. The public key serves as a persistent application identifier for push services; the private key cryptographically signs JWT assertions that authorize message delivery. Because private keys grant unrestricted dispatch capabilities, systematic rotation is a non-negotiable security control. It limits the blast radius of credential exposure, enforces cryptographic hygiene, and satisfies modern compliance audit requirements.

VAPID key lifecycle and zero-downtime rotation A generated keypair splits into a public key embedded in subscriptions and a private key stored in a secrets manager for JWT signing. During rotation the old public key keeps serving existing subscribers while new subscribers receive the new public key, until the old keypair is retired. generateVAPIDKeys() ECDSA P-256, CSPRNG Public key applicationServerKey Private key secrets manager · JWT sign Existing subs keep old public key New subs new public key Rotation window: serve old public key to existing subscribers, new public key to new ones Gradually re-subscribe → once > 99% migrated, retire the old keypair Monitor 401 / 403 rejection rates and pushsubscriptionchange spikes throughout
VAPID key roles and the zero-downtime rotation window: the public key is client-facing, the private key signs JWTs, and rotation overlaps both keys until subscribers migrate.

Key Architectural Considerations:

  • RFC 8292 Compliance: Keys must strictly adhere to the Elliptic Curve Digital Signature Algorithm (ECDSA) over the P-256 curve. Non-compliant curves or weak entropy will result in silent push rejection.
  • Asymmetric Key Roles: The public key is client-facing and embedded in subscription payloads (applicationServerKey in pushManager.subscribe()). The private key is strictly server-bound and used exclusively for JWT Authorization: WebPush <token> header generation.
  • Subscription Binding: A push subscription is cryptographically bound to the VAPID public key used at subscription time. Changing the public key without a re-subscription flow will invalidate all existing subscriptions.

Implementation Note: Treat VAPID private keys with the same severity as root TLS certificates. Document cryptographic generation timestamps, enforce explicit ownership for rotation schedules, and classify keys as Tier-1 secrets in your asset inventory.

The public key is what the client passes during Service Worker Registration Patterns, and the same signing key authorizes the encrypted records described in Push API Payload Encryption — encryption and authorization are independent but must both succeed.


2. Step-by-Step Secure Key Generation

Generating compliant VAPID keys mandates a cryptographically secure random number generator (CSPRNG) to derive the P-256 elliptic curve keypair. The resulting public key is distributed to clients during pushManager.subscribe() initialization; the private key remains strictly isolated on the server for JWT signing. Always verify base64url encoding and strip padding characters (=) before deployment to prevent silent browser rejection.

CLI Generation:

# Generate keys using the web-push CLI
npx web-push generate-vapid-keys --json

# Store output in a restricted-access secrets file
npx web-push generate-vapid-keys --json > /tmp/vapid_keys.json
chmod 600 /tmp/vapid_keys.json
# Move to your secrets manager immediately; delete the temp file

Node.js Programmatic Generation with Validation:

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

function generateSecureVapidKeys() {
  const keys = webpush.generateVAPIDKeys();

  // Validate: uncompressed P-256 public key is 65 bytes (130 hex chars).
  // web-push returns base64url without padding; decode to verify.
  const padding = '='.repeat((4 - (keys.publicKey.length % 4)) % 4);
  const publicKeyBuffer = Buffer.from(
    (keys.publicKey + padding).replace(/-/g, '+').replace(/_/g, '/'),
    'base64'
  );

  if (publicKeyBuffer.length !== 65) {
    throw new Error(
      `Invalid VAPID public key length: ${publicKeyBuffer.length} bytes (expected 65).`
    );
  }

  return {
    publicKey:   keys.publicKey,  // already base64url without padding
    privateKey:  keys.privateKey,
    generatedAt: new Date().toISOString()
  };
}

try {
  const vapidKeys = generateSecureVapidKeys();
  console.log('Keys generated. Inject into secrets manager immediately.');
  console.log('Public key (safe to share):', vapidKeys.publicKey);
} catch (err) {
  console.error('Key generation failed:', err.message);
  process.exit(1);
}

The 65-byte length check is not pedantry. An uncompressed P-256 public key is exactly one 0x04 prefix byte followed by the 32-byte X and 32-byte Y coordinates. If a library or copy-paste step has truncated, re-encoded, or compressed the key, the byte length will be wrong and pushManager.subscribe() rejects it in the browser long before any push is ever sent — a failure that surfaces as a confusing client-side error rather than a server log. Validating at generation time turns a hard-to-trace browser exception into an explicit server-side assertion.

Key Validation Requirements:

  • CSPRNG Validation: Ensure the underlying runtime uses /dev/urandom or OS-equivalent entropy sources. The web-push library delegates to Node.js crypto which uses the OS CSPRNG.
  • Base64url Encoding: RFC 4648 §5 compliance is mandatory. Standard Base64 (with + and /) will cause pushManager.subscribe() failures in browsers. The browser expects the URL-safe alphabet (- and _) with padding stripped, so converting between encodings is the most common source of a key that “looks right” but is rejected.
  • No Client-Side Generation: Never generate VAPID keys in the browser. Private keys must never leave the server, and the public key must never be hardcoded in server source — read it from process.env.VAPID_PUBLIC_KEY.

3. Secure Storage & Environment Configuration

VAPID private keys must never transit through version control systems, CI/CD logs, or client-side bundles. Production deployments require centralized secrets management via AWS Secrets Manager, HashiCorp Vault, or encrypted CI/CD variables. IAM policies must enforce least-privilege access, restricting key retrieval exclusively to the push dispatch service.

When configuring Service Worker Registration Patterns, inject the public key at build time or serve it via a secure, cache-controlled endpoint. This prevents client-side tampering and guarantees consistent subscription payloads across environments.

Configuration Reference:

Variable / setting Type Default Notes
VAPID_PUBLIC_KEY base64url string 65-byte uncompressed P-256 key; safe to expose to clients
VAPID_PRIVATE_KEY base64url string Tier-1 secret; secrets-manager only, never in source
subject mailto:/https: URI Contact for the push service; required in JWT sub
JWT exp seconds ≤ 86400 Keep 12–24 h; longer drifts risk stale rejection
Rotation cadence duration 90 days Orchestrate via IaC; overlap keys during transition
Key retention (post-rotation) duration 30 days Encrypted cold storage; purge after forensic window

Infrastructure Hardening Checklist:

  • Secrets Manager Integration: Use dynamic secret rotation APIs where supported. Never hardcode keys in .env files committed to repositories.
  • Least-Privilege IAM Policies: Scope IAM roles to secretsmanager:GetSecretValue for the specific secret ARN. Deny ListSecrets and PutSecretValue to application roles.
  • Environment Variable Injection Patterns: Inject secrets at container startup or via sidecar proxies (e.g., Vault Agent, AWS ECS Secrets Manager integration).

4. Automated Rotation Workflows & Zero-Downtime Deployment

Key rotation must follow a predictable cadence — typically 90 days — orchestrated through IaC pipelines. The critical constraint: existing push subscriptions are bound to the VAPID public key used at subscription time. Changing the public key immediately invalidates all existing subscriptions, because the push service will reject the new JWT signed with the new key for subscriptions that registered with the old key.

Zero-downtime rotation strategy:

  1. Generate a new VAPID keypair.
  2. Keep serving the old public key for new subscriptions temporarily.
  3. Deploy the new private key as the active signing key.
  4. Begin delivering push notifications signed with the new private key using the old public key as the audience identifier.
  5. Gradually re-subscribe users: when they visit the app, subscribe them with the new public key.
  6. Once >99% of subscriptions use the new public key, retire the old keypair.

For teams managing hybrid ecosystems, understanding the VAPID vs APNs authentication differences prevents rotation logic from conflicting with APNs token refresh cycles, and the full operational playbook lives in rotating VAPID keys without losing subscribers.

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

async function rotateVapidKeys(newKeys, legacyKeys) {
  // Configure the library with the new private key and legacy public key
  // during the transition window. The public key in VAPID JWTs must match
  // what clients used when subscribing.
  webpush.setVapidDetails(
    'mailto:security@yourdomain.com',
    legacyKeys.publicKey,    // still serve legacy public key to existing subscribers
    newKeys.privateKey       // sign with new private key (if re-keying within same pub key)
  );

  // Note: if rotating the public key, you must re-subscribe all users.
  // Track migration progress via subscription metadata in your database.

  console.log('Rotation in progress. Monitor 401/403 error rates.');
}

It is worth being precise about what can and cannot rotate transparently. The private signing key can change without touching subscribers as long as the public key the JWT advertises stays the one those subscribers used at subscription time — the push service only checks that the JWT’s signature verifies against the public key bound to the endpoint. The public key, by contrast, is baked into every subscription and cannot be swapped server-side; changing it strands every existing subscriber until they re-subscribe. This asymmetry is why a disciplined program rotates the private key on a fixed cadence for hygiene and reserves public-key rotation for genuine compromise, where the cost of mass re-subscription is justified. Track per-subscription which public key generated it, so a future rotation can target only the cohort that needs migrating rather than the whole base.

Transition Architecture:

  • JWT Expiration Alignment: Align JWT exp claims with browser cache TTLs. Short-lived tokens (12–24 h) reduce stale subscription failures.
  • Rollback Procedures: Implement automated health checks. If push success rates drop below 98%, trigger an immediate rollback to the previous keypair.
  • Monitor pushsubscriptionchange Events: A spike in these events indicates subscriptions being invalidated by the browser, often after a public key change.

5. Verification

Confirm the active key pair is wired correctly before relying on it for production delivery.

# Confirm both keys are present in the environment (length check only; never log values)
node -e "console.log('pub len', process.env.VAPID_PUBLIC_KEY?.length, 'priv set', !!process.env.VAPID_PRIVATE_KEY)"

Send one test notification to a known-good subscription and assert a 201 Created. A 401 means the JWT signature or exp is wrong; a 403 means the public key on the JWT does not match the one the subscription was created with. Decode the signed JWT (header and payload only) and confirm aud matches the push service origin, sub is your contact URI, and exp is within 24 hours.

6. Compliance, Security Auditing & Troubleshooting

Maintain regulatory alignment by enforcing strict documentation of key generation timestamps, rotation logs, and access control matrices. Deploy automated health checks that continuously validate JWT signature integrity.

Error Code Diagnostics:

Status Cause Resolution
401 Unauthorized Invalid JWT signature, expired token, or mismatched private key Re-sign with the correct private key; verify exp and NTP sync
403 Forbidden Public key mismatch, malformed subscription, or revoked endpoint Confirm the JWT public key matches the subscription’s key
404 Not Found Endpoint no longer recognized Treat as terminal; delete the subscription record
410 Gone Subscription expired or explicitly unsubscribed Purge endpoint; renew via pushsubscriptionchange
429 Too Many Requests Rate limit exceeded at the push service Honor Retry-After; apply exponential backoff
  • Clock Synchronization & JWT Validation: Ensure NTP synchronization across all dispatch nodes. JWT exp drift >300 s will cause push service rejection.

Incident Response Protocol:

  1. Alerting Thresholds: Configure alerts for push endpoint rejection spikes (>5% over 15 min).
  2. Structured Logging: Trace JWT signing failures back to specific key versions using correlation IDs.
  3. Key Retention: Archive rotated keys in encrypted cold storage for 30 days. Purge securely after the forensic window closes.

Back to Core Protocols & Browser Implementation

FAQ

What happens if the VAPID public key changes?

Existing subscriptions created with the old key keep working only while you continue serving that old public key; new subscriptions use the new key. If you change the public key clients subscribe with, those subscriptions are invalidated and the push service rejects deliveries. Rotate gradually: serve the old public key to existing subscribers and the new one to new subscribers until the base migrates, then retire the old keypair.

How often should I rotate VAPID keys?

A 90-day cadence is a sensible default, orchestrated through infrastructure-as-code. Because rotating the public key forces re-subscription, many teams rotate the private key on cadence while keeping the public key stable, and only rotate the public key on suspected compromise. Always overlap keys during the transition and monitor 401/403 rejection rates.

Why am I getting 401 Unauthorized from the push service?

A 401 means the VAPID JWT is invalid: a wrong signature, an expired exp claim, or a mismatched private key. Verify the private key matches the public key clients subscribed with, keep exp under 24 hours, and ensure all dispatch nodes are NTP-synced — drift over 300 seconds triggers rejection.

Can I store the VAPID private key in a .env file?

Not in a committed .env file. The private key is a Tier-1 secret that grants unrestricted dispatch capability. Store it in a secrets manager (AWS Secrets Manager, Vault, or encrypted CI/CD variables) and inject it at runtime as process.env.VAPID_PRIVATE_KEY, scoped by least-privilege IAM to the dispatch service only.

How are VAPID keys different from APNs tokens?

VAPID uses a single ECDSA P-256 keypair to sign short-lived JWTs across all browsers, while Apple’s APNs uses its own provider authentication token with a different key ID and team-based provisioning. On iOS/macOS Safari, web push still rides over APNs but is authorized via VAPID, so the two refresh cycles must not collide — see the VAPID vs APNs guide.