Web Push Encryption Field Guide: 41 Bytes In, 144 Bytes Out

Web push encryption byte anatomy: 41 bytes of plaintext become a 144-byte RFC 8291 aes128gcm push body

The byte anatomy of an encrypted push message. Every Web Push send that reaches a modern browser is wrapped in a small, rigid envelope: a salt, a record size, a key id, the ciphertext, and a GCM tag. Take the RFC 8291 reference vector — 41 bytes of plaintext — and you get a 144-byte body. Those 103 extra bytes are not padding waste; they are the entire reason a push service can decrypt your message without ever learning your subscription key. This guide walks the envelope byte by byte, shows where the modern RFC 8291 layout differs from the legacy draft-04 one still alive on old user agents, and lists the five rules that decide whether a send actually lands on a device.

Web Push is one of those protocols where the interesting part happens in bytes you never see. The application hands a library a JSON string and gets back an HTTP request. In between, three RFCs cooperate: RFC 8291 encrypts the payload, RFC 8292 authenticates the sender, and RFC 8030 says how the request should look on the wire. The encoder at Elysia Tools produces all three and then decrypts its own output to prove the result is correct.

Where the 144 Bytes Go

Take the RFC 8291 §5 reference vector. The plaintext is “When I grow up, I want to be a watermelon” — 41 bytes of UTF-8. The encrypted body that comes out the other side is exactly 144 bytes.

Field breakdown of the 144-byte encrypted push body: 16-byte salt, 4-byte record size, 1-byte key id length, 65-byte key id, 58 bytes of ciphertext and GCM tag

Here is the layout, byte offsets included:

  • Salt — 16 bytes, offset 0 to 15. A fresh random value for every single message. RFC 8188 requires it, and it is the reason two pushes carrying the same text never produce the same ciphertext.
  • Record size — 4 bytes, offset 16 to 19. Always 4096 here, written big-endian, so the bytes read 00 00 10 00. The record size tells a receiver how much ciphertext belongs to one record.
  • Key id length — 1 byte, offset 20. The value 65. It counts the bytes of the key id that follow.
  • Key id — 65 bytes, offset 21 to 85. The uncompressed P-256 public point of the application server’s ephemeral key: one 0x04 prefix byte plus 32 bytes of X and 32 bytes of Y. This is the one piece of key material the receiver can see in the clear.
  • Ciphertext — 42 bytes, offset 86 to 127. Your 41 bytes of plaintext, plus a single 0x02 delimiter byte, encrypted with AES-128-GCM.
  • GCM tag — 16 bytes, offset 128 to 143. The authentication tag. Without it the ciphertext is forgeable.

The header — salt, record size, key id length, key id — is 86 bytes of the 144. The rest is 42 bytes of ciphertext and 16 bytes of tag. The content encoding key that actually protects the payload is never transmitted at all; it is derived independently on both sides.

The Three Inputs a Subscription Carries

Every Web Push send starts with two strings the browser handed you at subscribe time, plus one key you generate. Getting the lengths right up front saves a lot of debugging later.

  • p256dh — 65 bytes. The browser’s P-256 public key as an uncompressed point, so it always starts with the byte 0x04. The encoder rejects anything that is not exactly 65 bytes or does not begin with that prefix. This is the value you put in the p256dh field of the subscription.
  • Auth secret — 16 bytes. A second secret, generated alongside the key pair. It is not a curve point and it is not derived from p256dh. Its only job is to bind the ECDH secret to this specific subscription, so that a shared secret captured elsewhere cannot be replayed here.
  • Your ephemeral private key — 32 bytes. The raw P-256 scalar your application server generates per message. The public half of it becomes the key id in the body header.

Two details are easy to get wrong. First, the auth secret is a second secret, not a second half of the first one — some implementations confuse it with a private key. Second, the application server’s private key must be fresh per message; the subscription’s key never encrypts anything, it is only the peer’s half of the exchange.

If you want to follow the same derivation in another language, the cryptography samples collection walks through symmetric encryption, hashing and key exchange with runnable code in several languages. For the base64url handling that every key in this protocol passes through, the Base64URL encoder is the tool to reach for — standard base64 breaks these keys, because the plus and slash characters are not URL-safe.

How the Keys Are Actually Derived

Getting the envelope right is only half the job. The keys inside it come from a chain of four derivations, and the order matters.

