Skip to main content

Automation, AI & open source

Self-Hosted ntfy Access Control: Deny-All, a Topic per Alert Domain, Access From Your App (2026)

Make ntfy private with deny-all, split alerts into topics people can be held to, and derive each person's access from the permissions your app already keeps.

Published 14 Oct 202611 min readBy Manoj

To make a self-hosted ntfy private, set auth-default-access to deny-all, give each alert domain its own topic, and write each person's read-only rows from the permissions your app already keeps, so a demotion updates both lists in one step. This post shows the map, the sync that denies as well as grants, a simulator for demotions, and the places where a revoke does not reach.

A phone that keeps buzzing after its owner lost access is the quiet failure. I lead Medusa and Frappe programmes at MithTech, a team of Frappe specialists in Bengaluru. The code below is the alert layer of one of our own platforms, a back office we built on Medusa (what Medusa gives you). It is ours, so I can show where it works and where it does not.

Short answer

Run ntfy with auth-default-access: deny-all, one topic per alert domain, and read-only rows that your app writes from its own permissions: grant what a person has earned, deny every other topic, on every invite and update. On a default server a revoke refuses new connections at once. A stream that is already open may run until it reconnects, so test that on your version.

What does deny-all change on a self-hosted ntfy?

A new ntfy server is open: everyone can read and write every topic. Setting auth-default-access to deny-all reverses that. A request is refused unless a user, a topic and a permission say otherwise, so each person, your publisher and your admin become accounts, and every grant becomes a row (ntfy access-control docs).

# server.yml  (ntfy v2.30.0)
auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"
enable-signup: false   # the default; written so nobody turns it on

Whether to self-host at all is a separate decision; this post assumes you have made it.

Four facts from the docs and the source (release v2.30.0, read on 14 October 2026) shape the rest of this post.

  • A row is a user, a topic or pattern, and a permission: read-write, read-only, write-only or deny. The most specific row wins: a named user beats everyone, a longer pattern beats a shorter one, and with no row the default applies (source).
  • There are two roles, user and admin, and no groups. An admin skips the table, so never make someone an admin to fix a missing row. A group is a set of rows you write per person (the open request for groups).
  • Deny-all gates publishing too. Your publisher needs write on every topic it uses, and a new topic fails until you grant it.
  • A token is its user. An access token carries all of its user's access; ntfy has no narrower token yet.

Run 2.24.0 or later and keep topic names lower case. Before that release, on SQLite, a rule for secret also matched SECRET (release notes).

Why a topic per alert domain and not one for everyone?

Split topics when different people have to act on different alerts. On one of our platforms every alert once went to a single default topic, so everyone got everything and nobody could be held to a queue. Alerts now route by the first word of the event name into eight topics: kyc, orders, queue, ops, support, vendors, security and money. The routing table holds 68 alert types.

One rule does the routing, so a new event lands in the right topic without anyone annotating it, and an unknown prefix falls back to ops instead of going nowhere.

// trimmed from the real table; topic names shortened
const TOPIC_BY_PREFIX = {
  kyc: "kyc", bank_account: "kyc", demat_account: "kyc",
  order: "orders", investing: "orders",
  indicative: "queue",              // a customer waiting for a person to price
  wallet: "money",
  security: "security", ai: "security",
  admin: "ops", desk: "ops", customer: "ops",
  support: "support", vendor: "vendors",
}
export const topicForEvent = (name: string) =>
  TOPIC_BY_PREFIX[name.split(".")[0]] ?? "ops"

The queue came off the orders topic for one reason. Quote requests are work, and order alerts are reports; mixed together, the one alert someone must act on looked like the fourteen that had just happened.

One topic is also the right answer sometimes. Our own website sends its lead alerts to a single topic, with one reader and a publisher whose access is write-only, so a leaked token cannot read the lead history. Its rule is the reverse of the platform's, and it fits: adding an event type must never mean adding a topic.

