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 before the service worker’s push event ever fires.

aes128gcm record layout The record begins with an 86-byte header made of a 16-byte salt, a 4-byte record size, a 1-byte key id length and a 65-byte ephemeral public key, followed by the variable-length ciphertext and a 16-byte AES-GCM authentication tag. Content-Encoding: aes128gcm — one record, one header, one tag record header — 86 bytes with an empty key-id salt 16 B record size 4 B key-id len 1 B ephemeral pubkey 65 B ciphertext variable, ends with 0x02 GCM tag 16 B 0 16 20 21 86 n−16 n 16 fresh random bytes per message — a reused salt gives intermittent failures Record size must equal the real encrypted length or the parser desynchronises The tag authenticates every preceding byte — one bad byte discards the whole record The browser re-derives the key from this header alone — nothing outside the body is available to it.
The single aes128gcm record: an 86-byte header, the ciphertext, and the 16-byte tag that authenticates all of it.

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.

Where the salt and server key travel in each scheme The legacy aesgcm draft carries the salt in an Encryption header and the server key in a Crypto-Key header with a ciphertext-only body, while RFC 8291 aes128gcm sends a single Content-Encoding header and packs the salt, record size, key id length and ephemeral key into the body. Where the salt and the server key travel aesgcm — pre-RFC draft HTTP headers Encryption: salt=… Crypto-Key: dh=… Content-Encoding: aesgcm Body ciphertext only — no framing Rejected by current push services and undecryptable in current browsers aes128gcm — RFC 8291 HTTP headers Content-Encoding: aes128gcm no Encryption header no Crypto-Key header Body salt · record size · key-id len · ephemeral key · ciphertext · tag The only scheme to send today everything the browser needs is in the body Mixing the two — draft-style headers under the modern content-encoding — fails every time.
The two schemes disagree about where the salt and the server's ephemeral key live, which is why their framings can never be mixed.

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.

Two validations, only one of them answers The push service validates header layout, body size and the VAPID signature and returns 201 Created. The browser then validates the key bytes, salt, record size and GCM tag, and on failure discards the record with no notification and no log entry visible to the sender. Two independent validations — only one of them talks back limit of server-side visibility App server POSTs the record Push service checks · header layout · body size under 4 KB · VAPID signature 201 Created Browser checks · base64url key bytes · 16-byte salt · record size field · AES-GCM auth tag record discarded No card no error event A 201 confirms queueing only. Decryption is verified on the far side of the dashed line, where your logs end. Reproduce with an audited library against a real browser to test the second gate.
The push service validates the envelope and answers; the browser validates the contents and stays silent.

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.

The RFC 8291 key derivation chain The subscription p256dh key and a per-message server ephemeral key produce a 32-byte ECDH shared secret. A first HKDF pass salted with the auth secret yields the input keying material, and a second pass salted with the record salt yields a 16-byte content encryption key and a 12-byte nonce used by AES-128-GCM. Every input in the derivation chain is byte-exact subscription p256dh 65 B, base64url server ephemeral key new for every message ECDH P-256 shared secret 32 B HKDF pass 1 salt = auth secret, 16 B info = WebPush: info yields IKM, 32 B HKDF pass 2 salt = the record salt aes128gcm info to CEK, 16 B nonce info to nonce, 12 B AES-128-GCM encrypt one record, 16-byte tag appended The info strings are exact ASCII constants — WebPush: info, Content-Encoding: aes128gcm and Content-Encoding: nonce. One wrong byte yields a key the browser can never reproduce.
Two HKDF passes stand between the ECDH shared secret and the key the browser must independently rederive.

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 — the 413 error walkthrough covers the response handling. 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, and see the maximum payload size limits for Chrome vs Firefox for the per-engine thresholds.
  • 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

FAQ

Why does a push send return 201 Created when no notification appears?

The 201 comes from the push service, which only validates the envelope: header layout, body size against the 4 KB payload size limit, and the VAPID signature. The record contents are validated later, by the browser, which recomputes the content-encryption key from the header and checks the AES-GCM authentication tag. If that check fails the browser discards the record with no error event and no console output. A 201 therefore confirms queueing and nothing else — treat missing notifications with clean send logs as a decryption failure, not a delivery failure.

How do I tell an aesgcm problem from a bad key or salt?

Read the outgoing request first. If Content-Encoding reads aesgcm, the scheme itself is wrong and every send will fail — upgrade the library. If it reads aes128gcm and the push service still returns 400, suspect the header layout or the VAPID signature. If the service returns 201 and nothing arrives, the framing was acceptable but the derived key was not: check that p256dh decodes from base64url to 65 bytes, that auth decodes to 16 bytes, that the salt is exactly 16 fresh random bytes, and that the 4-byte record size matches the real encrypted length.

Can I decrypt an aes128gcm payload server-side to verify it?

Yes, and it is the fastest way to prove your encryption is correct without a browser. Generate a throwaway P-256 key pair plus a 16-byte auth secret, build a synthetic subscription from them, encrypt with your production code path, then decrypt using an independent implementation — not the one you used to encrypt. If your own derivation appears on both sides of the test it will pass even when the output is unintelligible to a real browser, which is exactly the failure mode this page describes.