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.

Prerequisites

  • PushSubscription with endpoint, keys.p256dh, and keys.auth captured server-side.
  • process.env.VAPID_PUBLIC_KEY / process.env.VAPID_PRIVATE_KEY — never hardcoded in server code.
  • web-push for Node.js, pywebpush for Python) — do not hand-roll the cipher stack.
  • Service Worker Registration Patterns to receive and decrypt messages.

Implementation Prerequisites (mapping):

  • Extract the PushSubscription object structure: endpoint, keys.p256dh, and keys.auth
  • Map all cryptographic operations to RFC 8291 specifications
  • In production, use an audited library that implements RFC 8291 correctly — hand-rolling the full encryption stack is error-prone
RFC 8291 payload encryption sequence The server combines the client p256dh public key and an ephemeral server key via ECDH to derive a shared secret, runs HKDF-SHA-256 with the auth secret as salt to produce the content encryption key and nonce, encrypts the padded plaintext with AES-128-GCM, and frames it as an aes128gcm record under 4 KB. Subscription keys p256dh + auth Ephemeral P-256 server keypair / msg ECDH shared secret HKDF-SHA-256 salt = auth → CEK + nonce AES-128-GCM padded plaintext aes128gcm record < 4 KB salt · rs · keyid · ciphertext+tag
RFC 8291 sequence: ECDH shared secret → HKDF-derived CEK and nonce → AES-128-GCM ciphertext → framed aes128gcm record under the 4 KB limit.

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 salt input to HKDF-SHA-256, generating the Content-Encoding Key (CEK) and nonce. The final payload is encrypted using AES-128-GCM and framed as the aes128gcm content encoding.

Derivation Workflow (RFC 8291 §3.3 & §3.4):

  1. Extract p256dh (client public key, base64url) and auth (16-byte auth secret, base64url) from the subscription
  2. Generate an ephemeral server key pair (P-256) per-message for forward secrecy
  3. Derive ecdh_secret via ECDH between ephemeral server private key and client public key
  4. Compute prk = HKDF-Extract(salt=auth, IKM=ecdh_secret)
  5. Compute content_encryption_key = HKDF-Expand(prk, info="Content-Encoding: aes128gcm\x00", 16 bytes)
  6. Compute nonce = HKDF-Expand(prk, info="Content-Encoding: nonce\x00", 12 bytes)
const webpush = require('web-push');

// Production: let the library handle ECDH + HKDF + AES-128-GCM per RFC 8291
async function encryptAndSend(subscription, payloadObject) {
  const options = {
    vapidDetails: {
      subject:    'mailto:ops@yourdomain.com',
      publicKey:  process.env.VAPID_PUBLIC_KEY,
      privateKey: process.env.VAPID_PRIVATE_KEY
    },
    TTL: 86400
  };

  // web-push internally:
  //   1. Generates an ephemeral P-256 keypair
  //   2. Derives shared secret via ECDH with subscription.keys.p256dh
  //   3. Runs HKDF-SHA-256 to produce the CEK and nonce
  //   4. Encrypts with AES-128-GCM per RFC 8291 aes128gcm content encoding
  //   5. Attaches the VAPID Authorization header
  return webpush.sendNotification(
    subscription,
    JSON.stringify(payloadObject),
    options
  );
}

The VAPID public and private keys are read from the environment — they must never be hardcoded in server-side code. Key lifecycle for the signing pair is covered separately in VAPID Key Generation & Rotation.

Two properties of this derivation are worth internalizing because most decryption bugs trace back to violating one of them. First, the info strings passed to HKDF-Expand are exact byte sequences — "Content-Encoding: aes128gcm\x00" for the key and "Content-Encoding: nonce\x00" for the nonce — and a single wrong byte, including a missing trailing \x00, yields a key the browser cannot reproduce, so it silently fails to decrypt. Second, the ephemeral server keypair must be regenerated for every single message; reusing it across messages reuses the nonce under the same key, which not only breaks the spec’s forward-secrecy guarantee but undermines AES-GCM’s confidentiality entirely. A vetted library enforces both invariants automatically, which is the strongest argument against hand-rolling the stack.

Server-Side Encryption Workflow

