This page is for self-hosters making real deployment decisions, contributors keeping the security posture intact, and security researchers running tests under our coordinated-disclosure policy. Wherever the docs-site version diverges from the canonical Security card on the privacytracker Plane board, the Plane board is the source of truth and this page mirrors it for discoverability.
privacytracker is designed as a single-user, local-first tool. The threat model assumes one trusted operator running it on hardware they control. If you’re putting this in front of multiple untrusted users, you’re outside the supported model — see Runtime and database boundaries below.

Threat model

What privacytracker protects against:
  • Silent privacy-label changes. The whole point. Snapshots + diffs + notifications surface when an app’s disclosed data collection changes without you noticing.
  • Cross-origin abuse of the local API. Same-origin CSRF check on every mutating verb. A malicious tab can’t trigger POST /api/scrape against your local install.
  • Casual access to destructive routes when the API is exposed beyond localhost. The optional AUDITOR_ADMIN_TOKEN adds a shared-secret layer on top of CSRF.
  • Brute-forcing the admin token. Token login is capped at 5 attempts per minute, plus an absolute backstop that trips after 100 failed attempts in 15 minutes no matter where they come from. Comparison is constant-time. See Rate limiting for what the per-caller bucket is actually keyed on.
  • DNS rebinding. Every request — including GET — is rejected with 400 Host not allowed unless its effective Host is on the allowlist, which defaults to loopback only.
  • Silent ed25519 signature stripping during desktop auto-update. The Tauri updater verifies every patch’s signature before applying it.
  • Force-include of private annotations in audit-bundle exports. SQL-level filter at build time; no URL parameter or escape hatch can override it.
  • Unwitting AI provider exfiltration. AI is opt-in and disabled by default. When enabled, only privacy-policy text is sent — never app names, your annotations, or telemetry of any kind.
What privacytracker does not protect against:
  • A malicious or compromised host machine. If your laptop is rooted, privacytracker can’t protect data inside it. Use full-disk encryption.
  • A compromised AI provider. When you enable an AI provider and send privacy-policy text to it, that text leaves your machine. We hash policies and skip re-summarisation when the hash is unchanged, but the content of policies is still visible to whichever provider you chose.
  • Volumetric DoS from many source IPs. privacytracker does rate-limit its own routes (Rate limiting), but that limiter is defence-in-depth: the thresholds are modest, and unless you set PRIVACYTRACKER_TRUST_PROXY every caller lands in one shared bucket per route. It will not absorb a distributed flood. If you expose the instance to the public internet, put it behind a rate-limiting reverse proxy.
  • Multi-user separation. There are no user accounts. Anyone with browser access to your instance has full access to its data.
  • Physical attacks against your machine. Out of scope.

What data leaves your device

Five things, all opt-in or transparent:
privacytracker fetches each app’s App Store page to read its public privacy labels. This is the same network call your browser makes when you visit apps.apple.com/.../id<digits>. The request goes directly from your machine to Apple — no intermediary. No personal data is sent.Apple sees the request as coming from your IP address. They presumably log it like any other web request. There is no Apple developer account or API key involved.
When AI summaries are enabled, privacytracker follows the Developer’s Privacy Policy link from each app’s App Store page and fetches the policy text. The fetcher follows redirects, respects standard Cache-Control headers, and does not auto-fill any form fields. The developer’s host sees a request from your IP for the URL Apple already publishes on the App Store page.The text is hashed (SHA-256) and re-summarisation only happens when the hash changes — so cosmetic edits to a policy don’t burn AI calls or re-leak anything. A fetch keeps at most 300,000 characters of the text, and each app keeps its newest 20 captured versions (no more than 8 MiB between them), so a page that changes on every visit cannot grow the database or its backup without bound.
Two features talk to archive.org, and both send it a URL Apple or the developer already publishes, never anything about you beyond the IP the request comes from.The Wayback importer (Settings → Historical Import, or Check the archive on an app’s Change History tab) lists the archived copies of the app’s App Store page, fetches the ones it needs, and at most once per app asks Save Page Now to capture today’s page, only when the Archive holds no copy from the last 45 days. After each successful policy fetch, privacytracker also looks up the newest archived copy of that policy so the AI Policy and Change History tabs can link to one, keeps whichever known copy is newest, and asks for a fresh capture only when no copy from the last 45 days is known, from that lookup or from an earlier save. If a developer’s site refuses the policy twice, the text is read from the newest archived copy instead. None of this happens while policy scraping is disabled.
If you’ve configured an AI provider (OpenAI, Anthropic, or your own local OpenAI-compatible endpoint), privacytracker sends the policy text to that provider as part of summarisation prompts. The provider receives:
  • The policy text (chunked if the provider’s providerLikelyNeedsChunking flag is set)
  • The lens prompts (POLICY_TOPIC_GUIDES in lib/privacy-policy.ts)
  • Provider-required headers (your API key in the case of OpenAI / Anthropic)
