Skip to content

Homelab and self-hostingreference

End-to-end encrypted push notifications through an untrusted broker

How to build a publish/subscribe notification service whose server never holds a key that decrypts a message: scripts publish with one HTTP request, phones and browsers receive the push, and for encrypted topics a full copy of the server’s database reveals routing metadata and nothing else. It is for anyone choosing or building a self-hosted alternative to ntfy, Gotify or a hosted push service.

The design below has one implementation: a Go server with embedded SQLite, a CLI, and a browser PWA with Web Push, in use but not published. Every number in this page is the value that implementation uses, read from its source on 2026-11-13; the Evidence table splits what its tests exercise from what is design only.

TL;DR:

  • The broker stores and forwards ciphertext. Topic keys are random 256-bit keys made on a device; the server holds them only wrapped to a device’s X25519 public key or sealed under the recovery phrase.
  • A new device joins by scanning a QR code and being approved by a device that already has the keys. The approver wraps each topic key to the newcomer; the server stores the wrapped bundles and performs no cryptographic operation.
  • The server still sees topic names, priority, timestamps, sizes, device IPs and delivery state. Title and tags are sealed inside the payload, so a publisher that sends them as plain headers can only use unencrypted topics.
  • Web Push works without breaking this: the push service sees an RFC 8291 envelope around ciphertext it could not read anyway, and the browser’s service worker decrypts with a key kept in IndexedDB.
  • The guarantee covers a server that is breached or copied. A server that is actively malicious can still wrap a key it chose to a device, because bundles are not authenticated to the device that wrapped them.
  • Pick this over a server-readable broker when the server or its backups sit somewhere you do not fully trust. Pick ntfy when ntfy-compatible tooling and native apps matter more than the server being blind: as of 2026-11-13 ntfy’s end-to-end encryption issue has been open since 2021-12-29.1

Publisher(script, monitor,publisher device)Brokerciphertext, wrapped keys,routing metadataPOST /<topic>JWE bodySQLiteBrowser push service(RFC 8030)Web Pushwhen no live WebSocketDevice A(PWA, service worker)key bundlesDevice B(CLI or PWA)WebSocket / SSEliveRFC 8291 envelopeapprove: wrappedtopic keys

Every arrow carries ciphertext the broker cannot open. The one plaintext path is a topic created without encryption, which exists so tools that only speak ntfy headers keep working.


You needUseWhy
Existing tooling (Uptime Kuma, Grafana, Alertmanager) posting ntfy headers, todayntfy, or this design’s plaintext topicsboth accept the ntfy publish headers; the content is readable on the server either way
A server, VPS or backup that could be breached or copiedthis design, encrypted topicsa database dump yields wrapped keys and JWE bodies
Native Android or iOS appsntfyntfy ships both;2 the implementation here ships a browser PWA and a CLI; native apps are design only
Notifications that must survive every device being lostthis design, with the recovery phrasethe server keeps a backup blob it cannot decrypt; the 12-word phrase restores it
Hiding which topics exist and when they fireneithertopic names and timing stay visible to the broker in both

The threat model protects message content and topic keys against a fully compromised server, network observers, push services, and other users of the same server. It does not protect metadata, device endpoints, or availability: a malicious server can drop or delay messages.

FieldBroker sees itWhy
Message bodynoJWE compact serialisation, alg=dir, enc=A256GCM3
Title and tagsnowhen present, sealed with the body as JSON {"title","tags","body"} inside the JWE; otherwise the body is sealed alone
Topic nameyesrouting, wildcard subscription, access checks
Key id kid (topic and epoch)yesin the JWE protected header so a device knows which key to use
Priorityyesthe server sets the Web Push urgency from it; the service worker’s quiet-hours check reads it from the push payload
Supersedes, collapse key, TTLyesthe server replaces, collapses and expires copies it cannot read
Timestamps, sizes, device IPsyesinherent to a store-and-forward server
Delivery receipts (received, displayed, actioned, dismissed)yesescalation fires on missing acknowledgement, not on content

Two features follow from the table. A heartbeat topic (alert when a topic goes silent for its interval) works on encrypted topics because it watches arrival times only; its alert is plaintext, since the server has no key to encrypt it with. Escalation to email or a webhook carries topic, message id, priority and age, never content.


