privacytracker is single-user by design. There are no user accounts. Anyone who can reach the UI has full access to its data. Hardening here is about controlling who can reach it, not about partitioning between users on the same install.
Decide your network exposure
Pick the most restrictive box you can live with — every step below is calibrated to one of these.Tier 1: localhost only
Tier 1: localhost only
Default for the desktop app. No external network exposure; only processes on your machine can reach it. CSRF is enforced.The desktop app binds
127.0.0.1 on a port of its own, never 3000, and takes no admin token. See Set the admin token.http://localhost:3000 is the default for a server you run yourself, from Docker or a source checkout. That server counts as loopback-only when you tell it so with PRIVACYTRACKER_BIND_HOST=127.0.0.1; left unset, its bind reads as unknown and the admin token becomes mandatory. See Configuration.Hardening checklist: none beyond the defaults. You’re done.Tier 2: trusted LAN
Tier 2: trusted LAN
Hosted on a NAS / Pi / always-on machine reachable from other devices on your home or office network. Multiple household members can use the same instance. Not exposed to the internet.Hardening checklist: TLS via reverse proxy, admin token set, dev endpoints blocked, off-host backups. The
cookbook recipe at Self-host on a home NAS for the household walks through this end-to-end.Tier 3: public internet
Tier 3: public internet
Reachable from anywhere. This is not a supported deployment model — privacytracker has no user accounts and no DDoS protection, and its internal rate limiter is defence-in-depth rather than an edge control: without
PRIVACYTRACKER_TRUST_PROXY every caller shares one bucket per route, so it can’t tell a flood from a busy household. If you absolutely must, do all of Tier 2 plus: a rate-limiting reverse proxy (Cloudflare / a paid provider), IP allowlists, and an off-host TLS termination. Better: use Tailscale, WireGuard, or another mesh-VPN to keep the actual surface on a trusted network and only expose VPN authentication to the public internet.TLS via reverse proxy
privacytracker ships with no built-in TLS — it expects a reverse proxy to terminate. The proxy is also the natural place for edge rate limiting, IP allowlists, and structured access logs. The main repo ships working Compose stacks for two of the three proxies below —deploy/caddy/ (Caddyfile, compose.yaml, .env.example) and deploy/traefik/ (compose.yaml, .env.example). If you cloned the repo, start from those rather than the snippets here; the snippets are the minimum that makes the same-origin check work, not a complete deployment.
- Caddy
- Traefik
- Nginx
The shortest working config for a LAN deploy:Caddy auto-issues Let’s Encrypt certificates if
privacytracker.lan resolves on the public internet, or self-signed certs for a LAN-only deploy. The Host forwarding line is non-negotiable — privacytracker enforces a same-origin CSRF check, so a proxy that strips or rewrites the Host header will cause every mutation to fail with 403 {"error":"Cross-origin mutation rejected"}.Host. See Troubleshooting → Reverse-proxy CSRF rejection.
Set the admin token
Generate a token with at least 32 bytes of entropy and inject it into the runtime environment.The desktop app takes no admin token, and there is no way to give it one. It starts its backend with a fixed environment that does not include
AUDITOR_ADMIN_TOKEN, so setting the variable in a launchd plist, your shell profile or the app’s own environment has no effect. The desktop app binds 127.0.0.1 only, which keeps it out of the network-exposed posture that makes the token mandatory. See Tauri desktop shell for the variables the shell does hand the server.crypto.timingSafeEqual so token comparison runs in constant time. Failed attempts append to audit_log with the requester IP — see Review the audit log below.
Setting the token also turns on the browser-session path: the Settings panel exchanges it once via POST /api/auth/admin-token/login for an 8-hour HttpOnly pt_admin_token cookie, so household members don’t paste a secret into every request and no script running in the page can read it. POST /api/auth/admin-token/logout clears the session. See Security → Browser sessions use a cookie.
Once the instance is network-exposed, the token also gates a handful of sensitive reads — /api/backup/, /api/export, /api/deployment/, /api/diagnostics/, /api/ai/debug-log, /api/import/, /api/desktop/diagnostics. Any integration you point at those needs the header or the cookie too.
Block dev endpoints from the proxy
privacytracker ships with/api/dev/* routes that are only safe in development:
All five ship in production builds — nothing in the source gates them to development.
These are gated by the same CSRF and admin-token checks, but for a Tier 2/3 deploy you should also block them at the proxy so they aren’t reachable at all. Caddy:
Review the audit log
Theaudit_log table records destructive-route attempts and authentication failures. Review it on a regular cadence appropriate to your exposure tier.
There is no UI for this table — nothing in the app reads it. SQL is the only way in:
- Repeated
admin_token.login.invalidfrom the same IP — someone’s brute-forcing. Rotate the token, then ban the IP at the proxy.admin_token.login.rate_limitedandadmin_token.login.global_throttledmean the built-in limiter already pushed back. - Unfamiliar IPs hitting destructive routes — even if the token check passed (i.e., they have the token), an unfamiliar source means the token leaked. Rotate.
reset.successyou didn’t trigger — your data was wiped. Restore from backup; investigate how the actor got the token.
PRIVACYTRACKER_TRUST_PROXY if you want real client IPs in actor_ip. Without it, X-Forwarded-For is attacker-controlled and deliberately ignored, so every row records the literal local — which makes “same IP” un-observable and the first bullet unusable.
Don’t treat the table as tamper-proof. Individual rows are only ever appended, never deleted or rewritten, but two routes wipe the whole table: POST /api/admin/start-over truncates it (audit_log is in START_OVER_TABLES_TO_TRUNCATE, lib/reset-tables.ts), and POST /api/backup/restore clears it and re-inserts whatever the bundle carried. Both are admin-token routes, so the actor you’d be investigating here — someone who has the token — can erase the trail behind them. POST /api/reset preserves it on purpose. If you need the trail to survive that, replicate it off-host on a schedule alongside your backups. If it grows large, drop and recreate manually with the app stopped.
Off-host backups
A NAS’s own RAID is not a backup. For Tier 2/3, schedule a daily replica of the data directory to a different physical machine.data/backups/, then sync those off-host. They’re off by default — enable the toggle and pick an interval. The bundle carries an integer format version, and a bundle from an older format restores cleanly into a newer release. See Backup & restore for the full options.
Periodically verify a backup by restoring it into a throwaway instance — a backup you’ve never tested isn’t a backup, it’s a wish. Note the throwaway will reject the bundle as untrusted (different install, different signing key) unless you pass allowUntrusted=1; see Restoring a bundle from another install.
Disable inbound auto-update on Tier 3
The Tauri auto-updater fetches signed patches from GitHub releases on a recurring schedule, verified against the bundled minisign public key. For a Tier 3 (public-internet) deploy you may want to stop automatic updates and pull-and-verify each release by hand instead. Tradeoff: you lose silent security patches; you gain deterministic timing of every code change. If you need deterministic update timing today, the options are:- Run the Docker build instead of the desktop app on Tier 3 hosts. Image updates are explicit (
docker compose pull && up), which is the posture this tier wants anyway. - Block the update endpoint at the host firewall — the updater reads
https://github.com/privacykey/privacytracker/releases/latest/download/latest.json. A failed check now backs off (15 minutes, doubling, capped at a day) rather than retrying tightly, so a blackholed endpoint is not a busy loop.
Network restrictions
If your host firewall supports it, restrict outbound network from the privacytracker process to only the destinations it actually needs:
The last two are different things and people get them the wrong way round.
github.com is the desktop updater’s signed-patch endpoint. api.github.com is lib/update-check.ts, which runs on Docker, Node, Homebrew, and desktop alike: enabled by default, first probe 25 seconds after boot, then a 6-hour ticker that reaches the network at most once a day thanks to the cache. Block it believing it’s desktop-only and you silently disable the update banner on a server install. To switch the check off properly rather than blackholing the host, set its app_settings flag — there’s no UI toggle for it today:
Pre-launch checklist
Before exposing your install to anyone other than yourself:1
TLS terminates at a reverse proxy
Verified by browser padlock +
curl -v showing the cert.2
Same-origin check passes through the proxy
curl -X POST .../api/sync/trigger -H "Origin: https://your-host" returns 200, never 403. A sync already in flight is still a 200 — the body just carries "skipped": true.3
Admin token is set and required
curl -X POST .../api/reset -H "Origin: ..." (no token) returns 401 {"error":"Admin token required"}.4
Dev endpoints return 404
curl -X POST .../api/dev/seed-sample-data -H "Origin: ..." returns 404.5
Off-host backup ran at least once
ls /backups/privacytracker-*/privacy.db on the backup host shows a recent file. Restored it into a throwaway instance and counts match.6
Audit log is empty (or only your own legitimate events)
sqlite3 data/privacy.db "SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 20;" shows nothing surprising.What you can’t lock down
privacytracker is a self-hosted single-user app. There are guardrails it doesn’t have, by design:- No user accounts. Anyone with browser access has full access. Treat the URL itself as a credential.
- No per-caller rate limiting without a trusted proxy. privacytracker does limit its own routes (Rate limiting) — 30/min on
/api/scrape, 3 per 10 min on/api/backup/restore, 5/min on token login, and so on. But unlessPRIVACYTRACKER_TRUST_PROXYis set,X-Forwarded-Foris untrusted and every caller shares one bucket per route, so a bad actor inside the trust boundary consumes the same budget as the household. Rate-limit at the proxy if Tier 3. - No anti-CSRF beyond same-origin. No token-cookie pairs, no SameSite=strict-only flows. The same-origin check + the admin token are the entire CSRF surface.
- No append-only enforcement on
audit_logat the SQL level. Individual rows are never rewritten — the only write is an insert — but two admin-token routes clear the whole table (POST /api/admin/start-overtruncates it,POST /api/backup/restorereplaces it), and a process with shell access todata/privacy.dbcan rewrite it directly. So the actor you are investigating after a token leak can erase the record of their own access. The mitigation is to replicate the log off-host, and not to give untrusted actors shell access.