The provider does not receive: app names, bundle IDs, your annotations, your IP-on-the-Internet (assuming standard egress), telemetry, or anything else.Local providers (Ollama, llama.cpp, LM Studio, vLLM) keep all of this on your machine.
If you paste an incoming-webhook URL into the desktop app’s Keep privacytracker running in the background wizard (or write notification_webhook_url through POST /api/settings on any build), privacytracker POSTs notifications to that URL. Off by default — an empty URL, or notification_webhook_frequency = off, disables it entirely.The receiving service sees app names and change summaries, such as “Instagram: 2 new categories under Data Used to Track You”. With Privacy policy updates on, which it isn’t by default, it also learns which apps’ privacy policies changed and the host name in each policy’s link, as in “Spotify: Privacy policy text changed at spotify.com.” It does not see your annotations, privacy profile, AI keys, or the underlying policy text. Four payload shapes are supported (slack, discord, teams, generic); The generic payload also carries the structured rows under notifications.Delivery is either immediate or a daily or weekly batch posted from the 30-minute tick. Immediate delivery posts each App Store change (privacy labels, accessibility labels or age rating) as soon as a sync has saved it, and each privacy-policy change as it’s raised, during quiet hours too. The batches carry the notifications raised since the previous one. Neither applies the bell’s per-type filters, so a change the bell leaves out is still sent. Requests go out through the same safeFetch SSRF, size, and timeout guards as /api/scrape. Since this is a third-party chat service, treat it as you would any other outbound integration: pick a private channel.
privacytracker has no analytics, no telemetry, no crash reporting of its own. Beyond the four paths above, the SQLite database, all settings, all your annotations stay on your machine.

Authentication & access control

Three layers. The host allowlist applies to every request; the CSRF check applies to mutations; the admin token applies to mutations and — on a network-exposed instance — to a short list of sensitive reads too.

Host allowlist (always on)

Before anything else, proxy.ts rejects any request — GET included — whose effective Host isn’t allowlisted:
The default allowlist is loopback only. Add LAN hostnames with PRIVACYTRACKER_ALLOWED_HOSTS — see Configuration → Network exposure. This is the DNS-rebinding defence: browsers can’t spoof Host, so a page that rebinds DNS to your loopback instance still arrives under its own hostname and is bounced.

Same-origin CSRF check (always on)

Every mutating verb (POST, PUT, PATCH, DELETE) on /api/ checks that the Origin header matches the request host, unless the caller supplies the admin token instead. The check lives in proxy.ts and runs before any route handler.
The body is human-readable prose, not a stable machine code — match on the status, not the string. Browser requests from your installed UI satisfy this automatically. Cross-origin or curl-from-a-different-host calls won’t unless they set the header explicitly. If you’re behind a reverse proxy, the proxy must forward the original Host header — see Troubleshooting → Reverse-proxy CSRF rejection.

Admin token (optional, opt-in)

When you set AUDITOR_ADMIN_TOKEN in the environment, destructive routes (POST /api/reset, DELETE /api/apps, POST /api/settings, DELETE /api/wayback/import-all, etc.) require the token on top of the CSRF check. Calls without it get 401 { "error": "Admin token required" }.
Verification uses crypto.timingSafeEqual so token comparison runs in constant time regardless of where the first byte differs. This rules out timing oracles even on a fast LAN. The token is purely opt-in — there’s no built-in user/account system in privacytracker. Use it when the app is exposed beyond a single-user trusted boundary (e.g., self-hosted on a LAN where multiple devices can reach it).
Generate a token with openssl rand -hex 32. Store it like any other secret — never commit it, never paste it into a public log.

