Who's asking¶
Profiles are off until AUTH_TRUSTED_NETWORKS or AUTH_PROXY_SECRET is set. While they're off, everyone is treated as an admin. Once they're on, app/services/identity.py decides who each request belongs to:
- Through the sign-in proxy (Authelia behind Caddy): the request carries the shared secret plus the user header. Group membership sets admin (
nessie-admins), the AI permission (nessie-ai) and the request permission (nessie-requests). - From a trusted network (home): the profile this device picked, kept in the signed
nessie_profilecookie. A profile may have a PIN. - Anything else is refused. A 401 means "pick a profile" and makes the UI show the picker. A 403 means "not allowed".
The profile cookie¶
The cookie is {user_id}.{issued}.{signature}. The signature is an HMAC with secret_key(), which comes from SECRET_KEY or is made once in data/secret_key. The signed message includes two things that can change, so changing either one makes every older cookie stop working:
- The PIN hash. Setting or changing a PIN signs the profile's other devices out.
users.signed_out, a counter.POST /v1/profiles/{id}/sign-out-devicesadds one to it, which is the "Sign out your other devices" button in Settings → Profiles, for a lost phone. Anyone can do it for themselves and admins for anyone. The device that asked gets a fresh cookie. At 0 the counter is left out of the message, so cookies from before it existed stay valid.
The cookie only covers devices at home. Sessions through the sign-in page belong to Authelia, and signing out there ends them.
Watch for this
Whoever has AUTH_PROXY_SECRET can say they're anyone, with any groups. Keep it out of chats and repos. Only the proxy should be able to reach Nessie's port, using a Tailscale ACL or a DOCKER-USER rule: Docker shows every tailnet request as its gateway, so AUTH_PROXY_IPS can't tell the proxy apart. The README's Caddy example removes Remote-* headers before forward_auth, inside a route so the order holds. Any future route that skips the sign-in page must never send the secret.
In app/api/auth.py, guards are FastAPI dependencies:
require_useris attached to every router inmain.py.AdminandAIare added to individual routes.@publicexempts a route; the Lidarr webhook uses it and checks its own token instead.
Inside a route, current_identity(request) gives you Identity(mode, person). That's how per-profile features (Most played, My libraries) know whose data to use.
Whose playlists and mixes are¶
With profiles on, every playlist has an owner_id: a profile, or none, which makes it the household's. It also has a private flag. app/services/ownership.py answers who may do what, through a Viewer built from the request's identity:
| Its owner | Others | Admins | |
|---|---|---|---|
| Yours, shared (the default) | everything | see, play, Save a copy | also delete, give away |
| Yours, private | everything | nothing (404) | nothing either |
| The household's | everyone changes it, as before profiles | also give away |
The routes check every request: an unseen playlist is a 404, and a change you may not make is a 403 ("This is Sam's playlist. Save a copy to change it."). PlaylistOut carries owner, private and can_edit, can_delete, can_give and can_share, which the UI uses to decide what to show. Names are unique per owner: the index is on (coalesce(owner_id, 0), lower(name)), because Postgres counts every NULL as different. Deleting a profile takes ?playlists=household|give|delete (and give_to), and Settings → Profiles asks which. Renames on a clash give "Road trip (Sam)". With profiles off, Viewer.everyone is true and everything is everyone's.
Custom mixes work the same way, through the same Viewer. They have the same columns, CustomMixOut carries the same can_*, and POST /v1/custom-mixes/{id}/copy makes a copy. There are no name rules, because mix names needn't be unique. Two other places check who's asking:
smart_playlists._offer_custom_mixes, which lists the mixes on the Mixes page. Its offers carryowner_id,owner_nameandprivate.custom_mix(), the daily pick. Someone else's private mix plays nothing.
Ownership doesn't change whose plays count: a shared mix's "not played in 2 weeks" is about the plays of whoever is listening. Deleting a profile takes ?theirs=household|give|delete, covering playlists and mixes together. The front end's whoseHeading(), giveDialog() and gone() in views/common.js serve both kinds of page. gone() sends you back to the list after you give away a private one, which you can no longer see.
The UI mirrors this with identity.js: isAdmin(), canUseAi() and myLibraryIds() decide what to show. The server enforces it regardless of what the UI shows.