Once cryptographic parameters are resolved, the plaintext payload is padded to prevent length-based fingerprinting, encrypted, and packaged into the HTTP POST body. The Content-Encoding: aes128gcm header must be explicitly set.

Packaging & Dispatch Steps:

  1. Serialize JSON payload to a UTF-8 buffer
  2. Apply a random padding delimiter byte (value 0x02 per RFC 8291) followed by zero or more 0x00 padding bytes to obscure content length
  3. Encrypt the padded plaintext with AES-128-GCM using the derived CEK and nonce
  4. Construct the aes128gcm binary record: [salt (16 bytes)][record_size (4 bytes, big-endian)][key_id_len (1 byte)][key_id][ciphertext + auth-tag (16 bytes)]
  5. POST to the push endpoint with the correct headers
const webpush = require('web-push');
const crypto  = require('crypto');

/**
 * Sends an encrypted push notification using the web-push library.
 * The library handles aes128gcm framing, ECDH, HKDF, and VAPID signing.
 */
async function dispatchEncryptedPush(subscription, payload, vapidDetails) {
  const response = await webpush.sendNotification(
    subscription,
    JSON.stringify(payload),
    {
      vapidDetails,
      TTL: 86400,
      urgency: 'high',
      contentEncoding: 'aes128gcm' // default; explicit for clarity
    }
  );
  return response;
}

The single-record framing is what distinguishes the modern aes128gcm encoding from the deprecated aesgcm scheme it replaced. In aes128gcm, the salt and the sender’s public key (the key_id) travel inside the binary record itself, so the only HTTP header the receiver needs is Content-Encoding: aes128gcm. The older aesgcm encoding carried the salt and key in separate Encryption and Crypto-Key headers, which proved fragile across proxies and is why every current implementation should emit aes128gcm exclusively. If you inherit a codebase still setting Encryption/Crypto-Key headers, that is a migration to schedule, not a configuration to preserve.

Proper service worker lifecycle management ensures encrypted messages are routed correctly, which depends heavily on established Service Worker Registration Patterns. When the browser rejects a record at the decryption stage, the aes128gcm encoding & decryption errors guide walks through each error and its root cause.

Encryption Parameter Reference

Parameter Type Value Notes
Content encoding string aes128gcm RFC 8291; set Content-Encoding header explicitly
Curve EC curve P-256 (secp256r1) Both client and ephemeral server keys
KDF function HKDF-SHA-256 auth secret used as salt
salt bytes 16 Random per message; prepended to the record
record_size (rs) uint32 BE ≤ 4096 typical Declares record length in the header
CEK bytes 16 Output of HKDF-Expand for AES-128
nonce bytes 12 Output of HKDF-Expand; AES-GCM IV
Auth tag bytes 16 Appended to ciphertext by GCM
Padding delimiter byte 0x02 Followed by 0x00 padding bytes

Authentication & Key Lifecycle Integration

Encryption ensures confidentiality; VAPID handles authorization. The two operate independently but must be coordinated during request construction. VAPID JWTs are signed with the application’s ECDSA P-256 private key and attached to the Authorization: WebPush <token> header. To prevent delivery failures or unauthorized access, implement automated key lifecycle policies aligned with VAPID Key Generation & Rotation, and when you must swap keys on an active install base follow rotating VAPID keys without losing subscribers.

Key Management Directives:

  • Sign VAPID JWTs with explicit sub, exp (≤ 24 h), and aud claims using ES256
  • Store encryption parameters (p256dh, auth) separately from VAPID signing keys in a secrets manager
  • Implement cryptographic key versioning to enable seamless, zero-downtime rotation

Browser Constraints & Size Optimization

Encryption introduces overhead that reduces usable payload space. RFC 8291 aes128gcm overhead consists of: 16-byte salt, 4-byte record-size, 1-byte key-ID length field, 16-byte AES-GCM auth tag, and at least 1 padding byte — totaling roughly 38 bytes minimum plus any explicit padding you add. The overall encrypted ciphertext must fit within the push service’s per-message 4 KB payload size limit (nominally 4 KB per RFC 8030). Understanding maximum payload size limits for Chrome vs Firefox is critical for designing resilient notification architectures, and the per-engine rendering differences are catalogued in Cross-Browser Notification Quirks.

