Debugging aes128gcm Encoding & Decryption Errors

When a push send returns a 400 Bad Request or the browser drops the message silently, the cause is almost always a malformed aes128gcm payload — wrong content-encoding, a legacy scheme, corrupt subscription keys, or a record that violates RFC 8291.

Quick answer

Web Push payloads must be encrypted with the aes128gcm content-encoding defined in RFC 8291, and the encrypted ciphertext must stay under the 4 KB payload size limit (budget ~3.5 KB of plaintext for encryption overhead). The common failures are: sending the legacy aesgcm scheme instead of aes128gcm, a mismatched or truncated Content-Encoding header, invalid p256dh or auth keys from the subscription (wrong base64url decoding), an incorrect salt (16 bytes) or record size in the RFC 8291 header, or a payload that simply exceeds 4 KB. Use an audited library and never roll your own ECDH/HKDF unless you must.

Why this happens

RFC 8291 defines a precise binary layout. The server performs an ECDH key exchange against the subscription’s p256dh public key, mixes in the auth secret via HKDF to derive a content-encryption key and nonce, and emits a single record framed by a header: a 16-byte salt, a 4-byte record size, a 1-byte key-id length, the server’s ephemeral public key, then the AES-128-GCM ciphertext. The Content-Encoding: aes128gcm header tells the browser to decode exactly this structure. Any deviation — a salt of the wrong length, a record size that disagrees with the body, the older aesgcm framing, or keys decoded with standard base64 instead of base64url — produces a record the browser cannot decrypt, and it discards the message without showing anything.

The legacy aesgcm scheme (the pre-RFC-8291 draft) carried the salt and key in separate Encryption and Crypto-Key HTTP headers rather than inside the body. Modern browsers and push services expect the single-header aes128gcm form. Mixing the two — for example sending aes128gcm content-encoding but draft-style headers — guarantees a decryption failure. For the end-to-end cryptographic workflow and the payload-construction rules, see the Push API payload encryption reference and the parent Core Protocols & Browser Implementation overview.

The reason these errors are so hard to diagnose is that AES-GCM is an authenticated cipher: the browser does not partially decrypt and warn you about the bad byte. It computes the authentication tag over the ciphertext and the derived key, finds it does not match, and discards the entire record. There is no callback, no console error in the page, and frequently no error in your server logs either, because the push service accepted the bytes and only the browser rejected them. The push service validates the envelope (headers, size, VAPID signature) and returns 201 Created, while the browser validates the contents and silently drops them. A 201 from the push service therefore tells you nothing about whether the payload decrypted — it only means the message was queued.

The derivation chain is where most custom implementations go wrong. RFC 8291 specifies HKDF with the auth secret as salt to produce an intermediate key, then a second HKDF pass keyed by the record salt to produce the content-encryption key and the 12-byte nonce. The “info” strings are exact ASCII constants (WebPush: info, Content-Encoding: aes128gcm, Content-Encoding: nonce); a single wrong byte in any of them yields a key that encrypts fine on your side but cannot be reproduced by the browser. Because the failure is symmetric-looking — your code “works” end to end in a unit test that uses your own derivation on both sides — it only surfaces against a real browser.

Correct encryption with a library

The reliable path is to let an audited library handle ECDH, HKDF, salt generation, and framing. The web-push library defaults to aes128gcm on modern versions.

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

webpush.setVapidDetails(
  'mailto:ops@yourdomain.com',
  process.env.VAPID_PUBLIC_KEY,   // never hardcode the VAPID public key server-side
  process.env.VAPID_PRIVATE_KEY
);

async function sendEncrypted(subscription, payload) {
  const body = JSON.stringify(payload);

  // Enforce the 4 KB ciphertext limit at the plaintext layer (overhead ~600 B)
  if (Buffer.byteLength(body) > 3500) {
    throw new Error('Payload too large: aes128gcm ciphertext would exceed 4 KB');
  }

  return webpush.sendNotification(subscription, body, {
    TTL: 86400,
    contentEncoding: 'aes128gcm', // explicit; the default on current web-push
  });
}

If you hand-roll encryption, the salt must be exactly 16 random bytes, the record size must match the encrypted body length, and the subscription keys must be decoded as base64url. The header layout below is what the browser parses.

