Two branches meet at one HTTPS POST: a signed VAPID JWT, ES256 on SubtleCrypto, that proves who is sending, and an aes128gcm encrypted body only the subscriber can read. The request carries Authorization: vapid, Content-Encoding: aes128gcm, TTL: 86400 and Content-Length: 189.

VAPID keys and Web Push from scratch: the complete Web Crypto implementation

Build the whole send path on Web Crypto alone, with no web-push library and no Node crypto: sign the ES256 VAPID JWT, derive the aes128gcm key and nonce with the exact RFC 8291 info strings, frame the body byte by byte, POST it with the right headers, and route every status code. Runs on Cloudflare Workers and any edge, and ports to Python, Go, or Rust.

Do you need the web-push library to send a push?#

No. Every step of the send path runs on Web Crypto and fetch, and on Cloudflare Workers, Deno or Bun the Node library will not load at all. Search for how to send a Web Push VAPID message and you land in one of two places. Conceptual explainers name the specs, draw a box labelled "encrypt", and hand the actual bytes to a library. Library READMEs, meanwhile, show you sendNotification and hide the protocol entirely. Both are written for Node, and many still reference the legacy aesgcm content coding that browsers deprecated years ago. As a result, the middle is empty: nobody shows the modern path built by hand.

That empty middle is exactly where you end up the day you move the send path to the edge. The Node web-push library depends on Node's crypto module, so it will not run on Cloudflare Workers, Deno, or Bun's edge surface. When that happens, you need to know what the library was doing for you. After all, you are about to do it yourself with the one crypto API those runtimes all share: Web Crypto. This page is that implementation. Five primitives, no dependencies, runnable in your browser, and portable to any language with an HKDF and an AES-GCM.

The five protocol steps, and who actually shows you each one

Protocol stepConceptual explainersweb-push READMEThis guide
VAPID JWT (ES256)Names itOne config callSigned live on SubtleCrypto, with the raw-vs-DER trap
ECDH on P-256Mentions itHidden in the libraryRunnable deriveBits over the client key
HKDF key derivationRarely namedHiddenThe exact RFC 8291 info strings, stepped
aes128gcm body framingA vague diagramHiddenA byte-addressable map you can click
Status-code handlingA list, maybeLeft to youAn interactive router over a live store

What does the Web Push VAPID send path look like?#

It is two branches that meet at one HTTPS POST: a signed VAPID JWT that proves who is sending, and an encrypted body only the subscriber can read. Before the zoom-ins, hold the shape of the whole thing. The push service relays that POST to a service worker on the device.

The from-scratch Web Push send pathTwo branches feed one POST. Identity: the VAPID key signs an ES256 JWT for the Authorization header. Encryption: ECDH on P-256 gives a shared secret, HKDF derives the content key and nonce, AES-128-GCM produces the ciphertext and tag, and the record is assembled. The push service relays the encrypted bytes to the service worker.

Every section below is a zoom into one node of this map: the JWT you sign, the three encryption steps, the body you assemble, the POST you send, and the statuses you route. None of them needs a dependency.

What are VAPID keys, and how do you sign the JWT with Web Crypto?#

VAPID keys are an ECDSA key pair on the P-256 curve that identifies your server to the push service, and the private half signs a short ES256 JWT sent with every push. VAPID (Voluntary Application Server Identification, RFC 8292, published on 29 November 2017) is how a push service knows a message came from you without any account or shared secret. The public key is your applicationServerKey; the private key signs a short JWT you attach to every send. Getting the JWT right is two parts: the claims you put in, and the signature you produce.

Generate the VAPID keys once#

You generate the VAPID keys once per application server and keep them. Every subscription is bound to the public key the page passed as applicationServerKey, so rotating the VAPID keys orphans every existing subscription until each browser subscribes again. Web Crypto generates the pair directly: export the public half raw, a 65-byte uncompressed P-256 point that encodes to 87 base64url characters, and keep the private half as a JWK on the server.

