TrackerControl for iOS is a web service and analyser pipeline for finding trackers in iOS apps. It is inspired by TrackerControl for Android and related research on privacy analysis for mobile apps.
The project has two parts:
- A Node.js/Express website where people can search App Store apps and view tracker reports.
- An analyser worker that downloads free App Store apps, analyses them on an actual iPhone, and uploads the results back to the website.
The website also includes jurisdiction analysis, showing which companies and countries control detected tracking infrastructure.
- Search free iOS apps from the App Store.
- Queue apps for analysis by popularity and staleness.
- Detect embedded tracker signatures and declared tracking domains.
- Store current and historical analysis results.
- Show tracker, permission, and jurisdiction summaries.
- Reverse lookup: which apps contain a given tracker or a given company's trackers.
- An about page explaining how apps are analysed, and what a report does and does not tell you.
- Sitemap,
robots.txt, canonical links, and Open Graph/Twitter card metadata. - Run the analyser from macOS or a Raspberry Pi host.
Only free App Store apps are queued for analysis. The queue prioritises apps with more stored App Store reviews, then rechecks stale analyses over time.
analyser/ App download, install, scan, and upload scripts
lib/ Shared website helpers, including jurisdiction analysis
migrations/ SQL migrations
models/ PostgreSQL access layer
routes/ Express routes and analyser API endpoints
scripts/ Maintenance scripts
views/ Pug templates
public/ Browser assets
static/ Static image assets
| Path | Purpose |
|---|---|
/ |
Search and headline statistics |
/analysis/:appId |
Per-app tracker, permission, and jurisdiction report |
/statistics |
Aggregate jurisdiction statistics |
/trackers, /companies |
Directories of every tracker and company seen in an analysed app |
/tracker/:slug, /company/:slug |
Reverse lookup: the apps a tracker or company was found in |
/about |
How apps are analysed, what a report means, jurisdiction labels, project background and contact |
/sitemap.xml, /robots.txt |
Crawler metadata |
The reverse lookup pages are served from an inverted index built by
lib/reverseIndex.js and cached under CACHE_DIR alongside the aggregate site
data. It is rebuilt whenever the set of stored analyses changes, so no extra
work happens per request.
CACHE_DIR is a persistent volume in production, so cache entries outlive the
code that wrote them. An entry is only rebuilt when the set of stored analyses
changes, which cannot detect a change in how the cached data is derived.
After editing buildSiteData or buildReverseIndex, bump SCHEMA_VERSION in
lib/cache.js so the deploy discards entries built by the previous logic —
otherwise the old figures are served until the next analysis lands.
Website:
- Node.js
- npm
- PostgreSQL
DATABASE_URLpointing at an empty PostgreSQL database (the schema is created by runningnode scripts/migrate.js)
Analyser:
- An iPhone reachable over SSH
trackerscaninstalled on the iPhone- Matching
UPLOAD_PASSWORDon the website and analyser
Install dependencies:
npm installCreate a root .env file:
DATABASE_URL=postgres://user:password@host:5432/database
UPLOAD_PASSWORD=change-me
CURRENT_ANALYSIS_VERSION=4
BODY_LIMIT=25mb
PUBLIC_FORM_BODY_LIMIT=100kb
APP_STORE_CACHE_RETENTION_DAYS=90
PORT=3000BODY_LIMIT applies to authenticated analyser JSON and text uploads.
PUBLIC_FORM_BODY_LIMIT is the smaller limit for the public analysis request form.
APP_STORE_CACHE_RETENTION_DAYS controls how long cached App Store metadata is kept.
Set SITE_URL in production to the public origin, for example
SITE_URL=https://ios.trackercontrol.org. Canonical links, Open Graph URLs,
robots.txt, and sitemap.xml use it. Without it, those URLs are derived from
the request in development, which yields http:// links when TLS is terminated
by a proxy. It is required in production, so an untrusted Host header cannot
become a public canonical URL — the server refuses to start without it rather
than answering 500 on every route.
Rate limits are applied per IP over a five-minute window, and published pages
are budgeted separately from the App Store entry points because sitemap.xml
points crawlers at every app, tracker and company URL. RATE_LIMIT_BROWSE_MAX
(default 300) covers GET/HEAD of the published pages, which are served from
the cached site data. RATE_LIMIT_FORM_MAX (default 20) covers everything else,
including /search and /request/:appId — these are GETs so that Cloudflare
can challenge them, but each one reaches the App Store, so they are budgeted as
the form submissions they are rather than as page views. Authenticated analyser
traffic is exempt from both. robots.txt disallows both paths as well, so a
crawler neither spends App Store calls nor collects challenge interstitials.
Everything that costs an App Store call is protected by Cloudflare WAF rules rather than by a widget in the page. The app itself carries no challenge code, so nothing can shift the layout and a visitor verifies at most once per Challenge Passage window (30 minutes by default).
Both protected steps are GETs, because a Cloudflare challenge renders an interstitial and replays the original request — which it cannot do for a POST body:
- Search is
GET /search?search=…. - Requesting an analysis starts at
GET /request/:appId, the confirmation page for an app that is not in the database yet. It lives on its own path so a rule can cover it without challenging every published report under/analysis/:appId. Passing it clears the visitor for thePOST /analysis/:appIdthat the page submits.
Because the protection lives at the edge, it only holds while the origin is reachable through Cloudflare alone. The deployment relies on that: Railway serves the site only via the proxied domain, and the analyser reaches it the same way.
If a direct origin URL is ever exposed, set CLOUDFLARE_ORIGIN_SECRET and have a
Cloudflare Transform Rule add a matching X-Origin-Verify header to every
request for the zone — lib/originGate.js then rejects anything without it,
exempting the /healthz probes Railway sends to the container. Unset, the check
is inert.
Cloudflare dashboard setup — all on the free plan, on the zone trackercontrol.org. The account-level WAF page is an Enterprise add-on and is not what you want:
- Security → WAF → Custom rules, two rules with action Managed Challenge:
(http.request.uri.path eq "/search")(starts_with(http.request.uri.path, "/request/"))Optionally a third as a backstop:(http.request.method eq "POST" and starts_with(http.request.uri.path, "/analysis/"))
- Security → Settings → Challenge Passage sets how long one solved challenge lasts.
Run migrations:
npm run migrateStart the website:
npm run watchFor production:
npm run startOpen http://localhost:3000 if PORT=3000 is set.
Copy the example config:
cp analyser/.env.example analyser/.envSet at least:
SERVER=https://your-server.example
UPLOAD_PASSWORD=change-me
ANALYSIS_VERSION=4
ANALYSIS_MODE=trackerscan
TRACKERSCAN_CMD="ssh iphone trackerscan"
TRACKERSCAN_SIGNATURES=/var/mobile/ios_signatures_v2.json
TRACKERSCAN_SIGNATURE_SET=ios-v2Log in to ipatool once on the analyser host:
ipatool auth loginRun the queue processor:
bash analyser/processQueue.shTo analyse one app immediately:
ONLY_APP_ID=com.spotify.client bash analyser/processQueue.shThe default analysis path uses trackerscan and uploads analysis version 4. The legacy Frida flow is still available with:
ANALYSIS_MODE=frida bash analyser/processQueue.shThe analyser has conservative download and retry limits by default. Tune these in analyser/.env only if the host, network, and storage can handle the extra load.
The analyser can run on Linux/arm64. A Raspberry Pi needs node, python3, unzip, zip, curl, openssh-client, libimobiledevice tools, ideviceinstaller, and a Linux arm64 ipatool.
Use the installer:
sudo bash scripts/setup-raspi-analyser.shSee raspberry-pi-analyser.md for the full setup, including systemd, SSH aliases, ipatool, and RAM-backed IPA storage.
The website exposes analyser endpoints:
GET /queuereturns the next app to process in the response body and its one-use assignment token in theX-Analysis-Claim-Tokenresponse header.GET /pingmarks the analyser online.POST /uploadAnalysisstores successful analysis results when sent with the assignment'sX-Analysis-Claim-Tokenheader.POST /reportAnalysisFailurestores failed analysis results when sent with the assignment'sX-Analysis-Claim-Tokenheader.
Analyzer requests authenticate with Authorization: Bearer $UPLOAD_PASSWORD.
Authentication and assignment tokens serve different purposes: the bearer token authorises the analyser, while the assignment token prevents an expired worker from overwriting a newer result.
The claim-token migration deliberately requeues every in-flight assignment that predates tokens. Use a coordinated deployment:
- Stop all analyser queue processors.
- Deploy the website and run
npm run migrate(or start it withnpm run start). - Deploy the matching
analyser/processQueue.shto every analyser host. - Restart the analysers.
The /queue response body remains a plain bundle ID, but older analyser scripts do not return the required claim header. Their completion requests will be rejected and must not remain running after the migration. Any interrupted work is safely available to the updated analysers because the migration requeues it.
Health checks:
GET /healthzreturns200when the website can reach PostgreSQL.GET /healthz/analyserreturns200when the analyser has pinged in the last hour.
Useful maintenance commands:
npm test
npm run queue-status
npm run priority-report -- --limit=20
npm run backup-db
npm run migrateReset one app for a fresh analysis:
npm run reset-app -- --appid=com.google.ios.youtube --applyWithout --apply, the reset command runs as a dry run.
Set CURRENT_ANALYSIS_VERSION=4 on the website when version 4 results should be treated as current.
The queue will reprocess apps when:
- They have never been analysed.
- Their analysis version is stale.
- Their analysis is older than
STALE_ANALYSIS_DAYS. - A previous processing marker has expired after
PROCESSING_TIMEOUT_MINUTES.
The default stale window is 180 days. The default processing timeout is 120 minutes.
exodusTrackers.json holds the Exodus Privacy tracker catalogue that report pages use to link a detected tracker to its Exodus entry and show its category badges.
It is display data only: detection comes from the analyser signatures in analyser/data/, so a stale file costs a link and some badges, never a missed tracker.
Refresh it with:
node scripts/update-exodus-trackers.js # fetch and rewrite the file
node scripts/update-exodus-trackers.js --check # validate only, never writeThe script keeps only the fields the views read, sorts keys and categories, and rewrites the file only when the content actually changed — Exodus returns categories in an unstable order, so writing the raw response would bury real changes under dozens of spurious ones.
The Update Exodus trackers workflow runs it monthly and opens a PR when something changed, so the data is never fetched during a build or at boot where an Exodus outage could take a deployment down with it.
This project is licensed under AGPLv3.