Sensitive reads are gated too

Once the instance is network-exposed — any non-loopback entry in PRIVACYTRACKER_ALLOWED_HOSTS, PRIVACYTRACKER_NETWORK_EXPOSED set, or a specific non-loopback PRIVACYTRACKER_BIND_HOST — the token is also required on GET requests whose path starts with any of:
Missing or wrong token gets 401 { "error": "Admin token required for non-local API access" }. This proxy gate never fires on a loopback-only install — but it isn’t the only gate. GET /api/backup/export and GET /api/ai/debug-log check adminTokenRequiredForRequest() themselves, which is adminTokenConfigured() || isNetworkExposed() (lib/security.ts), so they return 401 { "error": "Admin token required" } as soon as AUDITOR_ADMIN_TOKEN is set, loopback or not. This is why “read-only means unauthenticated” doesn’t hold for the LAN deployments the token exists for. The Settings panel can’t put a long-lived secret in JavaScript, so it exchanges the token once and works from a cookie afterwards: All three are exempt from the non-local admin gate (ADMIN_AUTH_BYPASS_PREFIX in proxy.ts) — the login endpoint is how you obtain the cookie in the first place, so gating it would be circular. Same-origin is the narrower claim: login and logout call isSameOriginRequest() themselves, but GET .../status doesn’t, and the proxy’s CSRF check only covers POST/PUT/PATCH/DELETE — so a cross-origin GET reaches that handler. What it gets back is two booleans; the token itself is never returned. Everywhere else proxy.ts accepts either credential, with one asymmetry: the header takes the raw token only, while the cookie takes the derived session value or the raw token. Every pt_admin_token cookie on the request is tried, so a cookie planted by a sibling port or a parent domain cannot shadow the real one. Because the secret lives in the server process, a restart or a token rotation signs every browser out. Keeping the token out of sessionStorage is the point: an injected script can’t read an HttpOnly cookie, so an XSS bug can’t exfiltrate the token and replay it later from somewhere else, and a session value that does leak is worthless once the server restarts.

Rate limiting

privacytracker rate-limits its own routes. checkRateLimit() in lib/security.ts is a per-key sliding window, keyed on route prefix plus client IP, applied to 68 of the 122 API routes — 41 calling it directly, 27 more through requireMutationGuard(), which also writes a <action>.rate_limited audit row. Every denial is a 429, but only some carry Retry-After: requireMutationGuard() always sets it (lib/api-guards.ts), as do a handful of direct callers such as /api/scrape, /api/search, and the admin-token login. Most routes that call checkRateLimit() directly — /api/settings, /api/reset, /api/backup/export, /api/backup/restore, /api/shortlist, the /api/manual-apps family — return a bare 429 with just the error body, so a client that backs off on the header needs a fallback for those. None of it is behind an env var or build mode. Read the boundary carefully. Without PRIVACYTRACKER_TRUST_PROXY, X-Forwarded-For is attacker-controlled and deliberately ignored, so the IP part of every key collapses to the constant local — one shared bucket per route rather than one per caller. That’s what the last row is for: an absolute, IP-independent backstop on failed logins, counting failures only, so an attacker who trips it inflicts a self-healing cooldown rather than locking you out. The practical consequence: these limits stop a runaway client loop and make token brute-force impractical. They are not a substitute for a rate-limiting reverse proxy in front of an internet-facing instance.
A 429 from privacytracker is not the same thing as a 429 from Apple. The internal limiter logs its denials with the marker This is our INTERNAL limiter (lib/security.ts), not Apple's 429 cooldown. and clears within its own window; Apple’s cooldown is longer and shows up as partial: rateLimited in the sync runner.

Audit log

