Push API Payload Encryption: Secure Implementation Guide

Web push notifications require strict end-to-end confidentiality to protect user data and prevent network interception. This guide details the exact cryptographic workflow for encrypting payloads on the server and decrypting them in the browser, aligned with RFC 8291 (Message Encryption for Web Push). For foundational context on how browsers route and queue push messages, refer to Core Protocols & Browser Implementation.

Implementation Prerequisites:

  • Extract the PushSubscription object structure: endpoint, keys.p256dh, and keys.auth
  • Map all cryptographic operations to RFC 8291 specifications
  • Select a standards-compliant, audited server-side cryptographic library for production deployments

Cryptographic Architecture & Key Derivation

Payload encryption relies on Elliptic Curve Diffie-Hellman (ECDH) over the NIST P-256 curve to establish a shared secret between the application server and the user agent. The auth secret acts as a context-specific input to HKDF-SHA256, generating the Content-Encoding Key and Nonce. The final payload is encrypted using AES-128-GCM with a mandatory 16-byte authentication tag.

Derivation Workflow:

  1. Extract p256dh (client public key) and auth (shared secret) from the subscription JSON
  2. Generate an ephemeral server keypair per request to ensure forward secrecy
  3. Derive the shared secret via ECDH, then expand via HKDF-SHA256
  4. Construct the AES-GCM cipher using the derived key and nonce
const crypto = require('crypto');

/**
 * Derives AES-128-GCM key and nonce from PushSubscription keys.
 * Compliant with RFC 8291 Section 3.1 (HKDF Expansion)
 */
function deriveEncryptionKeys(p256dhB64, authB64) {
 // 1. Decode base64url keys
 const clientPubKey = Buffer.from(p256dhB64, 'base64url');
 const authSecret = Buffer.from(authB64, 'base64url');

 // 2. Generate ephemeral server ECDH keypair (P-256)
 const serverKey = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });

 // 3. Compute ECDH shared secret
 const sharedSecret = crypto.diffieHellman({
 key: serverKey.privateKey,
 publicKey: clientPubKey
 });

 // 4. HKDF-SHA256 expansion for AES key and nonce
 const keyInfo = Buffer.concat([
 Buffer.from('Content-Encoding: aes128gcm\0'),
 Buffer.from('P-256\0'),
 clientPubKey,
 serverKey.publicKey.export({ format: 'der', type: 'spki' }).slice(26) // Uncompressed point
 ]);
 
 const nonceInfo = Buffer.concat([
 Buffer.from('Content-Encoding: nonce\0'),
 Buffer.from('P-256\0'),
 clientPubKey,
 serverKey.publicKey.export({ format: 'der', type: 'spki' }).slice(26)
 ]);

 const aesKey = crypto.hkdfSync('sha256', sharedSecret, authSecret, keyInfo, 16);
 const nonce = crypto.hkdfSync('sha256', sharedSecret, authSecret, nonceInfo, 12);

 return { aesKey, nonce, serverPublicKey: serverKey.publicKey };
}

Server-Side Encryption Workflow

Once cryptographic parameters are resolved, the plaintext payload is padded to prevent length-based fingerprinting, encrypted, and serialized into the HTTP POST body. The Content-Encoding: aes128gcm header must be explicitly set. Proper service worker lifecycle management ensures these encrypted messages are routed correctly, which depends heavily on established Service Worker Registration Patterns.

Packaging & Dispatch Steps:

  1. Serialize JSON payload to a UTF-8 Buffer
  2. Apply random padding (1–4095 bytes) to obscure content length
  3. Encrypt with AES-128-GCM using the derived key and nonce
  4. Construct the HTTP POST request to the push endpoint with standardized headers
const crypto = require('crypto');

/**
 * Encrypts and packages payload for aes128gcm delivery.
 * Returns { encryptedPayload, headers, serverPublicKey }
 */
