Privacy and consent
What the tree already does for GDPR, CCPA, App Tracking Transparency, the Apple privacy manifest and Play Data safety - and what is still yours.
Hey - by the end of this page you'll know which privacy work the tree does for you, where each piece lives, and what you still have to do before you ship to the EU, the UK or California. None of this is legal advice; it is the mechanism, so the lawyer's hour is spent on your words, not on plumbing.
What ships
| Requirement | What the tree does | Where |
|---|---|---|
| Consent before analytics / crash reporting (GDPR, ePrivacy, UK GDPR, PIPEDA, LGPD) | Setup selects consent/consent whenever an analytics or crash module is in. It asks once, after onboarding, when a device locale region or a Europe/* time zone points to the EU/EEA, UK, Switzerland, Canada or Brazil, when the device has no region, or everywhere with privacy.askEverywhere. Undecided reads as off there and on elsewhere. PostHog, Amplitude and Sentry create no client until their category is consented to: no event, no feature-flag or remote-config request, no JS or native crash report, no session. | src/lib/consent.ts, src/components/consent-sheet.tsx, consent |
| Proof of consent | The stored record is { analytics, crash, doNotSell, version, decidedAt, updatedAt, source }. Raising CONSENT_VERSION asks everyone in the ask group again. | src/stores/consent.ts |
| Withdraw consent any time | Settings → Privacy has one switch per category the build has; turning one off stops that SDK right away (PostHog optOut(), Amplitude setOptOut(true), Sentry.close()). No analytics or crash module, no switches. | src/screens/settings/settings-screen.tsx, src/lib/privacy-categories.ts |
| "Do not sell or share" (CCPA/CPRA) | A persisted switch in Settings → Privacy whenever an analytics module is selected; while it is on, analytics reads as off under either consent option. California is not detectable from the device, so it gets this opt-out rather than the sheet. | same |
| Access / portability (GDPR Art. 15, 20) | Settings → Privacy → "Export my data" writes my-data-<date>.json (expo-file-system) and opens the share sheet (expo-sharing): everything the storage adapter holds, minus credentials (keys such as Supabase's sb-*-auth-token are skipped, token and password fields redacted), plus the signed-in user's profile. Web gets the JSON as text. | src/lib/privacy.ts |
| Erasure (GDPR Art. 17, App Store Review 5.1.1(v), Play Account deletion) | On Pro with an auth module, Settings → Account → "Delete account": with backend=api-routes it first asks POST /api/privacy/delete to erase the user at PostHog, Amplitude and RevenueCat (whichever have server keys), then calls auth.deleteAccount() - Supabase also deletes the user's Storage files, Better Auth asks for the password when the session is too old. | src/lib/account-data.ts, src/app/api/privacy/delete+api.ts (only with backend=api-routes), auth |
| Local data on sign-out | Every sign-out (button, account deletion, expired session) wipes local data except theme, language, consent and onboarding, resets the analytics identity (new anonymous and device id; the opt-out survives) and clears the query cache. | src/hooks/use-sign-out-cleanup.ts, src/lib/privacy.ts |
| Data minimisation | Sentry: sendDefaultPii: false; beforeSend and beforeBreadcrumb drop IP, email, username, headers, cookies and query strings and mask emails and tokens in messages, exception values, extra and breadcrumbs. Amplitude: no IP, ad id or carrier. Adapty: no IDFA, GAID or IP unless features.tracking. PostHog: no touch/screen autocapture. | the analytics / crash / payments modules |
| App Tracking Transparency (App Store Review 5.1.2(i)) | Off by default (features.tracking: false): first-party analytics is not tracking. app.config.ts then drops the ATT plugin, blocks Android's AD_ID permission and writes NSPrivacyTracking: false. Flip it when you link data across companies: the plugin comes back, the manifest says true, the prompt runs once the app is active and analytics.identify waits for it. | readynative.config.ts, app.config.ts, src/lib/tracking.ts |
| Apple privacy manifest (ITMS-91053) | Every module declares its collected data types and required-reason APIs; setup composes them into ios.privacyManifests, and app.config.ts sets NSPrivacyTracking from features.tracking. Analytics declares user id, device id, product interaction and coarse location, all linked. | .readynative.json → modules.app.expo.ios.privacyManifests |
| Play Data safety form | bun run doctor --store prints a draft row per selected SDK, from the modules' declarations. | .readynative.json → modules.privacy.dataSafety |
| Privacy policy | bun run gen:privacy writes docs/privacy-policy.md from the selection and the same declarations: what is collected and why, legal basis, processors, transfers, retention, and only the Settings controls this build actually has. Anything it cannot know is a [CONFIRM: …] placeholder. | scripts/gen-privacy.ts |
bun run doctor --store ties it together: it warns when the manifest is missing, when the ATT
flag and the manifest disagree, when a privacy URL is empty, and when account deletion is gone.
What is still yours
- Host the privacy policy. Run
bun run gen:privacy --owner "Your Company", read it, fill in every[…]placeholder (retention periods, transfer mechanism, what your server keeps), have a lawyer look at it, publish it, and put the URL inreadynative.config.ts→urls.privacy. Re-run after you add or remove a module. - Answer the store questionnaires from the same facts: App Store Connect → App Privacy (the
manifest's data types are the answer), Play Console → Data safety (the
doctor --storedraft). Say "No" to tracking unlessfeatures.trackingis on. - Vendor deletion keys (
backend=api-routes). Set the server keys the delete route uses:POSTHOG_PERSONAL_API_KEY+POSTHOG_PROJECT_ID(+POSTHOG_API_HOSTfor EU),AMPLITUDE_API_KEY+AMPLITUDE_SECRET_KEY(+AMPLITUDE_REGION=eu),REVENUECAT_SECRET_KEY. Analytics vendors can only find the user when you callanalytics.identify(session.user.id). With Clerk, add auser.deletedwebhook pointing at/api/webhooks/clerkand setCLERK_WEBHOOK_SIGNING_SECRET. Without API routes, vendor data is deleted on request - say so in the policy. - Your own tables.
deleteAccount()removes the user at Supabase / Clerk / Better Auth; rows in your tables needon delete cascade(the Supabase migration shows how) or a hook. Apply both Supabase migrations (supabase db push) - deletion refuses to run without them and says which one is missing. "Export my data" covers the device; add an endpoint that returns the user's rows if you store anything server-side. - Sentry dashboard. Turn on Project Settings → Security & Privacy → "Prevent Storing of IP Addresses": the SDK sends no IP, but the connection itself reveals it.
- Sign the DPAs of the processors you selected (they are linked from each module's docs
page and from
doctor --store). - Write the consent copy in your voice.
src/components/consent-sheet.tsxis yours; keep "Only necessary" as prominent as "Accept all" - that is what regulators check - and raiseCONSENT_VERSIONwhen you change what you ask for. - Age. The template is not directed at children. If yours is, COPPA / Art. 8 need a parental flow the tree does not ship.
How consent flows
consentStore (src/stores/consent.ts, key readynative:consent, loaded when @/lib/consent is imported)
│ { analytics, crash, doNotSell, version, decidedAt, updatedAt, source }
▼
consent.get() ─ resolves undecided by region, doNotSell turns analytics off ─► { analytics, crash }
│ ▲
├─► PostHog no client until yes → new PostHog + optIn / optOut │ Settings → Privacy switches
├─► Amplitude no init until yes → init / setOptOut │ "Do not sell or share"
└─► Sentry no init until yes → Sentry.init / Sentry.close, beforeSend gate │ ConsentSheet (by region, per CONSENT_VERSION)The SDK modules subscribe to the store at import time, before the first screen renders, so the
first analytics.screen() already sees the stored answer. With consent/none, undecided reads as
consented, "Do not sell or share" still turns analytics off, and the store and the Settings card
stay, so switching later changes nothing outside the module.
Gotchas
- Region comes from the device (locales and time zone), not IP. A user in Paris with a US region
and a US time zone sees no sheet; the Settings switches are always there, and
privacy.askEverywhere: trueasks everyone. - With AsyncStorage the consent store loads asynchronously; anything tracked before it has loaded is dropped rather than sent without a known answer.
app.config.tsdeep-merges arrays by replacement: a hand-writtenios.privacyManifestsin your base config is overwritten by the composed one. Add declarations to a module'sprivacyinstead.- The ATT purpose string stays in
Info.plisteven withfeatures.tracking: false: the library is still linked, and App Store Connect flags a linked ATT framework without one. Nothing shows it. - Keep
crash.capture(err, ctx)contexts free of personal data; the scrubber masks emails and tokens it recognises, not everything personal.