Step one is the ECDH secret. The application server computes an ECDH exchange between its ephemeral private key and the subscription’s p256dh public key. The result is a 32-byte shared secret. The receiver can compute the same value because it holds the private half of p256dh.

Step two is key combining. The shared secret alone is not enough, because the receiver’s auth secret differs from the application server’s view of it. RFC 8291 combines them with HKDF-SHA-256 under the label “WebPush: info”, producing a 32-byte input keying material. The context string binds the derivation to this one protocol, so the same secrets cannot be replayed into a different context.

Step three splits the input keying material two ways. A second HKDF extract with the salt produces a pseudorandom key, and two expands from that produce the actual AES-128-GCM content encryption key (16 bytes) and the nonce (12 bytes). The nonce is derived, never random — which is correct, because a random nonce alongside a derived key would be a second, separate failure mode to get right.

Step four is the AES-GCM pass. The plaintext gets a 0x02 delimiter appended (and any padding you asked for after it), then AES-128-GCM encrypts it with the derived key and nonce. The tag comes out of that call, not from a separate step.

Run the reference vector through that chain and you land on the RFC’s own intermediates: a content encryption key of oIhVW04MRdy2XN9CiKLxTg, a nonce of 4h_95klXJ5E_qnoN, and an ECDH secret of kyrL1jIIOHEzg3sM2ZWRHDRB62YACZhhSlknJ672kSs. The encoder’s decrypt round-trip then reproduces your 41 bytes exactly, which is the only proof that matters — a body can look plausible and still be undecryptable.

The Same Idea, Two Different Envelopes

Old user agents still speak the draft-04 content coding, and the difference is not cosmetic. The modern encoding puts the key id inside the body. The legacy encoding puts the salt and the ephemeral public key in HTTP headers instead, and the body carries only ciphertext plus tag.

Comparison of the RFC 8291 aes128gcm body layout against the legacy draft-04 aesgcm header layout
AspectRFC 8291 (aes128gcm)draft-04 (aesgcm)
Where the salt livesBody, bytes 0 to 15Encryption: salt=… header
Where the key id livesBody, bytes 21 to 85Crypto-Key: dh=… header
Padding directionTrailing, after a 0x02 delimiterLeading zeros, before the plaintext
Key context labelContent-Encoding: aes128gcmContent-Encoding: aesgcm plus a P-256 frame
Plaintext example41 bytes becomes a 144-byte body15 bytes becomes a 33-byte body

The draft-04 key contexts are worth a second look, because they are long enough to be easy to get wrong. The content encryption key context is 165 bytes: the literal “Content-Encoding: aesgcm” with a null terminator, the literal “P-256” with a null terminator, then a 2-byte length and the 65-byte receiver public key, then a 2-byte length and the 65-byte sender public key. The nonce context is the same construction one byte shorter, because “nonce” is one character shorter than “aesgcm”. Every length is big-endian.

Both encodings are still needed. Every modern browser — Chrome 97 and up, Firefox 96 and up, Safari 16.4 and up — speaks aes128gcm. The legacy path exists for user agents that have not been updated in years, and it costs you nothing to keep the switch available.

Five Rules Before You Ship a Push

The cryptography above is deterministic and testable. The operational rules around it are where sends actually fail.

Five operational rules for Web Push: fresh salt, one-use key, separate VAPID key, 4079-byte payload ceiling and a 32-character topic cap
  1. Fresh salt for every message. Sixteen random bytes, generated per push. The RFC’s own example uses DGv6ra1nlYgDCS1FRnbzlw. Reusing a salt across messages is the classic mistake, because it removes the only thing that makes identical plaintexts produce different ciphertexts.
  2. One-use application server key. A fresh P-256 key pair per send, 32 bytes of private scalar. The subscription key never encrypts anything — it is the peer’s half of the ECDH exchange, nothing more.
  3. A separate VAPID signing key. RFC 8292 forbids signing the authorization JWT with the encryption key. The encoder enforces this and rejects the request outright if the two private keys match, which is the correct failure mode: a hard error beats a silently weak construction.
  4. Respect the payload ceiling. The encoder accepts up to 4096 bytes and stops at 4079 bytes of plaintext, because the delimiter byte and the 16-byte tag have to fit too. A 4079-byte message and a 4096-byte payload are not the same thing.
  5. Keep the topic short. RFC 8030 caps the coalescing key at 32 characters. The tool’s own schema allows 64, and sanitizes the value down to letters, digits, hyphen, underscore, dot and tilde — a field that accepts more than the RFC permits is a field that will eventually ship an invalid header.