KeyMade byStored wherePrimitive
Topic keyfirst device on the topic, CSPRNGdevices; server only wrapped256-bit symmetric, used with AES-256-GCM in the JWE
Device key paireach device at provisioningprivate key on the device onlyX255194
Wrapped topic key (bundle)an approving deviceserverephemeral X25519, HKDF-SHA256, XChaCha20-Poly1305 with a 192-bit nonce
Recovery keyderived from the phrasenowhere128-bit BIP-39 entropy5 through Argon2id6

A bundle is the ephemeral public key, the nonce, the sealed topic key, and the topic, epoch, recipient device, wrapping device and timestamp. Those five fields are bound into the AEAD’s associated data, so the server cannot move a bundle to another topic, epoch or device without the unwrap failing.

Each topic key has an epoch. Messages name it in kid (<topic>.<epoch>), so a device holding several epochs decrypts old history with old keys and new messages with the current one. Rotation is lazy: a device that wants a new key asks the server to reserve epoch + 1, which the server grants to one device at a time for 10 minutes. That device generates the key, wraps it to every active device of the user and uploads the bundles; the first publish at the new epoch makes it current. Without the reservation, two devices revoking at once would each mint a different key for the same epoch and split the topic’s devices into two groups that cannot read each other.

The broker accepts an encrypted publish only at the current epoch or the next one; a stale epoch or a jump of more than one returns 409.

Revoking a device deletes its bundles and push registrations at once. On a topic with rotate-on-revoke set (the clients set it for every encrypted topic they create), the server also marks the current epoch as revoked: any publish at that epoch or below now returns 409, so the next message has to go out under a newer key. That key is only new to the revoked device if it was generated after the revoke: a rotation already reserved and distributed before it, still waiting for its first publish, stays acceptable at epoch + 1 and was wrapped to the revoked device too. Rotate once more after revoking. The revoked device keeps every key it already had, so it can still read the history it was given.


A newly approved device gets the history the topic’s policy says, which is a decision made per topic rather than a property of a shared static key:

PolicyKeys the new device receivesStored messages the server replays
allevery epoch the approving device holdsall retained
since-jointhe approver’s latest epoch, then every later rotationfrom when the device was first given a key for the topic
nonethe same as since-joinnone; live delivery only

The server enforces the replay cut on stored messages. The key set is chosen by the approving client, not enforced by the server, and it protects only older epochs: a message published in the current epoch before the device joined is withheld by the server alone.


mintedprovisioning tokenprovisionedkey pair madescan QRpending approvalPOST /v1/provisionactivebundles downloadedapprover comparesfingerprint, wraps keys
  1. An admin mints a one-time provisioning token (15-minute lifetime) and shows it as a QR code: a URI carrying the server URL and the token.
  2. The new device scans it, generates its X25519 key pair, and redeems the token with its public key and a name. It gets a device id and a 256-bit bearer token; the server stores only the token’s SHA-256.
  3. Existing devices are told a device is waiting. Each side shows an 80-bit fingerprint of the new device’s public key, as four groups of four base32 characters, on the pending device and in the approver’s prompt.
  4. The user compares the two and approves. The approver wraps every topic key the new device should have and uploads the bundles.
  5. The new device downloads and unwraps them. A full device provisioned while the user has no other active full device is activated by the server directly and creates keys locally.

The fingerprint comparison defends against one attack the broker can mount on its own: serving the approver a substituted public key so the topic keys get wrapped to the server’s key instead. It does not defend against a second. Every device’s public key is on the server, and a bundle names its wrapping device in the associated data without proving it, so a malicious server can wrap a key it chose to a device and serve it as a new epoch; the device will then encrypt under a key the server knows. Closing that needs bundles authenticated to the wrapping device, through a per-user cross-signing key or a static-key wrap, and both are design only. Until then, the guarantee holds against a server that is breached or copied, not against one that is actively malicious.

A headless box (a monitoring host) joins as a publisher device: its provisioning token binds the role, and its device token can publish but cannot subscribe or rotate keys. A compromised monitoring host can then forge alerts but cannot fetch anyone’s messages.