Right whenOne team reads everythingDifferent people act on different alerts
Held to a queueNobody by nameThe readers of that topic
A new alert typeJoins the topic; nobody subscribes againNeeds a route; a new topic needs a grant
Access listOne reader row, one publisher rowA row per person per topic, written by code
Common failureEveryone mutes itA topic nobody subscribed to

How do you decide who may read which topic?

Tie each topic to the one permission that makes the alert useful, and to nothing else. Our map has eight topics and uses seven permissions from the app's own list. The queue is earned by quotes:price, not quotes:view, because pricing is the act, and a viewer who cannot price would only get a buzz.

8 rows · click a column to sort

kyckyc:followup10noyesyes
ordersquotes:view10yesyesyes
queuequotes:price4noyesyes
opssubmissions:view21yesyesyes
supportsupport:view3yesyesyes
vendorsvendors:view10yesyesyes
securityoperators:manage6nonoyes
moneyoperators:manage4nonoyes

A viewer reads 4 topics, an operator 6 and an admin 8. The whole map is one object:

export const TOPIC_CAPABILITY = {
  kyc:      "kyc:followup",
  orders:   "quotes:view",         // reports, not actions
  queue:    "quotes:price",        // pricing is the act
  ops:      "submissions:view",
  support:  "support:view",
  vendors:  "vendors:view",
  security: "operators:manage",    // admin only
  money:    "operators:manage",    // admin only
}

Security and money stay with admins, and nobody gets more than read. Nothing needs an operator to publish to an alert topic, and write on the security topic would let a compromised phone forge a security alert. A person's permissions are also not a flat role: they are the role's list, or a custom preset's, plus extra grants, minus denials, and a denial wins. The sync reads that resolved list, never the role name.

A guard test fails if a mapped permission does not exist or if a topic that alerts route to has no permission. A typo would otherwise create a topic nobody can ever earn.

How does a demotion take the alerts away?

Write the rows from the person's current permissions every time, and write a deny for every topic they have not earned. That is the whole sync. It creates the ntfy user if it is missing, grants read-only on each earned topic and denies each of the other managed topics. An inactive person earns nothing, so deactivation takes the same path as a demotion.

const earned = active ? topicsForCapabilities(capabilities) : []
if (!known.includes(username)) {
  password = generatePassword()          // shown once; ntfy keeps only a hash
  await admin("POST", "/api/users", { username, password, role: "user" })
}
for (const topic of MANAGED_TOPICS) {
  await admin("POST", "/api/access", {
    username, topic,
    permission: earned.includes(topic) ? "read-only" : "deny",
  })
}

Granting alone is the drift bug. Someone loses kyc:followup, keeps the old row, and keeps receiving identity alerts for months, because the two lists were only equal on the day they were written. In ntfy a call that sets a permission replaces the old row for that user and topic (source), so a deny overwrites a read-only. We chose a deny row over deleting the row because a deny stays visible: "why can't she see KYC?" has an answer.

The sync runs from one shared helper, and every invite and update goes through it, from the admin console and from the desk's own operator screen. Two copies of that policy, the code comment says, would be a security defect. Try it below. Pick a role and a change, then switch the sync mode to see each failure.

Interactiventfy access simulatorlink

Pick a role and a change, such as a demotion or a deactivation, and see the access rows before and after, the calls sent, and how many alert types reach the phone. Switch the sync to grant only, or to did not run, to see the drift.

1. Who is the person today?

Roles are the app's own: the viewer, operator and admin lists, reduced to the seven permissions that earn a topic. The person starts with exactly the rows the invite wrote.

2. What changes?

An admin already holds all seven, so there is nothing to give.

Which role?

The desk and the phone agree: 2 topics revoked.
Alert types on the phone68 to 58before and after, of 68 defined
Alert types the desk allows58what the phone should read
Calls this sync sends91 lookup, 8 writes

3. Now break the sync: what does it do?