Alongside those, remember that a TTL of 0 means deliver-now-or-drop, and that the topic mechanism replaces any queued message carrying the same topic. For the HTTP shape these requests take, the HTTP headers analyzer reads back a captured request; for VAPID’s JWT form specifically, the JWT inspector decodes the aud, exp and sub claims you are about to send.

The VAPID Half: Who Is Allowed To Push

Encryption answers “can the receiver read this”. VAPID, from RFC 8292, answers a different question: “is this push service allowed to talk to me at all”.

VAPID is a signed token, not a secret. The application server holds a long-lived P-256 key pair, signs a small JSON claim set, and puts the result in an Authorization header alongside the public key. The push service verifies the signature and the claims, then decides whether to deliver.

The claim set is three fields. The aud claim is the origin of the push service you are talking to, and the encoder normalizes a bare host into an https:// origin for you — pass push.example.net and it signs https://push.example.net. The exp claim is a Unix timestamp in seconds; the default in the encoder is a fixed value, so set it yourself to a real expiry. The sub claim is a contact URI, and the encoder rejects anything that is not a mailto: or https: address.

The signature is ES256 — ECDSA over P-256 with SHA-256 — and the raw 64-byte r-then-s form, not the DER encoding most signing APIs return by default. That single detail is the most common reason a VAPID token gets rejected: a DER signature is roughly 70 bytes and will not verify.

The public key travels in the k parameter of the same header, as an uncompressed point in base64url. The encoder signs the token, then immediately verifies it against that same k value before showing it to you, so a broken signature shows up in the output rather than as a mystery 403 from a push service.

A VAPID key is long-lived and a subscription key is not the same thing. When the VAPID key rotates, every browser that subscribed under the old key needs to resubscribe. Plan for that.

APNs and FCM Read the Same Headers

Web Push does not exist in a vacuum. The push service ultimately hands your payload to Apple or Google, and both speak in terms that RFC 8030 does not define. The encoder carries a cross-walk table for exactly this reason.

TTL becomes expiry. A TTL of 10 in the Web Push request maps to an apns-expiration absolute Unix timestamp on the APNs side, and to time_to_live on the legacy FCM side.

Urgency becomes priority. The RFC 8030 Urgency header has four values, but APNs has two priorities. very-low and low both become apns-priority 5 with apns-push-type background; normal and high become apns-priority 10 with apns-push-type alert. Note what that means in practice: you cannot ask for a silent background delivery that is also urgent. The encoder maps low urgency to 5 and everything else to 10 — the two middle RFC values are not distinguishable on the APNs path.

Topic becomes a collapse id. The coalescing key maps to apns-collapse-id and to FCM’s collapse_key, so a queued message with the same topic gets replaced rather than stacked.

The payload itself does not cross over directly. APNs delivers the payload inside aps.alert through a Notification Service Extension; FCM delivers a JSON notification payload. The content encoding stays on the Web Push leg only.

That last point is the one that surprises people. A Web Push payload is not an APNs payload and not an FCM payload — it is encrypted for the browser, and the push service relays opaque bytes.

Putting It Together

The shape of a correct Web Push send is not complicated, but it is unforgiving, because every one of its parts is checkable and every one of them is easy to get subtly wrong.

The encoder takes a payload and a subscription, derives the four keys in the right order, lays out the RFC 8188 header, encrypts, signs a VAPID token, assembles the HTTP/2 request, and then decrypts its own output to confirm the plaintext matches. For the reference vector that means a 41-byte message becomes a 144-byte body, the content encryption key is oIhVW04MRdy2XN9CiKLxTg, and the round-trip comes back clean. For the legacy draft-04 vector, the same machinery produces a 33-byte body with the salt and the ephemeral public key shipped as headers instead.

Two things to take away. First, the overhead is not incidental: 103 bytes of envelope for 41 bytes of text is the price of a scheme where the push service never holds your key. Second, the failures are almost never cryptographic — they are a reused salt, a DER-encoded signature, a payload that is 20 bytes too long, a topic that is 40 characters instead of 32.

Run the vectors, check the round-trip badge, and the rest of the protocol follows from the bytes. You can try the encoder yourself at Elysia Tools, or browse the full catalog of format conversion tools.

Comments

No comments yet. Why don’t you start the discussion?

    Leave a Reply

    Your email address will not be published. Required fields are marked *