VAPID and web push (the Spur mistake)
Full breakdown of web push: VAPID JWT signing, message encryption with ECDH, the push service architecture, and how I would answer the Spur question now.
The Spur Story
In the Spur interview the question was straightforward: "walk me through web push, and tell me which key signs the VAPID JWT." I had used the web-push library on a side project, I knew the flow at a high level, and I confidently said the public key signs. The interviewer paused, asked again, gave me an out. I doubled down. Wrong.
The lesson is not about VAPID. It is about understanding the asymmetric crypto invariant deeply enough that you cannot get it backwards under pressure. Private key signs, public key verifies. Always. There is no protocol that breaks this rule because the math does not allow it.
This deep dive is the answer I should have given.
What Web Push Actually Is
Web push lets a server deliver a notification to a browser even when the user is not on the site. The browser registers a service worker, subscribes to push, gets back an endpoint URL on a push service operated by the browser vendor (FCM for Chrome and Edge, Mozilla AutoPush for Firefox, Apple Push Notification Service for Safari). The server POSTs to that endpoint, the push service delivers.
There are three security problems to solve.
- The push service should not let just anyone POST to any endpoint. Spam mitigation. Solved by VAPID.
- The push service should not be able to read the notification content. Privacy. Solved by message encryption (RFC 8291).
- The browser should accept only notifications from the server the user opted in to. Solved by the subscription endpoint being unguessable, plus the auth secret.
VAPID and message encryption are two separate crypto layers stacked on the same HTTP request. People conflate them. Do not.
VAPID in Detail
VAPID (Voluntary Application Server Identification) is defined in RFC 8292. It is a JWT-based authentication scheme.
You generate one ECDSA P-256 key pair, once, ever. The public key gets baked into your frontend code. When a user subscribes, the browser includes that public key in the subscription request to the push service. The push service stores it as the only key allowed to authenticate pushes to that endpoint.
For every push request, your server builds a JWT.
{
"aud": "https://fcm.googleapis.com",
"exp": 1700003600,
"sub": "mailto:yash@example.com"
}Signed with ES256, which is ECDSA over P-256 with SHA-256. The signing key is your private key. The result is a compact JWT string.
You send the JWT and the public key together in the Authorization header.
Authorization: vapid t=eyJhbGc..., k=BNbMr...
The push service decodes the JWT header, looks at the kid or just uses the k parameter to find the public key, verifies the signature, checks that aud matches its own origin, checks that exp is in the future and within 24 hours, then accepts the request.
The Spur question maps perfectly here. Sign with private. Verify with public. The push service sees only the public key. It cannot sign on your behalf because it does not have the private key.
Why ECDSA and Not RSA
VAPID mandates ES256 specifically. Reasons.
- Smaller keys. P-256 public keys serialize to 65 bytes uncompressed, fits in an HTTP header. RSA-2048 public keys are 270+ bytes.
- Smaller signatures. ECDSA signatures are 64 bytes. RSA-2048 signatures are 256 bytes.
- Web Crypto API support. Every modern browser supports ECDSA in
SubtleCrypto, which lets you generate keys client-side if needed.
The push services chose ES256 because every request is auth'd this way and bytes matter.
Message Encryption: The Second Layer
VAPID authenticates the application server to the push service. It does not encrypt the notification body. That is RFC 8291's job and it uses a totally different key set.
When the browser subscribes, it generates a second ECDH P-256 key pair (separate from VAPID). The browser also generates a 16-byte auth secret. Both are sent to the application server as part of the subscription object.
{
"endpoint": "https://fcm.googleapis.com/fcm/send/abc...",
"keys": {
"p256dh": "BIGdf...",
"auth": "Vx5..."
}
}To send an encrypted push, the server.
- Generates an ephemeral ECDH P-256 key pair.
- Performs ECDH between the ephemeral private key and the user's
p256dhpublic key, getting a shared secret. - Mixes the shared secret with the
authsecret via HKDF to derive an AES-128-GCM key. - Encrypts the payload.
- Includes the ephemeral public key in the Crypto-Key header (or the body, depending on encoding).
The browser does the mirror: derives the same shared secret from its private p256dh and the server's ephemeral public, then decrypts.
The push service in the middle sees opaque encrypted bytes. It cannot read the notification.
The Two-Layer Diagram
This is the part I should have led with at Spur. Two layers, two key pairs, two purposes. Authentication and confidentiality are different problems.
What web-push Library Does For You
In Node.js you write.
import webpush from "web-push";
webpush.setVapidDetails(
"mailto:yash@example.com",
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!,
);
await webpush.sendNotification(subscription, JSON.stringify({
title: "Hello",
body: "From the server",
}));Under the hood the library.
- Builds the VAPID JWT with the right aud derived from the subscription endpoint origin.
- Signs it with your private key.
- Performs the ECDH derivation against the subscription's p256dh.
- Encrypts the payload with the derived AES-GCM key.
- Sets all the right headers (Authorization, Crypto-Key, Content-Encoding: aes128gcm, TTL).
- POSTs to the endpoint.
You can get the whole flow right without understanding any of it. That is what bit me. The library hid the structure and I never forced myself to internalize which key did what.
Edge Cases and Production Concerns
- TTL header. If the user is offline, the push service holds the message for
TTLseconds, then drops it. Set TTL to something sensible like 86400 (one day) for important messages, 0 for "only if online now." - 410 Gone responses. The user uninstalled the service worker. Delete the subscription from your database.
- 429 rate limits. Each push service has its own limits. FCM allows roughly 600 messages per minute per endpoint topic.
- Payload size limit. RFC says push services must accept at least 4096 bytes. Plan for less, send only what the service worker needs to fetch the rest from your API.
- Key rotation. VAPID public keys are baked into existing subscriptions. Rotating means existing users stop receiving pushes unless you keep the old key alive for verification. Plan a long migration or never rotate.
Common Pitfalls
- Hardcoding the VAPID keys in the frontend. The private key must stay on the server. Only the public key goes to the browser.
- Generating new VAPID keys per push. Generate once, store, reuse forever. Same key pair for all users.
- Forgetting the sub claim. Some push services reject JWTs without a contact URL.
- Sending a JWT with exp more than 24 hours away. RFC 8292 says max 24 hours. Most services reject longer.
- Not handling 410 Gone. Your DB fills with dead subscriptions and you waste requests on them.
Interview Soundbites (What I Will Say Next Time)
- "Web push has two crypto layers. VAPID authenticates the server to the push service via an ECDSA JWT. Payload encryption uses ECDH between the server and the browser, end to end."
- "VAPID: server holds the private key, signs the JWT, includes the public key in the Authorization header. Push service verifies with that public key. Sign private, verify public, no exceptions."
- "ES256 specifically. Small keys, small signatures, browser support."
- "Payload is encrypted with AES-128-GCM, key derived from ECDH plus the auth secret via HKDF. Push service cannot read it."
Learn more
- DocsRFC 8292: VAPIDIETF
- Docs
- Docs
- Docs
- Repoweb-push libraryGitHub