src/vapid-keys.js · js
// Generate the VAPID keys once, then store them. Never per send.
const keys = await crypto.subtle.generateKey(
  { name: 'ECDSA', namedCurve: 'P-256' },
  true,                                   // extractable, so both halves export
  ['sign', 'verify'],
)

const b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)))
  .replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')

// Public half: the raw 65-byte point the browser takes as applicationServerKey.
const publicKey = b64url(await crypto.subtle.exportKey('raw', keys.publicKey))

// Private half: a JWK whose d field is the 32-byte private scalar.
const privateJwk = await crypto.subtle.exportKey('jwk', keys.privateKey)

Store the private half of your VAPID keys like any other signing secret, in your platform's secret store and never in the repository. Anyone holding it and your subscription list can push to every subscriber you have.

The three claims: aud, exp, sub#

The payload is three claims, and each one maps to a specific failure when it is wrong. Sign this exact JSON, base64url-encoded, as the token payload.

vapid-claims.json · json
{
  "aud": "https://fcm.googleapis.com",
  "exp": 1720483200,
  "sub": "mailto:push@atyantik.com"
}

aud is the push service origin, scheme and host with no path; a token minted for one push service is rejected by another. exp is Unix seconds, at most 24 hours out, and a stale one is the classic 401. sub is a mailto: or https: contact so the push service can reach you about abuse. None of these is secret; the signature is what makes the token trustworthy.

Signing with ES256, and the raw-vs-DER trap#

ES256 is ECDSA on P-256 with SHA-256. The trap that silently breaks ports is the signature encoding. JWS wants the raw r||s value, 64 bytes. OpenSSL and Node's jsonwebtoken hand back a DER-wrapped ECDSA signature that is around 70 to 72 bytes, and if you drop that into the token unconverted the push service returns 401. crypto.subtle.sign returns the raw form directly, which is one reason the Web Crypto path is less error-prone than the one you are porting from. Sign a token below and toggle the encoding to see the two shapes.

Sign a VAPID JWT with Web Crypto
Signature encoding

The claims you sign

The signed token on the wire

eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9 eyJhdWQiOiJodHRwczovL2ZjbS5nb29nbGVhcGlzLmNvbSIsImV4cCI6MTcyMDQ4MzIwMCwic3ViIjoibWFpbHRvOnB1c2hAYXR5YW50aWsuY29tIn0 ASdfUgY6zPTuB11MMH-9vReOgE3CjDQf9Amiij4PrNcRTO_rFy0LWBVlXzaPDQ_B_17fcBenOnhNaElYb7yC_g

raw r||s, 64 bytes. This is exactly what crypto.subtle.sign('ECDSA', …) returns, and exactly what JWS ES256 requires. base64url it and the push service verifies it.

The pre-signed example carries exp 1720483200, 9 Jul 2024, which has passed: a push service would reject it with 401. With JavaScript on, the signer sets exp to 12 hours from now and signs that.

Edit the claims and sign; the token is built and signed live by crypto.subtle in your browser, no jsonwebtoken and no Node crypto. With JavaScript off, a pre-signed worked example is shown.

How is a Web Push payload encrypted with aes128gcm?#

The server runs ECDH against the subscriber's p256dh key, derives a content key and nonce with HKDF, and encrypts with AES-128-GCM, so the push service relays bytes it cannot read. This is the internal the libraries hide most completely, and the strongest reason to read past a quick-start. Web Push payloads are encrypted to the subscriber's keys. The scheme is Message Encryption for Web Push (RFC 8291) using the aes128gcm content coding (RFC 8188). It is three real steps, each runnable on Web Crypto.

ECDH on P-256: the shared secret#

Every message starts with a fresh ephemeral server key pair and an elliptic-curve Diffie-Hellman against the client's p256dh. Both sides can compute the same 32-byte secret without it ever crossing the wire. The ephemeral key per message is what makes the same plaintext encrypt differently every time.