Every failed admin-token attempt (and a handful of other security-relevant events) is appended to the audit_log table: Action codes are namespaced by facility, then outcome. A failed admin-token login is admin_token.login.invalid (with admin_token.login.rate_limited and admin_token.login.global_throttled for the two throttle paths); a successful one is admin_token.login. A reset is reset.success, reset.unauthorised, reset.rate_limited, or reset.failed. Bundle export is backup.export.success; restore is backup.restore.*. Rows are only ever appended — the schema has no deleted_at column and nothing deletes or rewrites an individual entry. The table as a whole is not protected, though: audit_log is listed in START_OVER_TABLES_TO_TRUNCATE (lib/reset-tables.ts), so POST /api/admin/start-over runs DELETE FROM audit_log, and POST /api/backup/restore clears it too before replacing it with whatever the bundle carried (TABLES_IN_INSERT_ORDER in lib/backup.ts). POST /api/reset is the destructive route that deliberately preserves it. Both wipe paths need the admin token — which means an actor who has the token can erase the record of their own access, so copy the log off-host if you need it to survive that. If you want to prune it for storage, drop and recreate manually with the app stopped. There is no audit-log UI. Nothing in the app reads this table — no route SELECTs from it, and there’s no Settings panel for it. It’s a forensic trail, not a screen. Read it with the sqlite3 CLI:
The separate activity_log table — scrapes, re-syncs, scheduled runs, migrations, backup/restore — is surfaced in the UI, under Settings → Admin → Developer Options → Activity log. Don’t confuse the two: activity_log is the operational timeline, audit_log is the privileged-request trail.

Cryptographic primitives

Backup bundles are signed, not encrypted. The key is 32 random bytes at <data dir>/backup-signing.key, mode 0600, generated on first use and never leaving the machine — so a signature proves “this bundle came from this install”, nothing more. A bundle exported anywhere else fails verification and needs the explicit untrusted opt-in to restore; see Backup & restore. Anyone who can read that key file can forge envelopes targeting your install. Both bundle kinds are unencrypted JSON so they’re inspectable and recoverable without tooling. Treat them as sensitive data and share via channels you’d use for any other personal file — see Audit-bundle export threat model below.

Verifying signed binaries

The desktop app builds we publish on GitHub releases are:
  1. Signed with a Developer ID certificate
  2. Notarized by Apple
  3. Verified at install time by Gatekeeper
  4. Re-verified on every auto-update by Tauri’s ed25519 check before the patch is applied
You can verify the signature manually:
To verify the build matches source, build from source and compare the resulting bundle. The build is reproducible up to timestamp metadata; the unsigned .app bundle should match byte-for-byte modulo the signing payload.

Audit-bundle export threat model

An audit bundle is a JSON file describing tracked apps, their privacy labels, AI summaries, and exportable annotations. Bundles can also include the privacy profile you assessed against, with an opt-out checkbox in the export dialog. POST /api/export/audit-bundle allows the export when the flag.settings.admin.export.audit_bundle flag resolves to on, or when your focus workflow (flag.focus.workflow) is other_handoff — the “I’m preparing a bundle for someone else” answer. Either arm is sufficient, and the first one is what a recommender trips: AUDIENCE_RULES.loved_one in lib/feature-flag-rules.ts sets that flag to on, so picking the Loved one audience unlocks the export by itself, with the workflow still sitting at custom. For self and guardian the hard default is off, and those audiences need the other_handoff answer or an explicit flag override before the route stops returning 403. The API is the authoritative gate; client surfaces just hide the button. What’s included (when you click Export):
  • Tracked apps + their full privacy disclosure (categories, identifiers, policy text excerpt, AI summary, accessibility features)
  • Annotations marked visibility = 'export' only
  • Optionally: your privacy profile (the tolerances you set for data collection, sharing, retention, etc.)
What’s never included:
  • Notes flagged visibility = 'private' — unconditionally excluded by SQL filter at build time. There is no force-include URL parameter or escape hatch.
  • Your audience or goal selections — the recipient picks their own.
  • Your flag overrides, AI provider configuration, or AI keys.
  • Notification preferences, sync schedule, or any other personal settings.