The backup is the device list and every topic key, as JSON, sealed with XChaCha20-Poly1305 under a key derived from the recovery phrase with Argon2id, uploaded as an opaque blob with its KDF parameters and a version byte. Defaults are 3 passes, 64 MiB and 4 lanes; the clients accept 1 to 10 passes and 8 MiB to 1 GiB of memory when they seal or open a blob, so a tampered blob cannot request an absurd derivation.

The phrase is 12 BIP-39 words, 128 bits of entropy, shown once. Brute-force resistance rests on those 128 bits; Argon2id is the second layer. The server limits backup reads to 10 per minute per user regardless.

Restoring needs no surviving device, provided the lost devices are revoked first (an admin token can revoke any device): with no active full device left, the server activates the fresh device directly. Provision it by QR, enter the phrase, download and unseal. The restored device then wraps every recovered key to its own public key and uploads the bundles, because there is no other device to do it.


PathUsed whenWhat travels
WebSocket or SSEthe device is connectedthe stored JWE
GET /<topic>/json?since=<seq>catching up after being offlinestored JWEs after a monotonic sequence number
Web Push7the device has no live WebSocket on the topicthe JWE inside an RFC 8291 envelope8, or a fetch hint

The broker sends no push to a device that has a live WebSocket on the topic; an SSE subscriber still gets the push. Proxies drop idle connections, so the server sends an SSE comment or a WebSocket ping every 30 seconds and closes a WebSocket peer that leaves a ping unanswered for two intervals.

Web Push keeps the property because RFC 8291 encrypts the payload to a key the browser generated, authenticated with VAPID,9 and the payload inside is already the JWE. The push service sees timing and approximate size. The browser’s service worker unseals the JWE with the topic key from IndexedDB and builds the notification itself.

Size is the constraint. A push service may refuse anything over 4096 octets,10 which RFC 8291 says leaves at most 3993 octets of plaintext.11 The implementation sends the message in the push only when the JSON payload is 3072 bytes or less; above that it sends the routing fields with fetch: true, and the service worker fetches the message through the replay endpoint. The broker itself caps an inline message at 4096 bytes.

The browser subscription endpoint is a URL the client supplies, so it is a server-side request forgery vector. The broker accepts only https on port 443, refuses any host that resolves to a private, loopback, link-local or CGNAT address, re-resolves on every send, never follows redirects, keeps at most 5 registrations per device, and deletes a registration when the push service answers 404 or 410, or after 10 consecutive failures.


The server is reachable from the internet for phones to use it, and onboarding and backup are the two endpoints an attacker would hammer. The first two limits are per client IP, token buckets in process; backup reads are counted per user:

EndpointLimitAfter the limit
POST /v1/provision10 per minute429 with Retry-After
Any authenticated route, failed authentication30 per minuteevery credentialed request from that IP gets 429 before the token is checked
GET /v1/backup10 per minute per user429

Blocking before the token check means a guessed-right token after the limit still fails, so guessing gets no oracle. Behind a reverse proxy the client IP must come from a header the server trusts only from the proxy’s address; otherwise every request shares the proxy’s bucket and one attacker locks out everyone.


  • A browser is not a key store you can recover from: clearing site data deletes the IndexedDB that holds the device key and the topic keys. Make the backup and its phrase part of onboarding, not a later setting.
  • A push about an unknown topic still has to go somewhere. In this implementation Web Push for a message to a topic that has never been configured goes to the publisher’s own devices, which makes a curl -d hello with a publish token to a new topic name a working first test with no setup. The message itself is plaintext and readable by any subscribe-scoped token on the server, so this is for testing, not for anything private.
  • The fingerprint is only useful if the approver can see the same string the new device shows, so the pending device displays its own fingerprint, computed locally, beside the approver’s prompt.
  • Account changes are pushed as data-free hints over one WebSocket per device, and each client re-fetches: device-list changes (provisioned, approved, revoked) go to every connected device of the user, key uploads and the approval itself only to the device concerned. A pending device may hold that socket so it notices its approval without polling; in exchange it observes the timing of account changes while it waits.

