Authorization: vapid ES256, signed with SubtleCrypto
Content-Encoding: aes128gcm encrypted to the client keys

Web Push VAPID 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.

You do not need the library. You need Web Crypto.#

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

The whole Web Push VAPID send path, from scratch#

Before the zoom-ins, hold the shape of the whole thing. Two independent branches feed one request. First, the identity branch signs a VAPID JWT that proves who is sending. Second, the encryption branch turns your plaintext into an aes128gcm body that only the subscriber can read. They then meet at one HTTPS POST, which the push service relays 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.

VAPID identity: sign the JWT with Web Crypto (no jsonwebtoken)#

VAPID (Voluntary Application Server Identification, RFC 8292) is how a push service knows a message came from you without any account or shared secret. It is a plain ECDSA key pair on the P-256 curve. 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.

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

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.

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.

The encryption: aes128gcm from ECDH to AES-GCM (RFC 8291)#

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, so the push service relays bytes it cannot read. 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.

Assemble the body byte by byte (RFC 8188 framing)#

The ciphertext and tag are not the request body on their own. RFC 8188 frames them with a fixed header so the recipient can re-derive the same key and nonce: the salt, a record size, the length and value of the server public key, then the ciphertext and tag. 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 4096-byte record ceiling. That leaves ~3993 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.

The POST: RFC 8030 headers and the request on the wire#

Delivery is one HTTPS POST to the subscription endpoint (RFC 8030). 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

Route every status code, and keep the store healthy#

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.

Receiving: the service worker#

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 ?? '/' },
    }),
  )
})

Port the five primitives to any language#

The reason to learn the path this way is that it is not tied to JavaScript. Five primitives carry the entire send, and every mainstream language has all five in its 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 not to build this from scratch#

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#

Web Push sits on top of a service worker, so if you have not shipped one yet, 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.

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 since 2015. 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