Threat model. Bundles are unencrypted JSON. Treat them like a personal email: once you share the file, the recipient can open it indefinitely, and there is no revocation mechanism. We deliberately avoided shipping any centralised revocation list — that would contradict the local-first design — so the recommended sharing channels are ones you already trust for personal data: encrypted messaging (Signal, iMessage), end-to-end-encrypted email, or direct file transfer between devices on your local network. Avoid pasting the contents into public-facing tools (issue trackers, chat rooms, AI assistants). When a recipient imports a bundle, they see your annotations attributed to your name (the free-text field captured at export time, or “your friend” if blank). They can edit or delete their own copy of those notes after import; this does not affect the original on your device. The bundle’s version field guards against schema mismatches — newer bundles refuse to import into older app versions with a clear error rather than silently misparsing.
If you find a way to surface private (visibility = 'private') notes in an exported bundle, that’s a security issue and we want to hear about it through the reporting process below.

Runtime and database boundaries

privacytracker is designed as a single-user, local-first tool. Run only one runtime against a given SQLite database at a time: a Docker/Node process or the desktop app, not both sharing the same privacy.db. SQLite WAL mode handles ordinary contention inside one process, but two app processes writing the same database is outside the supported security and data-integrity model. If you want to run multiple instances (e.g., one per family member), use separate data/ directories per instance. The in-app device menu separates family members’ devices within one install, but it’s a filter, not an access boundary — use separate instances when their data must stay apart.

Supported versions

Security fixes are applied to the main branch only. There is no long-term-support branch. If you’re running privacytracker in production, track the latest Docker image (or the latest commit on main) so you receive fixes as soon as they ship.

Reporting a vulnerability

Please do not open a public GitHub issue, Discussion, or pull request for anything security-sensitive. Once a vulnerability is visible in a public thread it’s effectively disclosed to attackers too. Use one of the two private channels below.

GitHub Private Vulnerability Reporting

Preferred. Keeps the entire conversation inside the repo but hidden from the public until we publish an advisory. Threads directly into our triage workflow; when a fix ships, GitHub can request a CVE on your behalf.

Encrypted email (once the key is published)

The PGP key for security@privacykey.org isn’t published yet. Until .github/security-pgp.asc and its fingerprint land in the repo, please use GitHub Private Vulnerability Reporting. If you can’t use GitHub, open an advisory with minimal detail and ask us to coordinate another secure channel from there.Do not send sensitive vulnerability details by plaintext email.

What to include

The more of the following you can share up front, the faster we can reproduce the issue and ship a fix:
  • The affected version or commit (git rev-parse --short HEAD, or your Docker image tag / digest).
  • A clear description of the vulnerability and the impact if exploited (who gets what access, what data leaks, what can be modified).
  • Reliable reproduction steps — ideally a minimal proof-of-concept (curl command, repro script, or short written walkthrough).
  • Any logs, HTTP captures, or screenshots that show the issue.
  • Whether you’ve disclosed the issue anywhere else (and to whom).
  • How you’d like to be credited — name, handle, affiliation, or anonymous.

Our response

privacytracker is maintained by a small team, so we can’t commit to a strict SLA, but we aim to:
  • Acknowledge every report as soon as practical after receiving it.
  • Keep you updated as we triage, reproduce, and work on a fix — even if that update is “still investigating, no news yet”.
  • Coordinate the disclosure timeline with you. Ninety days is a reasonable default; we’re happy to move faster on clear-cut issues and ask for more time on ones that need a deeper refactor. Active exploitation in the wild changes the math — tell us if you see it.
  • Credit you in the fix commit, release notes, and the GitHub advisory, unless you ask to stay anonymous.
If you haven’t heard back from us and feel like you’re being ignored, escalate by pinging the same channel — replies sometimes end up in spam, and advisory notifications can get lost behind a busy inbox.

Safe harbor

We consider security research conducted under this policy to be authorised activity. If you act in good faith and follow the rules below, we will not:
  • Pursue or support any legal action against you;
  • Cooperate with law enforcement requests targeting you for the research;
  • Ask your hosting provider or network to take action against you.
To stay within safe harbor:
1

Report privately first

Report the vulnerability through one of the channels above before disclosing it publicly or to any third party.
2

Test only your own deployments

Test against deployments you own or have explicit permission to test. Don’t probe another user’s self-hosted privacytracker instance without consent.
3

Avoid collateral damage

Avoid privacy violations (don’t access data you don’t need to prove the issue), destruction of data, and disruption of service. If a proof-of-concept would require any of these, stop and describe the issue to us instead.
4

