Part 8 · 2 chapters · ~15 min
Privacy
Consent as application state with a lifecycle: the default before a decision, per-purpose choices, gates on every script, persistence the server can read, one-action withdrawal and a CI test that proves nothing leaks; then minimisation across collect, send, store and keep, rights built as product features, and how the main regimes translate into frontend work.
17
Consent as a state machine
a default, a decision, gates, persistence, withdrawal, a test
- Before deciding, only strictly necessary storage and requests; non-essential scripts do not load at all.
- The decision per purpose, reject as prominent as accept, recorded with time and banner version; GPC honoured where it is an opt-out.
- Gates: every non-essential script loads only through the consent manager; CSP reports the ungated.
- Persist and propagate in a first-party cookie the server reads, in the account, and to vendors.
- Withdrawal in one action, effective immediately; a new purpose re-asks for that purpose.
- Test it in CI: no consent, reject all and GPC must produce zero non-essential requests and storage.
code
// consent.ts: the state and the gate
type Purpose = 'analytics' | 'personalisation' | 'advertising';
type Consent = { v: number; at: string; granted: Purpose[] };
const BANNER_VERSION = 3;
const loaders: Record<Purpose, (() => void)[]> = { analytics: [], personalisation: [], advertising: [] };
export const whenGranted = (p: Purpose, load: () => void) =>
current()?.granted.includes(p) ? load() : loaders[p].push(load);
export function current(): Consent | null {
const c = readCookie('consent');
if (!c || c.v < BANNER_VERSION) return null; // unknown: necessary only
if ((navigator as any).globalPrivacyControl)
c.granted = c.granted.filter(p => p !== 'advertising'); // GPC as opt-out of sale/share
return c;
}
export function decide(granted: Purpose[]) {
const c: Consent = { v: BANNER_VERSION, at: new Date().toISOString(), granted };
writeCookie('consent', c, { maxAge: 180 * 86400, sameSite: 'Lax', secure: true });
fetch('/api/consent', { method: 'POST', body: JSON.stringify(c), keepalive: true });
granted.forEach(p => loaders[p].splice(0).forEach(f => f()));
}
// usage
whenGranted('analytics', () => import('./analytics').then(a => a.init()));code
// consent.spec.ts: the CI check (Playwright)
test('no consent: nothing non-essential leaves', async ({ page }) => {
const leaked: string[] = [];
page.on('request', r => { if (!ALLOWED_HOSTS.has(new URL(r.url()).host)) leaked.push(r.url()); });
await page.goto('/checkout');
await page.waitForLoadState('networkidle');
expect(leaked).toEqual([]);
expect((await page.context().cookies()).map(c => c.name)).toEqual(expect.arrayContaining(NECESSARY));
});CONSENT AS A STATE MACHINE
what may run before the user decides, how the decision gates every script, and how it is honoured on the next visit and the next server call
swipe the figure sideways, or tap expand for full screen
1/6
before deciding
Before the decision: only strictly necessary storage and requests run (the session cookie, the CSRF token, the load balancer's affinity, the user's own preferences). Analytics, advertising pixels, session replay and A/B tools that set identifiers do not load at all: a script that loads and "waits" has already sent a request with the IP and the referrer. Server-side, the same default: no third-party forwarding.
18
Minimisation, rights and the regimes
the cheapest compliance is data you never had
- Collect less: a nameable purpose per field; the derivative instead of the source.
- Send less: route templates, pseudonymous ids, enumerated properties; an allowlist in the SDK and at ingest.
- Store less: no personal data in localStorage; a logout path that clears caches and sends Clear-Site-Data.
- Keep less: retention per data class enforced by TTL.
- Rights as features: access, export, edit, erase (propagated), object, with an SLA.
- Accountability: a privacy section in the design doc, a data map row per field, a DPIA when high-risk.
| regime | where | tracking default | rights SLA | frontend consequence |
|---|---|---|---|---|
| GDPR + ePrivacy | EU | opt-in | 1 month | banner with equal reject; no tags before consent |
| UK GDPR + PECR | UK | opt-in | 1 month | same; ICO has acted on buried reject buttons |
| NDPA 2023 | Nigeria | consent or another lawful basis | 1 month | consent records, a DPO above thresholds, NDPC audits |
| LGPD | Brazil | lawful basis (consent common) | 15 days | similar to GDPR; faster access SLA |
| CCPA / CPRA | California | opt-out of sale/share | 45 days | "Do not sell or share" link; honour GPC |
not legal advice
Regimes change and thresholds matter; your counsel and DPO decide the policy. What engineering owns is making the policy executable: gated scripts, enforced allowlists, TTLs, deletion that propagates, and tests that prove it.
code
// logout: leave nothing on a shared device
await fetch('/api/logout', { method: 'POST' });
// server responds with: Clear-Site-Data: "cache", "cookies", "storage"
indexedDB.databases?.().then(dbs => dbs.forEach(d => d.name && indexedDB.deleteDatabase(d.name)));
for (const k of await caches.keys()) await caches.delete(k);the exercise
Sign in to your product on a fresh profile, use it, log out, then open Application in DevTools. Everything still there with a name, email, order or token in it is a finding.
MINIMISATION BY DESIGN
the data you never collect cannot leak, be subpoenaed, or need a deletion pipeline
swipe the figure sideways, or tap expand for full screen
1/6
collect less
Collect less: every field in a form needs a purpose that the product can name (date of birth for age verification, or only "over 18: yes"?; full address, or the postcode for delivery zones?). Optional fields are labelled optional and do not block. Derived data (age from date of birth, region from IP) is computed and the source discarded when only the derivative is needed.