function packageEncryptedPayload(plaintext, aesKey, nonce, salt) {
 const plaintextBuf = Buffer.from(plaintext, 'utf8');
 
 // RFC 8291: 2-byte padding length + random padding + 16-byte auth tag overhead
 const maxPadding = 4095;
 const paddingLength = Math.floor(Math.random() * maxPadding) + 1;
 const padding = Buffer.alloc(paddingLength, 0);
 crypto.randomFillSync(padding);

 // Prepend 2-byte padding length (big-endian)
 const paddedPayload = Buffer.concat([
 Buffer.from(paddingLength.toString(16).padStart(4, '0'), 'hex'),
 plaintextBuf,
 padding
 ]);

 // AES-128-GCM encryption
 const cipher = crypto.createCipheriv('aes-128-gcm', aesKey, nonce);
 const encrypted = Buffer.concat([cipher.update(paddedPayload), cipher.final()]);
 const authTag = cipher.getAuthTag();

 // Construct aes128gcm record: [encrypted data] + [16-byte auth tag]
 const record = Buffer.concat([encrypted, authTag]);

 return {
 record,
 salt: salt.toString('base64url'),
 headers: {
 'Content-Encoding': 'aes128gcm',
 'Encryption': `salt=${salt.toString('base64url')}`,
 'Content-Length': record.length.toString()
 }
 };
}

// Dispatch Example
async function dispatchPush(subscription, encryptedData, vapidToken) {
 const response = await fetch(subscription.endpoint, {
 method: 'POST',
 headers: {
 ...encryptedData.headers,
 'Authorization': `WebPush ${vapidToken}`,
 'TTL': '86400',
 'Urgency': 'high'
 },
 body: encryptedData.record
 });

 if (!response.ok) {
 throw new Error(`Push delivery failed: ${response.status} ${response.statusText}`);
 }
}

Authentication & Key Lifecycle Integration

Encryption ensures confidentiality, while VAPID handles authorization. The two operate independently but must be coordinated during request construction. VAPID JWTs are signed with the application’s keypair and attached to the Authorization header. To prevent delivery failures or unauthorized access, implement automated key lifecycle policies aligned with VAPID Key Generation & Rotation.

Key Management Directives:

  • Sign VAPID JWTs with explicit sub, exp, and aud claims using ES256
  • Attach Authorization: WebPush <token> to every dispatch request
  • Store encryption parameters (p256dh, auth) separately from VAPID signing keys in KMS
  • Implement cryptographic key versioning to enable seamless, zero-downtime rotation

Browser Constraints & Size Optimization

Encryption introduces cryptographic overhead (IV, salt, auth tag, padding, and header blocks) that significantly reduces usable payload space. Browsers enforce strict limits on the encrypted payload size before rejecting the push request. Understanding Maximum payload size limits for Chrome vs Firefox is critical for designing resilient notification architectures.

Optimization & Fallback Strategy:

  • Calculate baseline overhead: ~100–120 bytes for headers, salt, and authentication tags
  • Cap plaintext payloads to 3072 bytes to guarantee universal browser compatibility
  • Implement server-side payload chunking or URL-based data fetching for payloads exceeding limits
  • Validate encrypted size before dispatching to preempt 413 Payload Too Large or 400 Bad Request errors

Security Compliance & Production Hardening

Encrypted payloads must comply with stringent data protection regulations. Avoid transmitting PII directly in push messages. Implement strict TLS 1.2+ enforcement for all push endpoints, log only delivery metadata (never decrypted content), and monitor decryption failures via the pushsubscriptionchange event for proactive subscription renewal.

Hardening Checklist:

  • Audit all payload contents for PII; replace with opaque, revocable identifiers
  • Enforce HSTS and strict Content-Security-Policy on push delivery infrastructure
  • Configure alerting for pushsubscriptionchange event spikes indicating mass expiration or compromise
  • Conduct quarterly cryptographic dependency audits to patch vulnerabilities in underlying TLS/HKDF implementations