src/ecdh.js · js
// Import the client public key (p256dh) and generate an ephemeral server pair.
const clientPub = await crypto.subtle.importKey(
  'raw', p256dh,                          // the 65-byte uncompressed point
  { name: 'ECDH', namedCurve: 'P-256' }, false, [],
)
const server = await crypto.subtle.generateKey(
  { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits'],
)

// The 32-byte ECDH shared secret. The push service holds neither private key,
// so it relays the bytes without ever computing this.
const ecdhSecret = new Uint8Array(
  await crypto.subtle.deriveBits(
    { name: 'ECDH', public: clientPub }, server.privateKey, 256,
  ),
)

HKDF with the exact info strings#

The shared secret is not the key you encrypt with. RFC 8291 runs it through HKDF twice, and the byte-exact info arguments to those HKDF calls are the single detail no competitor prints. Get one byte of an info string wrong and every message fails the recipient's GCM tag check silently, with no error you can see server-side. Step through the derivation and watch the literal strings that produce the content key and the nonce.

HKDF, byte-exact (RFC 8291)
  1. Inputs

    from the subscription + a per-message ephemeral server key

    auth_secret · ecdh_secret 16 B · 32 B 4c 78 … 7f 18 · 34 03 84 9f … 83 99

    The auth secret and the client public key come from the PushSubscription. ecdh_secret is the P-256 ECDH output of your ephemeral private key and the client p256dh.

  2. HKDF-Extract for the key info

    PRK_key = HKDF-Extract(salt = auth_secret, IKM = ecdh_secret)

    PRK_key 32 B 08 a2 6e 1f c4 … 6d 71

    The auth secret is the salt; the ECDH secret is the input keying material. HMAC-SHA-256 with these gives a 32-byte pseudorandom key.

  3. HKDF-Expand to the shared IKM

    IKM = HKDF-Expand(PRK_key, key_info, 32)

    key_info = "WebPush: info" 0x00 ua_public (65 B) as_public (65 B)

    IKM 32 B c6 5c 46 4c 2a … 6f 2f

    The literal ASCII string "WebPush: info", a single null byte, then both public keys concatenated. Binding both keys in is what ties this material to this exact sender-recipient pair.

  4. HKDF-Extract with the message salt

    PRK = HKDF-Extract(salt = random(16), IKM)

    salt · PRK 16 B · 32 B 49 5f 35 c8 … ed 28 · b1 7c 90 2a … 44 e0

    A fresh random 16-byte salt per message re-extracts the IKM. This salt is the same value that heads the aes128gcm record on the wire.

  5. The content-encryption key

    CEK = HKDF-Expand(PRK, cek_info, 16)

    cek_info = "Content-Encoding: aes128gcm" 0x00

    CEK 16 B 56 10 2c 37 fe 45 51 6c 86 d8 ec 8b 22 06 f4 94

    The exact info string is "Content-Encoding: aes128gcm" plus one null byte. Get a single byte wrong here and every message you send fails the recipient GCM tag check silently.

  6. The nonce

    NONCE = HKDF-Expand(PRK, nonce_info, 12)

    nonce_info = "Content-Encoding: nonce" 0x00

    NONCE 12 B 98 eb be b8 d0 48 9e ba 02 5f e2 ff

    Same PRK, a different info string, a shorter output: the 12-byte AES-GCM nonce. The CEK and the nonce are the only two outputs the encrypt step needs.

Step through the derivation from the subscription secrets to the content-encryption key and nonce. The literal HKDF info strings are the detail the libraries hide. All hex is an illustrative worked example. With JavaScript off, every step is shown stacked.

AES-128-GCM: encrypt the padded plaintext#

With the content-encryption key and nonce in hand, the encryption itself is one call. RFC 8188 appends a single 0x02 padding delimiter to the plaintext first, then AES-128-GCM produces the ciphertext and a 16-byte authentication tag. GCM is authenticated, so any tampering in transit fails the tag and the browser drops the message before your service worker ever runs.

src/encrypt.js · js
// cek and nonce come from the HKDF step. RFC 8188 appends a single 0x02
// padding delimiter to the plaintext before encryption.
const key = await crypto.subtle.importKey('raw', cek, 'AES-GCM', false, ['encrypt'])
const record = new Uint8Array([...plaintext, 0x02])

const sealed = new Uint8Array(
  await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv: nonce, tagLength: 128 }, key, record,
  ),
)
// `sealed` is the ciphertext followed by the 16-byte GCM tag, exactly the
// layout the aes128gcm record expects next.