What the app runs: read-only on what is earned, an explicit deny on every other managed topic, every time.

4. How is a topic revoked?

What the app sends. The row stays visible, so "why can't they see it?" has an answer. As far as the source shows, ntfy's own admin API does not end an open stream on this call.

Access rows for this person, before and after the change
Topic (alert types)Earned byRow, today and afterPhone afterChange
kyc (10)kyc:followupread-only to read-onlyReads
orders (10)quotes:viewread-only to read-onlyReads
queue (4)quotes:priceread-only to read-onlyReads
ops (21)submissions:viewread-only to read-onlyReads
support (3)support:viewread-only to read-onlyReads
vendors (10)vendors:viewread-only to read-onlyReads
security (6)operators:manageread-only to denyRefused (403)Revoked
money (4)operators:manageread-only to denyRefused (403)Revoked
A phone connected right now

security, money: the stream is not re-checked and may keep receiving until it reconnects; new connections get 403.

Rules read from the code of one of our platforms and from ntfy v2.30.0; a test fails if this module's data drifts from that code. Alert types are definitions in the routing table, not alerts sent.

We ran all 180 states of it. With grant and deny, the phone matches the desk in every one of its 60. With grant only, the phone drifts in 46 of 60: every change that takes a topic away, which is 23 of the 30 changes. With the sync skipped, the two disagree in all 60.

A 12-check guard test keeps the design from decaying. It fails if the deny half is removed, if a write grant appears, if an inactive person still earns a topic, or if the removal helper deletes before it denies. It is static: it reads the source and matches patterns, so it proves the code has the right shape and not that a call reached the server.

What does a revoke not reach?

Five things can leave a person reading after the app says no. These are the gaps around the sync. We list them because a reader copying the design should test each one on their own system.

  1. A stream that is already open. ntfy checks access when a request starts. An open subscription then sends messages and keepalives and is not checked again (middleware, stream loop). In ntfy's own admin API, deleting a user or deleting a row ends the stream, and setting a deny row has no such call (source). Our app sends a deny. We have read this and not yet run it.
  2. A call that fails. The sync does not block the change. On an update a failure is logged loudly and the update still succeeds, with no retry and no job that compares the two lists. A phone-alerts outage should not block adding a person, but the price is a revoke that quietly did not happen (the same boundary, for workflows).
  3. A screen that never calls the sync. Editing a role preset changes what everyone on it can do in the desk on their next request. At the commit we read, 20 September 2026, that screen did not call the sync, so those people kept their old rows until their next invite or update.
  4. A helper nothing calls. We wrote a function that denies every topic and then deletes the ntfy user, in that order, so a failed delete still leaves access gone. Nothing calls it and there is no delete route, so deactivation is the working revoke.
  5. A catch-all outside the map. The default topic receives test sends and any alert whose own topic returned 403, and no permission earns it. Decide who may read it.

Test the first one on your version. The admin endpoints below exist in the v2.30.0 source and are undocumented, so pin your version.

# 0. as an admin: give the person read-only on the topic
curl -u admin:PASSWORD -X POST https://ntfy.example.com/v1/users/access \
  -d '{"username":"person@example.com","topic":"kyc","permission":"read-only"}'

# 1. an open stream, as the person
curl -u person@example.com:PASSWORD -s https://ntfy.example.com/kyc/json

# 2. as an admin, in a second terminal: set a deny row
curl -u admin:PASSWORD -X POST https://ntfy.example.com/v1/users/access \
  -d '{"username":"person@example.com","topic":"kyc","permission":"deny"}'

# 3. does stream 1 end? Then repeat with the row deleted:
curl -u admin:PASSWORD -X DELETE https://ntfy.example.com/v1/users/access \
  -d '{"username":"person@example.com","topic":"kyc"}'

The command-line tool is different: it edits the database directly and the running server is not told. With the optional ACL cache on, the server learns on the next reload, every 87 seconds by default (config docs).