ClaimStatusHow it was checked
Server stores no title or tags for a sealed messagetestedclient integration test publishes with both and asserts the stored row’s metadata is empty
Go client and browser TypeScript agree on JWE, key wrap, fingerprint and backup formatstestedshared test vectors in both directions: Go-generated vectors checked by the browser unit tests, TypeScript-generated vectors checked by a Go test
Onboarding, approval, encrypted publish and live delivery between two browserstestedPlaywright end-to-end test driving two browser contexts against a fresh server
A pending device cannot sync keys; a revoked device’s token is refusedtestedclient integration test
Concurrent rotations converge on one next-epoch keytestedclient test with two rotating devices
Server replay cut per history policytestedAPI test per policy; the key set per policy is client logic and untested
Web Push skips devices with a live WebSocket, falls back to a fetch hint above 3072 bytes, prunes a gone registrationtestedAPI tests against a fake push sender
Push endpoints resolving to private addresses are refusedtestedAPI test with a stub resolver
Rate limits and the block-before-token-check ruletestedAPI tests, including a valid token from a blocked IP
Real Web Push delivery through browser push services, camera QR scanningchecked by hand, not automatedthe end-to-end suite stops at the service worker and decodes QR from a fixture; delivery and the camera were checked on a phone
Revoked-epoch 409, and the rotation-reserved-before-revoke case abovecode read, untestedno test exercises the revoked-epoch path
Bundles authenticated to the wrapping devicenot builtsee Onboarding a device
Native Android and iOS apps, cross-signing, federationdesign onlynot built

Is the server, its host or itsbackups outside your trust?Do you need nativemobile apps now?noMust notifications survivelosing every device?yesServer-readable broker(ntfy)yesUntrusted-broker design,encrypted topicsno, a PWA is enoughnoUntrusted-broker designplus recovery phraseyes
  1. If the server, its host and its backups are all yours and trusted, and you want native apps today, a server-readable broker such as ntfy is the smaller system.
  2. If they are yours but a PWA is enough, the untrusted-broker design costs little extra and makes a leaked backup harmless.
  3. If the server sits somewhere you do not trust, use encrypted topics, and set up the recovery phrase when losing every device would lose the keys. Against an operator who would actively tamper with key bundles, this design is not enough yet (see Onboarding a device).

  1. binwiederhier, “End-to-end encryption (E2E) between clients (Android app, CLI, web app),” ntfy issue #69, GitHub. https://github.com/binwiederhier/ntfy/issues/69 ↩

  2. ntfy, “Subscribe from your phone,” ntfy docs. https://docs.ntfy.sh/subscribe/phone/ ↩

  3. M. Jones and J. Hildebrand, “JSON Web Encryption (JWE),” RFC 7516, IETF. https://www.rfc-editor.org/rfc/rfc7516 ↩

  4. A. Langley, M. Hamburg and S. Turner, “Elliptic Curves for Security,” RFC 7748, IETF. https://www.rfc-editor.org/rfc/rfc7748 ↩

  5. M. Palatinus, P. Rusnak, A. Voisine and S. Bowe, “Mnemonic code for generating deterministic keys,” BIP-39, GitHub. https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki ↩

  6. A. Biryukov, D. Dinu, D. Khovratovich and S. Josefsson, “Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications,” RFC 9106, IETF. https://www.rfc-editor.org/rfc/rfc9106 ↩

  7. M. Thomson, E. Damaggio and B. Raymor, “Generic Event Delivery Using HTTP Push,” RFC 8030, IETF. https://www.rfc-editor.org/rfc/rfc8030 ↩

  8. M. Thomson, “Message Encryption for Web Push,” RFC 8291, IETF. https://www.rfc-editor.org/rfc/rfc8291 ↩

  9. M. Thomson and P. Beverloo, “Voluntary Application Server Identification (VAPID) for Web Push,” RFC 8292, IETF. https://www.rfc-editor.org/rfc/rfc8292 ↩

  10. M. Thomson, E. Damaggio and B. Raymor, “Generic Event Delivery Using HTTP Push,” RFC 8030, section 7.2, IETF. https://www.rfc-editor.org/rfc/rfc8030#section-7.2 ↩

  11. M. Thomson, “Message Encryption for Web Push,” RFC 8291, section 4, IETF. https://www.rfc-editor.org/rfc/rfc8291#section-4 ↩