How is the aes128gcm request body laid out?#

RFC 8188 puts a fixed header in front of the ciphertext and tag: the salt, a record size, and the length and value of the server public key. The ciphertext and tag are not the request body on their own; the recipient reads that header to re-derive the same key and nonce. This is the "read it byte for byte" promise made literal. Click any field to inspect its offset, length, and meaning.

The aes128gcm body, byte by byte

salt framing overhead

Offset
byte 0
Length
16 B

The random per-message salt from the second HKDF-Extract. It heads the record so the recipient can re-derive the same content key and nonce.

rs framing overhead

Offset
byte 16
Length
4 B

Record size, a big-endian uint32. For a single-record push it is the whole record length. It exists so a stream can be split into fixed-size records.

idlen framing overhead

Offset
byte 20
Length
1 B

The length of the key id that follows, as one byte. For Web Push it is always 0x41 = 65, the length of an uncompressed P-256 point.

keyid framing overhead

Offset
byte 21
Length
65 B

The ephemeral server public key, uncompressed. The recipient runs ECDH against it to recover the shared secret. This is why the server key ships in the clear inside the record.

ciphertext your payload

Offset
byte 86
Length
n B

Your plaintext plus a single 0x02 padding delimiter, encrypted with AES-128-GCM under the derived content key and nonce. This is the only part that carries your data.

tag framing overhead

Offset
byte 86 + n
Length
16 B

The AES-GCM authentication tag. Any tampering in transit fails this check and the browser drops the message before your service worker ever sees it.

Framing is 102 bytes on the wire, plus the 1-byte padding delimiter inside the plaintext: 103 bytes of fixed overhead against the 4,096-byte record ceiling. That leaves ~3,993 usable plaintext bytes. Go over and the push service returns 413.

Every field in the aes128gcm record
Field Offset Length Role
salt 0 16 B overhead
rs 16 4 B overhead
idlen 20 1 B overhead
keyid 21 65 B overhead
ciphertext 86 n B payload
tag 86 + n 16 B overhead
Click any field to inspect its offset, length, and meaning. The framing fields are pure overhead; only the ciphertext carries your data. With JavaScript off, the full field reference is in the table below.

The picture also settles the payload budget without a separate meter. Because that framing is fixed overhead on every single message, the practical rule is to send an id, not the object. So push a short notice with an identifier and a URL, then let the opened page or the service worker fetch the full record over a normal request. A push is a doorbell, not a delivery truck.

Which headers does the Web Push POST need?#

The Authorization header carries the VAPID JWT and key, Content-Encoding is aes128gcm, and three RFC 8030 headers, TTL, Urgency and Topic, decide how the message is delivered. Delivery is one HTTPS POST to the subscription endpoint (RFC 8030, published on 8 December 2016). The body is the record you just assembled; what carries the real delivery decisions is a handful of headers.

TTL, Urgency, Topic#

Three headers change how and whether a message is delivered. The defaults are rarely what you want, so set them deliberately.

The three RFC 8030 headers that change delivery behaviour

HeaderWhat it controlsValuesIf you omit it
TTLHow long the push service holds the message while the device is offlineSeconds, 0 to 2419200 (28 days)Behaviour varies by service; set it explicitly rather than guessing
UrgencyDelivery priority and the battery trade-off on the devicevery-low, low, normal, highnormal
TopicA collapse key: a newer message replaces an undelivered older one with the same topicAn opaque string, up to 32 base64url charactersNo collapsing; every queued message is delivered separately

Topic is the one teams discover late. Without it, a user who was offline for an hour comes back to ten stale copies of the same alert. With a shared topic, the push service keeps only the newest undelivered one. Use it for anything that supersedes itself, like a live score or an order status.

The full request, in fetch and raw#

Here is the assembled send with everything above in place, and the raw request it produces on the wire. The fetch version is the whole point of the from-scratch path: it is standard fetch plus Web Crypto, so it runs on every edge runtime without change.

