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
PushSubscriptionwithendpoint,keys.p256dh, andkeys.authcaptured server-side.process.env.VAPID_PUBLIC_KEY/process.env.VAPID_PRIVATE_KEY— never hardcoded in server code.web-pushfor Node.js,pywebpushfor Python) — do not hand-roll the cipher stack.- Service Worker Registration Patterns to receive and decrypt messages.
Implementation Prerequisites (mapping):
- Extract the
PushSubscriptionobject structure:endpoint,keys.p256dh, andkeys.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
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):
- Extract
p256dh(client public key, base64url) andauth(16-byte auth secret, base64url) from the subscription - Generate an ephemeral server key pair (P-256) per-message for forward secrecy
- Derive
ecdh_secretvia ECDH between ephemeral server private key and client public key - Compute
prk= HKDF-Extract(salt=auth, IKM=ecdh_secret) - Compute
content_encryption_key= HKDF-Expand(prk, info="Content-Encoding: aes128gcm\x00", 16 bytes) - 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:
- Serialize JSON payload to a UTF-8 buffer
- Apply a random padding delimiter byte (value
0x02per RFC 8291) followed by zero or more0x00padding bytes to obscure content length - Encrypt the padded plaintext with AES-128-GCM using the derived CEK and nonce
- Construct the
aes128gcmbinary record:[salt (16 bytes)][record_size (4 bytes, big-endian)][key_id_len (1 byte)][key_id][ciphertext + auth-tag (16 bytes)] - 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), andaudclaims 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 Largeresponses
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
pushsubscriptionchangeevent spikes indicating mass expiration or compromise - Conduct quarterly cryptographic dependency audits to patch vulnerabilities in underlying TLS/HKDF implementations
Related
- Maximum payload size limits for Chrome vs Firefox — exact per-engine ceilings after encryption overhead.
- aes128gcm encoding & decryption errors — diagnosing browser-side decrypt failures.
- VAPID Key Generation & Rotation — the signing key lifecycle that pairs with encryption.
- Service Worker Registration Patterns — the worker that receives and parses the decrypted payload.
- Cross-Browser Notification Quirks — rendering differences once the payload is decrypted.
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.