What breaks when you add a topic under deny-all?

A new topic drops its alerts until the publisher is granted write on it. Deny-all gates publishing as well as reading. The first alert returns 403, the publisher logs a failed send, and nothing else happens: no alert about the missing alert. It cost us twice in two days. The first split into topics lost three alerts on 15 July 2026, and the queue topic followed on 16 July.

Our fix has three parts. On a 403 the alert goes to the default topic with the intended topic in its title, so it is loud and wrong instead of silent. The log records the topic the alert actually reached, not the one it meant. And a probe sends one message to every topic after any change.

Add the topic to the routing table and the map

The guard test fails if a routed topic has no permission, so you cannot forget the map.

Grant the publisher write on it

In ntfy, give the publishing account write, or write-only if it never reads. This is the step that was skipped twice.

Run the probe

Send one message to each topic and read sent or failed for every one.

Sync the people who should read it

Existing people have no row on the new topic, and nothing in our code runs a bulk sync, so trigger one.

Look for 403s a day later

The failed-send log is the only place a lost alert is recorded. Read it.

Frequently asked questions

+How do I make a self-hosted ntfy private?

Set auth-default-access to deny-all and give ntfy an auth database with auth-file. Leave signup off, create an admin and the accounts you need, and grant per-topic rows. A request with no matching row is refused. Serve it over HTTPS, because passwords travel with each request.

+What does auth-default-access deny-all do in ntfy?

It sets the fallback for a request that matches no row to no access, for reading and for publishing. The default is read-write, which leaves the server open. Admins skip the table, and each user keeps access to their own sync topic. Everything else needs a row.

+Does ntfy have groups or roles for topics?

No. ntfy has two roles, user and admin, and an access list of rows per user and topic pattern. A request for an all-authenticated-users principal has been open since December 2023. To give a team the same access, write the same rows for each member, ideally from your own role list.

+Can I give someone read-only access to an ntfy topic?

Yes. The permissions are read-write, read-only, write-only and deny. On the server, ntfy access person@example.com kyc read-only adds the row. The person can then subscribe but not publish. Setting deny on the same topic replaces that row.

+What happens if I remove a user's access while their phone is connected?

New requests are refused at once. A stream that is already open is checked only when it starts. In ntfy's own admin API, deleting the row or the user closes it, and setting a deny row does not, as far as the source shows. Test it on your version.

+Which ntfy version should I run for access control?

The current stable server release is v2.30.0, from October 2026. Run 2.24.0 or later, which fixed case-insensitive topic matching on SQLite. If your users sync their subscriptions across devices under deny-all, run 2.25.0 or later, which gives each user access to their own sync topic.

+Why does a new ntfy topic return 403 for my publisher?

Deny-all applies to publishing as well as reading. The publishing account has no row on the new topic, so ntfy refuses it. Grant that account write on the topic, then send a test message. Check your send log, because nothing else reports the failure.

What is the bottom line?

The server settings take ten minutes. The work is the list you keep beside them, and making sure it changes whenever the other list does. Write the map, deny what is not earned, and test the revoke before you need it.

Your next step

Routing alerts from your own back office?

See it live

An online order, start to finish

A shop order is picked, shipped, billed and paid, and nobody types it twice.

You end with a payment recorded · 5 screens · about 8 min

No sign-up, no card, no account.

Next step · Automation, AI & open source

Now see hosting someone else looks after

ERPNext, Medusa and open-source tools on infrastructure you own, with updates, backups and monitoring handled for you.

Managed hosting
0
Published on 14 October 2026

Manoj

Comments & ratings

No comments yet. Start a new discussion.

Worked with us? Leave a Google review

Questions about this? Talk to the team.

Book a 30-minute Google Meet, WhatsApp us, or send a message. The engineer who works on this replies.

Reach us your way

Pick a 30-minute slot that suits you. The invite carries the Google Meet link.

Loading the calendar…