Give us a fix window

Give us a reasonable opportunity to fix the issue before you publish.
This policy follows the spirit of disclose.io. If another policy we agree to in writing conflicts with this one, the written agreement takes precedence.

Scope

privacytracker ships as source in the main repository and as a Docker image. In scope for security reports:
  • The Next.js web application (app/, lib/, components/).
  • The HTTP API surface (app/api/**).
  • The companion Python CLI (scripts/ios-app-import/).
  • The official Docker image and the reference docker-compose.yml.
  • The default configuration shipped in this repo — including the database schema in lib/db.ts, the scraping logic in lib/scraper.ts, and the AI-provider request flow in lib/ai-config.ts.
  • Exposure of locally-stored secrets (AI provider keys, session data) via the web UI or API.
Out of scope:
Vulnerabilities in Apple’s App Store, archive.org, the iTunes Search API, or your chosen AI provider (OpenAI, Anthropic, Ollama, etc.) — please report those to the relevant vendor.We’re still interested in cases where our handling of a third-party response is unsafe — e.g., a parser bug that lets a malicious response execute code on the host.
Social engineering of maintainers or contributors, physical attacks against a user’s own machine, and volumetric DoS or brute-force against a user’s own local deployment are out of scope.
Issues that require the user to install a malicious browser extension, run arbitrary untrusted code on their own machine, or intentionally misconfigure their own deployment.
Missing HSTS header on localhost, verbose error messages in dev mode, outdated non-exploitable dependencies flagged by a scanner, etc. You’re welcome to open a normal GitHub issue for these.
If you’re unsure whether something is in scope, report it anyway — we’d rather hear about it and tell you it’s out of scope than miss something real.

Known transitive advisories with no shipping impact

Some Dependabot advisories surface against transitive Rust dependencies that sit deep in Tauri’s gtk stack on Linux. We don’t ship a Linux desktop build (the only Tauri targets compiled in CI are aarch64-apple-darwin and x86_64-apple-darwin), so the vulnerable code paths never compile into released binaries. We track these here so reviewers can confirm the no actionable fix status without re-deriving it each time Dependabot re-flags them.
Advisory: RUSTSEC-2025-* / GHSA-h22v-69rx-mc8w (severity: Moderate).Vulnerable code path: Linux gtk3 webview only. glib is a transitive dependency of wry → webkit2gtk-rs → gtk-rs. Our Cargo.toml does not depend on glib directly; the macOS / Windows builds compile a different webview backend (WKWebView / WebView2) that doesn’t link against glib.Why no in-tree fix: The glib 0.18.x line was abandoned at 0.18.5 with no 0.18.6 patch release. The fix exists in 0.20.10+ and 0.21.x, but wry still pins gtk = "^0.18", which transitively forces glib 0.18.x. Bumping requires wry (and through it tauri) to advance its gtk stack — outside our control.Mitigation in our build: None needed. The macOS bundles compiled by .github/workflows/macos-release.yml don’t link glib; the Linux Docker image builds the Next.js web app only, which is a pure JavaScript runtime with no Rust components.
Advisory: RUSTSEC-2025-* / GHSA-* (severity: Low).Vulnerable code path: Triggered only when application code calls rand::rng() while a user-supplied global logger is installed. We don’t depend on rand directly; it’s pulled in transitively by Tauri internals that don’t take application-level loggers.Why no in-tree fix: rand 0.9.4 is the latest published version (next semver-compatible release would be 0.9.5+, not yet on crates.io). Once a patched 0.9.5 ships, Dependabot will open a Cargo.lock-only PR; merging that PR is the fix.Mitigation in our build: We don’t install a custom log subscriber that uses rand::rng() from inside its log callback. tauri-plugin-log is the single owner of the log facade subscriber and uses its own filesystem appender — no rand involvement.
When the upstream patches above land, Dependabot’s auto-PRs to Cargo.lock are the canonical fix; merge them on the next maintenance cycle. Do not fork Cargo.lock to edit version strings by hand — the checksums are content-addressed and any drift will fail cargo verify.

Hall of fame

Thanks to the researchers who help improve privacytracker’s security. With each researcher’s permission, names are listed here alongside the report that earned them a place. No entries yet.