Reference implementation of the subscribe_url / unsubscribe_url / trigger contract that the Hugo PWA module's push.ts expects. Deploys to Cloudflare Workers with a Workers KV namespace as the subscription store and the platform-native crypto.subtle API for VAPID JWT signing.
| Component | Detail |
|---|---|
| Runtime | Cloudflare Workers (V8 isolate; serverless edge) |
| Subscription store | Workers KV namespace PWA_SUBS, keyed by SHA-256 hash of the endpoint URL |
| VAPID signing | Native crypto.subtle.sign({name: 'ECDSA', hash: 'SHA-256'}, ...) for ES256 JWT |
| Origin defense | Origin / Referer allowlist hard-coded in worker.js (ALLOWED_ORIGINS array) |
| Admin auth | X-Admin-Key header check against env.ADMIN_KEY for the /trigger endpoint |
| Secrets | wrangler secret put (NOT wrangler.toml); accessed via env.<NAME> at runtime |
| File | Purpose |
|---|---|
worker.js |
Single-entry default export with /subscribe, /unsubscribe, /trigger handlers |
wrangler.toml |
KV namespace binding + setup instructions; secrets are NOT declared here |
package.json |
Dev dependency on wrangler; helper script for web-push generate-vapid-keys |
Request body (canonical PushSubscription.toJSON() shape):
{
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {
"p256dh": "BNX...base64url...",
"auth": "abc...base64url..."
}
}Response: 201 {"ok": true} on success.
Request body: {"endpoint": "..."}. Response: 200 {"ok": true}.
Headers: X-Admin-Key: <ADMIN_KEY>. Body:
{"title": "...", "body": "...", "url": "https://example.com/blog/post"}Response: {"ok": true, "sent": <int>, "removed": <int>}. Stale subscriptions (push service returned 410 or 404) are deleted from KV during the fan-out.
npm install
# Or, if you prefer global installation:
npm install -g wrangler@^3Run on your operator workstation:
npm run vapid
# Or, equivalently:
npx web-push generate-vapid-keysThe output prints Public Key and Private Key in base64url. Save them securely; the next step provisions them as Cloudflare secrets.
Important: do NOT commit either key. The PUBLIC key goes into your Hugo config; the PRIVATE key goes into Cloudflare's secret store only.
wrangler secret put VAPID_PUBLIC_KEY
# Paste the base64url public key when prompted.
wrangler secret put VAPID_PRIVATE_KEY
# Paste the base64url private key when prompted.
wrangler secret put VAPID_SUBJECT
# Enter "mailto:admin@yourdomain.com" (or an https: URL).
wrangler secret put ADMIN_KEY
# Generate a strong random string, e.g.:
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"See VAPID key provisioning notes below for the Cloudflare-specific JWK format requirement.
wrangler kv:namespace create PWA_SUBSThe command prints something like:
{ binding = "PWA_SUBS", id = "abc123def456..." }
Copy the id value into wrangler.toml -- replace the REPLACE_WITH_KV_NAMESPACE_ID placeholder under [[kv_namespaces]].
For local wrangler dev, optionally also create a preview namespace:
wrangler kv:namespace create PWA_SUBS --previewThen uncomment and fill in the preview_id line in wrangler.toml.
Edit worker.js and replace the ALLOWED_ORIGINS constant near the top with your real consumer-site origin(s):
const ALLOWED_ORIGINS = new Set([
'https://example.com', // production origin
'https://staging.example.com', // optional staging
// 'http://localhost:1313', // remove for production deployments
]);The localhost entry is for the fixture-site / dev-server flow and SHOULD be removed before production.
npx wrangler deploy
# or:
npm run deployWrangler prints the deployed URL, e.g. https://pwa-push-backend.<account>.workers.dev.
In your Hugo site's config:
[params.pwa.push]
enabled = true
vapid_public_key = "<paste your VAPID PUBLIC key here>"
subscribe_url = "https://pwa-push-backend.<account>.workers.dev/subscribe"
unsubscribe_url = "https://pwa-push-backend.<account>.workers.dev/unsubscribe"The PUBLIC key goes here; the PRIVATE key stays in Cloudflare secrets.
From your CMS, an admin shell, or any server-side context that holds the ADMIN_KEY:
curl -X POST \
-H "X-Admin-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"New post","body":"Read the latest article","url":"https://example.com/blog/new-post/"}' \
https://pwa-push-backend.<account>.workers.dev/triggerThe trigger endpoint fans out a push notification to every stored subscription, deletes 410/404 stale endpoints, and returns a JSON summary {"ok": true, "sent": N, "removed": M}.
NEVER expose the ADMIN_KEY to client-side code or place it in any client bundle. Treat it as a server-side credential.
worker.js ships a complete, in-Worker Web Push send pipeline. The importVapidPrivateKey(privateKeyB64Url, publicKeyB64Url) function accepts the raw 32-byte P-256 private scalar (d) -- the canonical output of npx web-push generate-vapid-keys -- AND the matching 65-byte uncompressed P-256 public point (0x04 || x || y, the same format the user-agent receives in the Authorization: vapid k=<...> header). It constructs a JWK at runtime ({kty: "EC", crv: "P-256", x, y, d, ext: true}) and imports it as an ECDSA P-256 signing key via crypto.subtle.importKey('jwk', ..., {name: 'ECDSA', namedCurve: 'P-256'}, false, ['sign']). Web Crypto on Workers does not expose a way to derive the public point from d alone, so the operator MUST provide both halves -- and already has both, since VAPID_PUBLIC_KEY is the non-secret value that ships to the page and VAPID_PRIVATE_KEY is the secret-managed scalar.
The encryptPayload() function implements the full RFC 8291 (Web Push Message Encryption with aes128gcm) pipeline natively in the Worker runtime: (1) generate a per-message ephemeral P-256 keypair via crypto.subtle.generateKey({name: 'ECDH', namedCurve: 'P-256'}, ...); (2) import the user-agent's p256dh public key; (3) compute the ECDH shared secret via crypto.subtle.deriveBits({name: 'ECDH', public: uaPubKey}, ...); (4) generate a fresh 16-byte random salt per message; (5) HKDF stage 1 (RFC 5869) derives the input keying material with info = "WebPush: info\0" || ua_public || server_public; (6) HKDF stage 2 derives the AES-128-GCM content-encryption key (info = "Content-Encoding: aes128gcm\0") and AEAD nonce (info = "Content-Encoding: nonce\0"); (7) AES-128-GCM encrypts the payload appended with the RFC 8188 §2.1 last-record padding delimiter (0x02); (8) the body is assembled as the canonical RFC 8188 §2.1 record (16-byte salt || 4-byte big-endian record-size || 1-byte keyid-length || 65-byte server ephemeral public key || ciphertext+GCM tag).
VAPID JWT signing is performed by buildVapidJwt(audience, subject, privateKeyB64Url, publicKeyB64Url) per RFC 8292: ES256 header + claims (aud, exp = now + 12 h, sub), signed via crypto.subtle.sign({name: 'ECDSA', hash: 'SHA-256'}, ...) using the imported VAPID identity key. The signed JWT is sent as Authorization: vapid t=<jwt>, k=<VAPID_PUBLIC_KEY> alongside the encrypted body to the user-agent's push endpoint.
The pipeline is dependency-free at runtime: no web-push npm package, no third-party JWT or HKDF library. The only inputs the operator provides are the four secrets (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT, ADMIN_KEY) provisioned in step 3 of the Setup section.
- VAPID PRIVATE key: stored in Cloudflare's secret manager only. NEVER commit it to source control or include it in the deployed bundle. Rotate periodically.
- Origin validation: the
ALLOWED_ORIGINSset inworker.jsis the primary CSRF defense. Keep it tight; removelocalhostentries before production deployment. - Admin endpoint:
/triggeris gated byX-Admin-Key. Treat the key as a production credential. - Encryption at rest: Workers KV provides encryption-at-rest automatically. Subscription endpoints are user-identifying data per GDPR; treat backups and access logs accordingly.
- HTTPS-only: the Workers runtime enforces HTTPS for incoming requests; the Push API requires HTTPS for outgoing requests. There is no HTTP path.
For the broader Hugo PWA security guidance (VAPID handling, CSRF, GDPR), see modules/pwa/README.md -> Security.
wrangler delete pwa-push-backend
wrangler kv:namespace delete --binding PWA_SUBSDelete the Cloudflare secrets via the Cloudflare dashboard (Workers -> your worker -> Settings -> Variables) or:
wrangler secret delete VAPID_PUBLIC_KEY
wrangler secret delete VAPID_PRIVATE_KEY
wrangler secret delete VAPID_SUBJECT
wrangler secret delete ADMIN_KEYmodules/pwa/README.md-- the Hugo module that POSTs to this backend's endpoints.examples/backend-express/README.md-- alternate reference using the auditedweb-pushnpm package and Postgres.examples/backend-firebase-functions/README.md-- alternate reference using Firebase Functions and Firestore.- Web Push protocol RFC 8030
- VAPID protocol RFC 8292
- Cloudflare Workers documentation