POST /push/abc123 HTTP/1.1
Content-Encoding: aes128gcm
TTL: 86400
Content-Type: application/octet-stream

[16-byte salt][4-byte record size][1-byte keyid len][65-byte ephemeral pubkey][AES-128-GCM ciphertext]

Why hand-rolling encryption is rarely worth it

The pull toward implementing RFC 8291 directly usually comes from wanting to avoid a dependency or to encrypt in an environment without a maintained library. The hidden cost is that every part of the pipeline must be byte-exact and there is no friendly error when it is not. The ECDH must use the P-256 curve and produce an uncompressed 65-byte point; HKDF must use SHA-256 with the precise info strings; the salt must be 16 fresh random bytes per message; the nonce must be 12 bytes derived from the second HKDF pass; the record size in the header must equal the actual encrypted length; and the padding delimiter byte (0x02 for the last record) must be present before the GCM tag. A library encodes all of this once and is tested against real browsers. If you must implement it yourself — say, inside a constrained edge runtime — port a reference implementation rather than working from the RFC prose, and validate against an actual Chrome and Firefox before trusting it, because a unit test that uses your own derivation on both sides will pass even when the output is unintelligible to a browser.

Diagnostic steps

  1. Confirm the content-encoding. Inspect the outgoing request: it must read Content-Encoding: aes128gcm. If you see aesgcm, upgrade your library or switch the scheme — the legacy form is the most frequent culprit.
  2. Validate the subscription keys. Log subscription.keys.p256dh and subscription.keys.auth. The p256dh decodes to 65 bytes (uncompressed P-256 point) and auth to 16 bytes. Use base64url decoding, not standard base64 — a +// versus -/_ mismatch corrupts the key.
  3. Check the salt length. RFC 8291 requires a 16-byte salt in the record header. A salt of any other length makes the body undecryptable.
  4. Verify the record size field. The 4-byte record size must be consistent with the actual ciphertext length. A stale or hardcoded value desynchronizes the parser.
  5. Measure the payload. If the encrypted body exceeds 4 KB the push service rejects it with 413 Payload Too Large. Trim to identifiers and fetch the rest client-side.
  6. Reproduce with a known-good library. Send the same subscription a one-line webpush.sendNotification. If that succeeds, the bug is in your custom encryption, not the subscription.

Error-to-cause reference

Observed signal Likely cause Fix
400 Bad Request from push service Malformed header, wrong Content-Encoding, bad VAPID Use aes128gcm; verify signature and header layout
413 Payload Too Large Ciphertext over the 4 KB limit Trim plaintext to ~3.5 KB; send an id, fetch the rest
201 Created but nothing arrives Browser failed GCM auth (bad key/salt/nonce) Reproduce with a library; check base64url decoding
Works in Chrome, fails in Firefox Stricter Mozilla autopush validation Conform exactly to RFC 8291 framing
Random intermittent failures Reused salt or stale record-size field Generate a fresh 16-byte salt per message

The most misleading row is the third: a 201 Created looks like success but only confirms the push service queued the bytes. When notifications silently never appear despite clean send logs, suspect a decryption failure, not a delivery failure, and reproduce with a known-good library to isolate your encryption code.

Gotchas and edge cases

  • aesgcm vs aes128gcm are not interchangeable. They use different HTTP header layouts and salt placement. Sending one scheme’s framing under the other’s content-encoding always fails.
  • Standard base64 silently corrupts keys. p256dh and auth are base64url; decoding them with a standard base64 routine yields the wrong bytes and a decryption failure with no obvious error.
  • The 4 KB limit is on ciphertext, not your JSON. RFC 8291 adds roughly 600 bytes of header and tag overhead, so a 4 KB plaintext can push the encrypted record over the limit. Budget around 3.5 KB.
  • A wrong nonce/record-size pairing decrypts to garbage. AES-GCM authentication then fails, and the browser drops the message with no user-visible error — it looks like a delivery failure, not an encryption bug.
  • Firefox validates more strictly than Chrome. A malformed record Chrome tolerates may be rejected outright by Mozilla autopush, so test both engines when debugging.

Back to Push API Payload Encryption