js
// `body` is the assembled aes128gcm record:
//   salt | rs | idlen | keyid | ciphertext | tag
// `jwt` is the ES256 token you signed; `serverPubB64` is its public key.
const res = await fetch(subscription.endpoint, {
  method: 'POST',
  headers: {
    Authorization: `vapid t=${jwt}, k=${serverPubB64}`,
    'Content-Encoding': 'aes128gcm',
    'Content-Type': 'application/octet-stream',
    TTL: '86400',
    Urgency: 'normal',
    Topic: 'order-4210',
  },
  body,
})

// Everything above is standard fetch plus Web Crypto, so it runs unchanged on
// Cloudflare Workers, Deno, Bun, and the browser. No Node crypto, no library.
route(res.status, subscription)   // the status router from the next section

How should you handle each push service status code?#

Delete the subscription on 404 or 410, never on 401, and back off and retry on 429 or a 5xx. The send returns a status, and that status is an instruction. Mishandling one is how a working push system slowly rots. The two branches that matter most are opposite reflexes: delete on 410, but never delete on 401. Pick a status the push service returned and see the correct branch and what it does to your subscription store.

Route every push-service status

201 Accepted, keep

Queued for delivery. Store nothing new and move on. 201 means accepted, not delivered, so do not mark the message as read.

404 Delete, never retry

The endpoint is gone. The subscription no longer exists. Delete it from your store now and never retry it.

410 Delete, never retry

Gone. The single highest-value branch in a push system: the user cleared site data or uninstalled. Delete it immediately so your store stays honest.

413 Shrink, keep

Payload too large. You are over the ~3993-byte budget. Send an id and refetch instead of inlining the object. Keep the subscription.

429 Back off, keep

Rate limited. Honour Retry-After, back off, and requeue. The subscription is fine; you are sending too fast.

5xx Back off, keep

Push service error (500 / 502 / 503). Transient. Retry with exponential backoff and keep the subscription.

Your subscription store In your store: 3 · Pruned this run: 1

The focused row received the selected status
Subscription Client Result
sub_a1f9 Chrome · FCM pruned from store
sub_7c2e Firefox · autopush healthy
sub_b830 Safari · Apple healthy
sub_44d1 Edge · FCM healthy
Pick a status the push service returned and see the correct branch and its effect on the subscription store. With JavaScript off, every branch is listed below and the table shows its base state.

The single highest-value line in a push system is the one that deletes on 410. It is also the one most demos never write, because on localhost a subscription never dies. Real subscriptions die constantly and silently: a user clears site data, uninstalls, or the browser expires the endpoint, and it returns 404 or 410 forever after. Delete those and your store stays honest for years. Confuse a 401 for a dead subscription and you delete live subscribers over a token bug you could have fixed, usually a stale exp or a JWT signed with VAPID keys other than the pair the subscription was created with.

What does the service worker do when a push arrives?#

It shows the notification in the push event and focuses or opens the right tab in notificationclick. The last two hops run on the device, in the service worker, which can wake with no tab open. That is the whole point of push. It has two events to handle, and both are short.

The push event fires with the decrypted payload; call showNotification inside event.waitUntil, so the browser keeps the worker alive until the notification is shown. Meanwhile the notificationclick handler focuses an already-open tab for the target URL when there is one, and otherwise opens a new window. That small branch is the difference between a notification that feels native and one that reloads a fresh tab every time.

js
// sw.js runs in the service worker, even with no tab open.
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {}
  event.waitUntil(
    self.registration.showNotification(data.title ?? 'New update', {
      body: data.body,
      icon: '/icons/pwa-192.png',
      data: { url: data.url ?? '/' },
    }),
  )
})

Can you send Web Push from Python, Go or Rust?#

Yes. The send path needs five primitives, ES256 signing, ECDH, HKDF, AES-128-GCM and an HTTPS POST, and every mainstream language has all five. The reason to learn the path this way is that it is not tied to JavaScript. Each one sits in a standard library or a small, well-known crate. Move the same shape to your backend of choice and the protocol does not change.

