One request, built on Web Crypto
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.
| Protocol step | Conceptual explainers | web-push README | This guide |
|---|---|---|---|
| VAPID JWT (ES256) | Conceptual explainersNames it | web-push READMEOne config call | This guideSigned live on SubtleCrypto, with the raw-vs-DER trap |
| ECDH on P-256 | Conceptual explainersMentions it | web-push READMEHidden in the library | This guideRunnable deriveBits over the client key |
| HKDF key derivation | Conceptual explainersRarely named | web-push READMEHidden | This guideThe exact RFC 8291 info strings, stepped |
| aes128gcm body framing | Conceptual explainersA vague diagram | web-push READMEHidden | This guideA byte-addressable map you can click |
| Status-code handling | Conceptual explainersA list, maybe | web-push READMELeft to you | This guideAn 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.
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.
{
"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.
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.
DER-wrapped, ~70-72 bytes. Wrong for JWS. This is what
OpenSSL and Node's jsonwebtoken hand back:
SEQUENCE(INTEGER r, INTEGER s). Drop it into the token
unconverted and the push service returns 401. You must
unwrap the two integers back to a fixed 64-byte r||s first.
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.
// 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.
-
Inputs
from the subscription + a per-message ephemeral server keyauth_secret · ecdh_secret 16 B · 32 B
4c 78 … 7f 18 · 34 03 84 9f … 83 99The 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.
-
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 71The auth secret is the salt; the ECDH secret is the input keying material. HMAC-SHA-256 with these gives a 32-byte pseudorandom key.
-
HKDF-Expand to the shared IKM
IKM = HKDF-Expand(PRK_key, key_info, 32)key_info ="WebPush: info"0x00ua_public (65 B)as_public (65 B)IKM 32 B
c6 5c 46 4c 2a … 6f 2fThe 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.
-
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 e0A 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.
-
The content-encryption key
CEK = HKDF-Expand(PRK, cek_info, 16)cek_info ="Content-Encoding: aes128gcm"0x00CEK 16 B
56 10 2c 37 fe 45 51 6c 86 d8 ec 8b 22 06 f4 94The 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.
-
The nonce
NONCE = HKDF-Expand(PRK, nonce_info, 12)nonce_info ="Content-Encoding: nonce"0x00NONCE 12 B
98 eb be b8 d0 48 9e ba 02 5f e2 ffSame 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.
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.
// 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.
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.
| 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 |
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.
| Header | What it controls | Values | If you omit it |
|---|---|---|---|
| TTL | What it controlsHow long the push service holds the message while the device is offline | ValuesSeconds, 0 to 2419200 (28 days) | If you omit itBehaviour varies by service; set it explicitly rather than guessing |
| Urgency | What it controlsDelivery priority and the battery trade-off on the device | Valuesvery-low, low, normal, high | If you omit itnormal |
| Topic | What it controlsA collapse key: a newer message replaces an undelivered older one with the same topic | ValuesAn opaque string, up to 32 base64url characters | If you omit itNo 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.
// `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 POST /wp/abcd1234 HTTP/1.1
Host: fcm.googleapis.com
Authorization: vapid t=eyJ0eXAiOiJKV1Qi..., k=BFx...
Content-Encoding: aes128gcm
Content-Type: application/octet-stream
TTL: 86400
Urgency: normal
Topic: order-4210
Content-Length: 189
<binary aes128gcm record: salt | rs | idlen | keyid | ciphertext | tag> 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.
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.
400 Fix the request, keep
Malformed request or headers. Check Content-Encoding and the body framing. This is your bug, so keep the subscription and fix the sender.
401 Re-sign the JWT, keep
VAPID auth failed. Re-sign the JWT and check that aud matches the endpoint origin and exp is under 24 hours out. Never delete on 401.
403 Check the key, keep
Forbidden. Usually the VAPID public key does not match the one the subscription was created with. Fix the key pairing; keep the subscription.
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
| 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 |
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.
// 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 ?? '/' },
}),
)
}) self.addEventListener('notificationclick', (event) => {
event.notification.close()
const target = event.notification.data?.url ?? '/'
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((wins) => {
const open = wins.find((w) => new URL(w.url).pathname === target)
return open ? open.focus() : clients.openWindow(target)
}),
)
}) 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.
| Primitive | Web Crypto (edge, browser) | Node | Python | Go | Rust |
|---|---|---|---|---|---|
| Sign ES256 JWT | Web Crypto (edge, browser)crypto.subtle.sign('ECDSA', …), raw r||s | Nodecrypto.sign, convert DER to raw | Pythoncryptography ec.ECDSA, decode_dss_signature | Goecdsa.Sign, then re-pack to raw | Rustp256 / ecdsa, Signature::to_bytes |
| ECDH on P-256 | Web Crypto (edge, browser)crypto.subtle.deriveBits({name:'ECDH'}) | Nodecrypto ECDH / diffieHellman | Pythonprivate_key.exchange(ec.ECDH()) | Goecdh.PrivateKey.ECDH | Rustp256::ecdh::diffie_hellman |
| HKDF-SHA-256 | Web Crypto (edge, browser)crypto.subtle.deriveBits({name:'HKDF'}) | Nodecrypto.hkdfSync | Pythoncryptography HKDF | Gogolang.org/x/crypto/hkdf | Rusthkdf crate |
| AES-128-GCM | Web Crypto (edge, browser)crypto.subtle.encrypt({name:'AES-GCM'}) | Nodecrypto.createCipheriv('aes-128-gcm') | Pythoncryptography AESGCM | Gocrypto/cipher NewGCM | Rustaes-gcm crate |
| HTTPS POST | Web Crypto (edge, browser)fetch | Nodefetch / undici / https | Pythonhttpx / requests | Gonet/http | Rustreqwest |
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.