Optimization & Fallback Strategy:

  • Keep plaintext JSON payloads under 3 KB to guarantee universal browser compatibility after encryption overhead
  • Implement server-side payload minimization: transmit only identifiers and action metadata; fetch heavy content client-side after the notification triggers
  • Validate encrypted size before dispatching to preempt 413 Payload Too Large responses

The “send an identifier, fetch the body” pattern is worth treating as the default rather than an optimization for large payloads only. A push carrying a single notification ID lets the service worker call your API on push to retrieve the current title, body, and image, which means a notification can reflect state that changed after dispatch and you never risk the 4 KB ceiling. The trade-off is that the fetch must complete inside the worker’s execution budget and must itself be wrapped in event.waitUntil(); if the network is unavailable, fall back to a generic notification rather than letting the event terminate without a visible result. Padding deserves a deliberate decision too: RFC 8291 lets you add 0x00 bytes after the 0x02 delimiter to mask the true plaintext length, which matters when the mere size of a notification could leak information (a short “approved” versus a long “declined, see details”). For most applications a small fixed pad is sufficient; for sensitive flows, pad to a constant bucket size.

Verification

Confirm the encryption path before shipping. The fastest signal is a 201 Created from the push service and a delivered notification in the target browser.

# Send a test push and observe the status code; expect 201 Created on success
node -e "require('./send-test-push.js')" && echo "dispatch returned 2xx"

In Chrome, open chrome://serviceworker-internals, push a test message, and confirm the push event fires with non-empty event.data. A decryption failure surfaces as the worker never receiving the event or event.data being null despite a 201 from the push service — that asymmetry is the canonical signature of a malformed aes128gcm record.

Error & Edge-Case Matrix

Condition Cause Fix
400 Bad Request from push service Malformed aes128gcm framing or missing Content-Encoding Use an audited library; set the header explicitly
401 Unauthorized Invalid or expired VAPID JWT Re-sign with the correct key; check exp and clock drift
413 Payload Too Large Encrypted record exceeds 4 KB Trim plaintext under ~3 KB; fetch content client-side
Worker receives null event.data Wrong p256dh/auth, or record built with a stale key Re-read subscription keys; re-subscribe on pushsubscriptionchange
Intermittent decryption failures Reused nonce or non-ephemeral server key Generate a fresh ephemeral keypair per message
Payload visible in transit logs Logging decrypted content Log only delivery metadata, never plaintext

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 for pushsubscriptionchange events to proactively renew subscriptions.

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

Back to Core Protocols & Browser Implementation

FAQ

What content encoding does web push use?

Modern web push uses aes128gcm as defined in RFC 8291. The plaintext is encrypted with AES-128-GCM using a key derived via HKDF-SHA-256 from an ECDH shared secret, and framed as a single aes128gcm record. The legacy aesgcm encoding is deprecated; new implementations should always emit aes128gcm.

What is the maximum payload size after encryption?

The encrypted record must fit within roughly the 4 KB payload size limit. RFC 8291 framing adds about 38 bytes of fixed overhead (16-byte salt, 4-byte record size, 1-byte key-ID length, 16-byte auth tag, 1+ padding byte), so keep plaintext JSON under about 3 KB. Exceeding the limit returns 413 Payload Too Large.

Should I implement RFC 8291 encryption myself?

No. Use an audited library such as web-push (Node.js) or pywebpush (Python). Hand-rolling ECDH, HKDF, padding, and AES-GCM framing is error-prone, and a single mistake — a reused nonce, wrong info string, or off-by-one in the record header — produces a record the browser silently fails to decrypt.

Why does the push service return 201 but no notification appears?

A 201 Created only confirms the push service accepted and queued the record; it does not confirm successful decryption. If event.data is null in the service worker, the record was built with the wrong p256dh/auth keys or a malformed aes128gcm frame. Re-read the subscription keys and re-subscribe on pushsubscriptionchange.

Where do the VAPID keys fit relative to encryption?

Encryption (RFC 8291) protects payload confidentiality between server and browser; VAPID (RFC 8292) authorizes the request via a signed JWT. They are independent: the ephemeral ECDH keypair encrypts the body, while the persistent ECDSA P-256 VAPID keypair signs the Authorization header. Both must be correct for delivery to succeed.