Skip to content

Users and Access

Scope: locking the server down — users, per-topic grants, the default for everyone else — and the four answers a locked server gives.

Until now the lab has been open: the config's auth-default-access: "read-write" lets anyone who can reach it publish and subscribe. On loopback, "anyone" is you. This chapter builds the locked version: named users, each allowed specific things on specific topics, and everyone else refused.

The cast is from lab-notes.txt: alice, who may read and write the lab-private topic, and bob, who may only read it. Their passwords (alice-lab-pass, bob-lab-pass) are invented fixtures for a throwaway lab server, printed here because the captures need them. They protect nothing. Real passwords for a real server are made the same way but kept like passwords.

Making users

The user and access commands are server commands: they edit the auth database file directly, on the server machine, and they find that file through the server's config. In this version of ntfy that pointer is set with the NTFY_CONFIG_FILE environment variable (or the --config flag those commands carry) — there is no global --config, and a first attempt without the pointer fails on exactly that. One export, then the commands work:

$ export NTFY_CONFIG_FILE=/home/hatch/workspace/ntfy-build/server.yml   (so the user/access commands find the lab auth database)
$ NTFY_PASSWORD=alice-lab-pass ntfy user add alice
user alice added with role user
$ NTFY_PASSWORD=bob-lab-pass ntfy user add bob
user bob added with role user
$ ntfy user list
user alice (role: user, tier: none)
- no topic-specific permissions
user bob (role: user, tier: none)
- no topic-specific permissions
user * (role: anonymous, tier: none)
- no topic-specific permissions
- read-write access to all (other) topics (server config)

(NTFY_PASSWORD keeps the password out of the interactive prompt, which is what makes the command scriptable; it also puts it in your shell history — another reason lab passwords are lab passwords.) Three things to read in the listing. A fresh user has no permissions of their own. The * user is "everyone else" — the anonymous visitor — and its line "(server config)" is the auth-default-access setting, about to change. And a user's role can be admin, which overrides everything (an admin reads and writes all topics); alice and bob are ordinary users, governed by grants:

$ ntfy access alice lab-private read-write
granted read-write access to topic lab-private

user alice (role: user, tier: none)
- read-write access to topic lab-private
$ ntfy access bob lab-private read-only
granted read-only access to topic lab-private

user bob (role: user, tier: none)
- read-only access to topic lab-private

Grants are per topic, and the topic patterns can be broader (alerts-*) or the whole server — but notice the order of operations: grants were made while the server was still open, and they already existed when it locked. Making them costs nothing in either state; the database is the same file the running server reads.

Locking the door

Now the one-line change: in server.yml, auth-default-access becomes "deny-all", and the server restarts. The grants stay; the welcome mat goes. Check: the four answers a locked server gives, each captured:

Anonymous — no credentials, no grant. Forbidden:

$ curl -s -w "\nHTTP %{http_code}\n" -d "Anyone there?" http://127.0.0.1:8099/lab-alerts
{"code":40301,"http":403,"error":"forbidden","link":"https://ntfy.sh/docs/publish/#authentication"}

HTTP 403

Alice, with her grant, publishes to lab-private as before — credentials ride along as ordinary HTTP basic auth (-u user:password):

$ curl -s -w "\nHTTP %{http_code}\n" -u alice:alice-lab-pass -d "Private backup finished" http://127.0.0.1:8099/lab-private
{"id":"8m7OCDtUaLod","time":1792611170,"expires":1792654370,"event":"message","topic":"lab-private","message":"Private backup finished"}

HTTP 200

Alice with a wrong password is a different answer — not forbidden, but unauthorized. The distinction is diagnostic gold: 401 means "I don't believe you are alice"; 403 means "I believe you, and no":

$ curl -s -w "\nHTTP %{http_code}\n" -u alice:wrong-password -d "Private backup finished" http://127.0.0.1:8099/lab-private
{"code":40101,"http":401,"error":"unauthorized","link":"https://ntfy.sh/docs/publish/#authentication"}

HTTP 401

Bob, read-only, tries to publish and gets the 403 his grant promises:

$ curl -s -w "\nHTTP %{http_code}\n" -u bob:bob-lab-pass -d "Bob trying to publish" http://127.0.0.1:8099/lab-private
{"code":40301,"http":403,"error":"forbidden","link":"https://ntfy.sh/docs/publish/#authentication"}

HTTP 403

And bob doing exactly what he is allowed — reading the topic — works, and shows him alice's message:

$ curl -s -u bob:bob-lab-pass "http://127.0.0.1:8099/lab-private/json?poll=1"
{"id":"8m7OCDtUaLod","time":1792611170,"expires":1792654370,"event":"message","topic":"lab-private","message":"Private backup finished"}

What this buys, and what it doesn't

Buys: a server a stranger can find but not use; topics whose contents are read only by the people granted; scripts that authenticate (the ntfy client takes the same credentials as --user, and upstream also issues per-user access tokens — the ntfy token command — so scripts need not carry the account password; tokens are named here as documented, the lab used basic auth).

Doesn't buy: secrecy on the wire. Basic auth over plain HTTP is a password in a trench coat — readable by anyone on the path. On loopback there is no path; on a network, chapter 7's HTTPS is what makes chapter 8's locks worth turning. And on the public server there are no locks of yours to turn: anyone who knows or guesses your topic name can read and publish it, which is why public-server topics get long, unguessable names — the name itself is the password there.