The five send primitives and their equivalent in each ecosystem

PrimitiveWeb Crypto (edge, browser)NodePythonGoRust
Sign ES256 JWTcrypto.subtle.sign('ECDSA', …), raw r||scrypto.sign, convert DER to rawcryptography ec.ECDSA, decode_dss_signatureecdsa.Sign, then re-pack to rawp256 / ecdsa, Signature::to_bytes
ECDH on P-256crypto.subtle.deriveBits({name:'ECDH'})crypto ECDH / diffieHellmanprivate_key.exchange(ec.ECDH())ecdh.PrivateKey.ECDHp256::ecdh::diffie_hellman
HKDF-SHA-256crypto.subtle.deriveBits({name:'HKDF'})crypto.hkdfSynccryptography HKDFgolang.org/x/crypto/hkdfhkdf crate
AES-128-GCMcrypto.subtle.encrypt({name:'AES-GCM'})crypto.createCipheriv('aes-128-gcm')cryptography AESGCMcrypto/cipher NewGCMaes-gcm crate
HTTPS POSTfetchfetch / undici / httpshttpx / requestsnet/httpreqwest

The two places a port goes wrong are both in this table. First, the ES256 signature needs converting from DER to raw in every ecosystem except Web Crypto. Second, the HKDF info strings must be byte-identical to the ones the derivation step showed. Get those two right, however, and the rest is plumbing.

When should you not build Web Push from scratch?#

When you are on Node with no edge constraint and the web-push library already works, because hand-rolling adds cryptographic code for no gain. Understanding the internals is the point of this guide. Shipping the hand-rolled version is a narrower call. If you are already on Node and staying there, the web-push library is well-tested and there is no prize for replacing it. Build it yourself when you have a reason the library cannot serve.

Where to go next#

Start with the service worker if you have not shipped one yet. Web Push sits on top of it, so the mechanics of registration, caching, and updates are the companion read: building a real Progressive Web App with React. If the send path runs on the edge, how you render and run a React app on Cloudflare covers the same Workers runtime this backend lives in, and it is how we build on Cloudflare in practice. The key handling and encryption here are one slice of broader application security work, and keeping delivery fast is part of Core Web Vitals and performance.

If you want a second set of hands on a push system that has to survive its ninetieth day, you can hire Node.js developers or React developers from our team, or talk to us about the fit. No pressure and no lock-in: everything above is standard, documented web platform work you own outright, and it is the same approach we use to build and launch a production MVP.

Questions this post answers

How do I generate VAPID keys?
Call crypto.subtle.generateKey for an ECDSA key pair on P-256, export the public half raw as your applicationServerKey, and keep the private half as a JWK in your secret store. Generate them once per application server.
How long is a VAPID public key?
65 bytes. It is an uncompressed P-256 point, which encodes to 87 base64url characters.
Should I rotate my VAPID keys?
Not routinely. Every subscription is bound to the public key it was created with, so rotating the keys orphans every existing subscription until each browser subscribes again.
Why does the push service return 401?
VAPID auth failed. The usual causes are an exp claim more than 24 hours out or already past, an aud that does not match the endpoint origin, or a DER signature where JWS wants raw r||s. Fix the JWT and keep the subscription.
Can the web-push library run on Cloudflare Workers?
No. It depends on Node's crypto module, which Cloudflare Workers, Deno and Bun's edge do not provide. Build the send path on Web Crypto and fetch instead, which those runtimes all implement.
What does the Topic header do in Web Push?
It is a collapse key. A newer message with the same topic replaces an older one that is still undelivered, so a user who was offline gets one current alert instead of ten stale copies.
Portrait of Tirth Bodawala

Tirth Bodawala

Chief Technology Officer, Atyantik Technologies

Tirth leads software engineering at Atyantik Technologies, a software product studio building web platforms, mobile apps, and AI-integrated systems. He writes about shipping software that holds up in production, from payload encryption to the operational work that keeps a system honest past launch.

More from Tirth BodawalaAbout AtyantikHire Node.js developers

Keep reading