ADR-0020: A client sends nothing the privacy policy does not name
- Status: Accepted
- Date: 2026-09-11
- Scope: core (every client: web SPAs and mobile)
Context
A privacy policy lists what is collected and who receives it. Some of those flows never appear in
server code, because the client causes them on the user’s behalf. They are the easy ones to miss:
- A third-party asset is a disclosure. When a web font loads from Google Fonts, every visitor’s
browser sends its IP address, user agent and referrer to Google on every page load. That makes
Google a recipient the policy would have to name, and nothing in the application code shows it.
It is one CSS@import. - A photo carries more than its pixels. A phone camera writes EXIF into every photo, which
usually includes the GPS position where it was taken, the device model and the exact time. The
EXIF survives when the photo is uploaded from a browser as well as from the app. Uploading the
original file hands all of it to the server, and to any model the image is forwarded to. - A native permission can arrive without anyone asking for it. On Android, a library’s manifest
merge can add permissions the app never uses (storage, media, microphone). They appear on the
store listing, and may have to be disclosed.
Expenses found all three while writing its first Privacy Policy. Both SPAs loaded their fonts from
Google. The web and mobile assistants both uploaded images, whether photographed or picked, with
their EXIF intact. The Android manifest carried storage, media and audio permissions that nothing
used.
Decision
A client makes no request, and sends no data, that the privacy policy does not name. When a
client flow would need a new disclosure, first ask whether the flow is needed. Removing a flow is
usually cheaper than disclosing it.
-
Serve static assets from the app’s own origin. Bundle fonts, icons, stylesheets and scripts
(for fonts, import@fontsource/@fontsource-variablepackages in the entry file). Never
hot-link them from a third-party CDN. The browser may contact a third-party origin directly only
when the policy names it and a feature the user triggers needs it, for example a postcode
lookup. Design-system reference files often ship with a Google Fonts@import; do not port that
line into the app. -
Strip image metadata on the device before upload, and fail closed. Every path that uploads
an image re-encodes it first, on every client. Decoding to pixels and encoding again is the
reliable way to drop every metadata block. Two cases refuse the image and never send it as it
is:- the re-encode fails;
- the image is a type the client cannot re-encode, such as a GIF or a TIFF.
Never fall back to the original file, since that is the one carrying the location. Files that
are not images (a PDF, a CSV) pass through unchanged. Apply size limits to the re-encoded file,
because that is what gets sent. -
Block the permissions the app does not use. Declare unused sensitive permissions as blocked
in the app config, so a dependency cannot add them silently, and turn off library options that
request permissions for features the app does not have.
Consequences
- The policy’s lists of recipients and data now describe the clients as well as the server. Every
new client dependency gets the same check: does it contact a new origin, or send something new? - Self-hosted fonts add a few hundred kilobytes to the bundle and are cached with it. No
third-party request happens on first paint. - Re-encoding costs some image quality and some time on the device. Doing it once, at the upload’s
own quality setting, keeps it from being a second lossy step. - If an image fails to re-encode, or is of a type the client cannot re-encode, the user cannot
attach it. That is accepted: sending it anyway would break the promise. - Nothing checks any of this mechanically. A new
@import, upload path or native module can
reintroduce a leak without failing anything. Review, includinghandbook:ask-legal, is the
control.
Related
- 0013-centralized-api-layer-and-server-state — first-party requests go through one API layer;
this ADR covers the requests that do not - How Expenses applies it:
@fontsource-variablein both SPAs, asrc/utils/stripImageMetadata.ts
in bothexpenses-webandexpenses-app, andandroid.blockedPermissionsin the mobile
app.json. - Review:
handbook:ask-legal