Skip to content

A launch checklist for developers

One line of code got a German website owner sued.It was a Google Fonts link.

AI writes your code now. It does not read the GDPR. It writes what worked in the training data, and much of that was written before anyone cared — or somewhere the rules are different.

These are the eight things that come up again and again in AI-generated apps. For each: why it matters, how to check it in about two minutes, and how to fix it. Most are a config change. The expensive part is not knowing.

8 checks · about 20 minutes · no sign-up, nothing to install

This page, measured

0
third-party requestsnothing loads from another origin
0
cookiesand so no banner to dismiss
0
bytes stored on your deviceno localStorage, no sessionStorage
4
fonts, all from this domaincommitted to the repo, never fetched

Not a claim — a build step. Every deploy runs privacy-check, which fails if this page would contact anyone but itself.

01

Eight things to look at before you ship

Each card expands. Nothing here needs a lawyer — it needs ten minutes and DevTools. Answer as you go and the audit below fills itself in.

Self-host your fonts

A stylesheet link to Google sends every visitor’s IP address to a third country before they have clicked anything.

Why it matters · the 2-minute check · the fix

Why it matters

When a page links to fonts.googleapis.com, the visitor’s browser has to connect to Google to render the page. That request carries their IP address and user agent, and it happens automatically — before they have read a word, consented to anything, or had any opportunity to object.

An IP address is personal data when the operator has means reasonably likely to be used to identify the person behind it. That is settled: the CJEU said so in Breyer (C-582/14), and Recital 30 treats online identifiers the same way.

On 20 January 2022 the Landgericht München I awarded a visitor €100 in damages, plus an injunction, because a website embedded Google Fonts dynamically (Az. 3 O 17493/20). The court specifically rejected legitimate interest under Art. 6(1)(f): the fonts could have been hosted locally, so passing the IP address to Google was not necessary. It is a first-instance judgment and not binding precedent, but the reasoning is hard to argue with, and it triggered a large wave of warning letters in Germany.

The same logic applies to anything else the page loads by itself: icon fonts, CSS frameworks from a CDN, avatar services, embedded maps, YouTube iframes, hosted chat widgets. The font is just the example everyone got sued over.

The 2-minute check

  1. Open your site in a private window with DevTools on the Network tab, and reload with the cache disabled.
  2. Sort by Domain. Every row that is not your own domain is a third party your visitor contacted without being asked.
  3. Or search your built output directly. For a static build: grep -rhoE 'https?://[^"\'()<> ]+' out/ | sort -u | grep -v your-domain.test — every line left is a third party. (Avoid a negative lookahead here: grep -E is POSIX and does not support (?!...).)

The fix

Serve the font from your own origin. In Next.js, next/font downloads the file at build time and emits it under /_next/static/media, so the browser never talks to Google. next/font/local goes one step further: the file lives in your repository, so not even your build machine makes the request.

Then re-run the network check. The fix is only real when the request is gone.

The problem
// Every visitor's browser connects to Google to fetch this.
// Their IP address goes with it, automatically, before any consent.
<link
  rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Inter&display=swap"
/>
app/layout.tsx — the fix
import localFont from 'next/font/local'

// The .woff2 file is committed to the repo. Next emits it under
// /_next/static/media and rewrites the @font-face rule to point there.
// No request to Google at runtime — or at build time.
const inter = localFont({
  src: './fonts/Inter-Variable.woff2',
  variable: '--font-sans',
  display: 'swap',
})

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en" className={inter.variable}>
      <body>{children}</body>
    </html>
  )
}

next/font/google works too and also self-hosts the result — the visitor never reaches Google either way. The difference is that your build machine still downloads from Google, and the build needs network access. This site uses the local variant.

Is this already true of your app?

Self-host your fonts — does this apply to your app?

Lock down your database

Your Supabase or Firebase key is in the browser bundle by design. Row Level Security is the only thing standing between a stranger and every row.

Why it matters · the 2-minute check · the fix

Why it matters

Supabase and Firebase are built around a client that talks straight to the database. The anon or web API key ships in your JavaScript bundle, and that is not a mistake — it is not a secret and it was never meant to be one. Authorisation is supposed to happen in the database, through Row Level Security policies or security rules.

Turn those off, or never turn them on, and the key in your bundle becomes a public read token for your entire dataset. Anyone can open DevTools, copy it, and query the REST endpoint directly. No exploit required; you published the API.

Art. 32 GDPR requires technical and organisational measures appropriate to the risk, taking into account the state of the art and the nature of the data. An unauthenticated read over a table of user emails will not survive that test.

If it happens, it is a personal data breach: Art. 33 gives you 72 hours to notify the supervisory authority, and Art. 34 may require you to tell every affected person directly.

The two patterns to look for: Postgres tables created without `enable row level security`, and Firestore rules still in test mode (`allow read, write: if true`) — often made permanent when the 30-day timer started failing the build.

The 2-minute check

  1. Supabase: open the Table Editor. Any table showing an "RLS disabled" or "Unrestricted" warning is publicly readable through the API.
  2. Take the anon key out of your own frontend bundle and curl the REST endpoint for your most sensitive table. Whatever comes back is what a stranger gets: curl "https://<project>.supabase.co/rest/v1/profiles?select=*" -H "apikey: <anon key>"
  3. Firebase: Firestore → Rules. If you see `if true`, or a date comparison that has already passed, that is the finding. Use the Rules Playground to simulate an unauthenticated read.
  4. Remember that enabling RLS with no policies denies everything. A table that suddenly returns nothing is locked, not broken — write the policy.

The fix

Enable RLS on every table holding personal data, then add the narrowest policy that still lets your app work. Scope rows to the authenticated user, and write separate policies per operation rather than one blanket policy.

Keep the service role key on the server, always. It bypasses RLS entirely by design, so a single leak into a client bundle undoes all of this.

supabase/migrations/enable_rls.sql
-- Without this line the anon key in your bundle can read the table.
alter table public.profiles enable row level security;

-- RLS with no policies denies everything, so add them explicitly.
-- One policy per operation: read and write are different risks.

create policy "Users can read their own profile"
  on public.profiles
  for select
  to authenticated
  using ( (select auth.uid()) = user_id );

create policy "Users can update their own profile"
  on public.profiles
  for update
  to authenticated
  using      ( (select auth.uid()) = user_id )   -- which rows are visible
  with check ( (select auth.uid()) = user_id );  -- what the row may become

-- Verify as an anonymous caller. This must return zero rows:
--   set role anon;
--   select * from public.profiles;

Wrapping auth.uid() in a subselect lets Postgres evaluate it once per statement instead of once per row — the same policy, without the performance cliff on large tables.

Is this already true of your app?

Lock down your database — does this apply to your app?

Know where your AI prompts go

If the prompt contains a user’s name, email or support ticket, you are sending personal data to a processor — probably in another country.

Why it matters · the 2-minute check · the fix

Why it matters

The moment your app puts user content into a prompt, that provider becomes a processor acting on your instructions, and Art. 28 GDPR applies. Processing may only take place under a contract — the data processing agreement — that binds them on purpose, duration, security, sub-processors, deletion and audit. For most providers this is a document you have to actively accept, not something that exists because you have an account.

Five things you need to be able to answer, and most vibe-coded apps cannot answer any of them: which personal data actually ends up in the prompt; in which region it is processed; how long the provider keeps it; whether inputs are used to train models; and which sub-processors sit behind them.

Training and retention defaults differ between consumer and API tiers of the same product, and change over time. Whatever you read in a blog post is not evidence — read the terms for the tier you are actually on, and keep a dated copy.

If the provider processes outside the EEA, you also need a Chapter V transfer mechanism, and you must disclose the recipients and the transfer in your privacy notice under Art. 13(1)(e) and (f).

And if the text might contain health data, biometric data, trade union membership, religion, sexual orientation or political opinions — a support inbox routinely does — then Art. 9 applies and the bar is much higher than a legitimate interest.

The 2-minute check

  1. Find every place you build a prompt. Log one real, fully-assembled prompt in development and actually read it, including the system prompt and any retrieved context.
  2. Ask of each field: does this identify a person? Would I be comfortable if it appeared in a breach notification?
  3. Open your provider dashboard and write down the processing region, the retention period, the training setting for your tier, and the date you accepted the DPA.
  4. Check whether your privacy notice mentions this provider at all. If it does not, that is two findings, not one.

The fix

Decide what may leave your system before you build the prompt, not after. Pass a pseudonymous reference instead of an email address, and strip identifiers out of free text you pass through.

Pin the region where the provider offers one, switch off training and zero-day retention where the tier allows it, and record the DPA in your Art. 30 record of processing activities.

Pseudonymisation is a risk-reducing measure under Art. 32, not an exemption: pseudonymous data is still personal data. It lowers the blast radius; it does not remove the obligation.

lib/ai/summarise.ts
import { redactPII } from '@/lib/redact'

type Ticket = { id: string; authorEmail: string; body: string }

export async function summariseTicket(ticket: Ticket, user: { pseudonymId: string }) {
  // Decide what may leave the system BEFORE building the prompt.
  // The model does not need to know who wrote this to summarise it.
  const prompt = [
    'Summarise the following support ticket in two sentences.',
    '',
    `Reference: ${user.pseudonymId}`,      // not authorEmail
    `Ticket: ${redactPII(ticket.body)}`,   // emails, phones, IBANs removed
  ].join('\n')

  const response = await fetch('https://api.example-llm.com/v1/messages', {
    method: 'POST',
    headers: {
      authorization: `Bearer ${process.env.LLM_API_KEY}`,
      'content-type': 'application/json',
      // Where the provider offers a choice, pin it and document it in
      // your Art. 30 record. Do not rely on the account default.
      'x-processing-region': 'eu',
    },
    body: JSON.stringify({
      model: 'your-model',
      max_tokens: 300,
      messages: [{ role: 'user', content: prompt }],
      // Retention and training controls differ per provider and per tier.
      // TODO: confirm the flag names against your provider's current API
      // reference, and keep a dated copy of the terms you relied on.
    }),
  })

  return response.json()
}

The header and flag names above are illustrative. Every provider spells these differently, and some do not offer them at all — check the reference for the tier you are on rather than copying this verbatim.

Is this already true of your app?

Know where your AI prompts go — does this apply to your app?

Keep personal data out of logs

console.log(user) quietly copies personal data into a second system — one with different retention, different access and usually a different country.

Why it matters · the 2-minute check · the fix

Why it matters

Art. 5(1)(c) GDPR requires personal data to be adequate, relevant and limited to what is necessary. Logging a whole user object to debug one field fails that on its face — you copied ten attributes to inspect one.

A log line is not a harmless side effect. It is a copy of personal data in a different system, with its own retention, its own access control, its own region, and frequently its own vendor. Art. 5(1)(e) storage limitation applies to logs too, and "our log provider keeps everything for 30 days" is a retention decision whether you made it deliberately or not.

Logs are also unusually exposed. Everyone on the team can read them, they get piped into alerting and support tools, they end up in screenshots in issue trackers, and they are a routine source of breach reports.

The pattern to grep for is the one an AI writes when a login fails: console.error("Login failed", { email, password }). Credentials and tokens in plaintext, retained for a month, searchable by the whole team.

The 2-minute check

  1. grep -rn "console\.\(log\|error\|warn\)" src/ and read every hit that sits near authentication, payment, user or request handling.
  2. Look for the whole-object shapes: logging req.body, req.headers, the user record, or an error object that carries the request on it.
  3. Open your hosting or log provider’s viewer and read the last fifty lines of production logs as if you were an attacker who just got access.
  4. Check the retention setting while you are there, and whether you ever chose it.

The fix

Log identifiers, not people. A user ID and a request ID will debug almost everything a full user object would, and neither identifies anyone on its own if the log is leaked.

Where you do need structured context, put a redaction step between the object and the logger, so it is applied by default rather than remembered case by case.

lib/redact.ts
const SENSITIVE_KEYS = new Set([
  'password', 'passwd', 'secret', 'token', 'accesstoken', 'refreshtoken',
  'authorization', 'cookie', 'apikey', 'api_key', 'sessionid',
  'email', 'phone', 'telephone', 'address', 'street', 'postcode', 'zip',
  'firstname', 'lastname', 'fullname', 'name', 'dob', 'dateofbirth',
  'iban', 'bic', 'creditcard', 'cardnumber', 'cvv', 'ssn', 'taxid', 'ip',
])

const MAX_DEPTH = 6

/**
 * Returns a copy safe to log: sensitive keys are replaced, everything else
 * is preserved. Redact on the way in, so it happens by default rather than
 * when somebody remembers.
 */
export function redact(value: unknown, depth = 0): unknown {
  if (depth > MAX_DEPTH) return '[max depth]'
  if (value === null || typeof value !== 'object') return value
  if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1))

  return Object.fromEntries(
    Object.entries(value as Record<string, unknown>).map(([key, v]) => {
      if (SENSITIVE_KEYS.has(key.toLowerCase().replace(/[-_\s]/g, ''))) {
        return [key, '[redacted]']
      }
      return [key, redact(v, depth + 1)]
    }),
  )
}

/** Free text can carry identifiers the key name never revealed. */
export function redactPII(text: string): string {
  return text
    .replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, '[email]')
    .replace(/\+?\d[\d\s()/-]{7,}\d/g, '[phone]')
    .replace(/\b[A-Z]{2}\d{2}[A-Z0-9]{10,30}\b/g, '[iban]')
}

// Before: console.error('Login failed', { email, password })
// After:  console.error('Login failed', redact({ userId, requestId, reason }))

Treat the key list as a floor, not a definition of personal data. Add the field names your own schema uses, and remember that a free-text column can contain anything a user typed.

Is this already true of your app?

Keep personal data out of logs — does this apply to your app?

Build "delete my account" and "export my data"

Both are rights, not features. You have one month to answer, and a soft delete is not an erasure.

Why it matters · the 2-minute check · the fix

Why it matters

Art. 17 gives the data subject the right to erasure and Art. 20 the right to receive their data in a structured, commonly used, machine-readable format. Art. 12(3) sets the clock: without undue delay, and in any event within one month of the request, extendable by two further months for complex cases if you tell them why within the first month.

These are obligations you must be able to satisfy on request. A manual process is lawful; not being able to do it at all is not. The reason to build a button is that the manual version is where people quietly miss systems.

Setting deleted_at = now() is not erasure. The data is still there, still readable, still in your backups, still in the dump you gave the analytics contractor. If you keep it, keep it for a reason you can name.

Erasure is not absolute. Art. 17(3) carves out, among others, data you must keep to comply with a legal obligation — in Germany, invoices fall under retention periods in the HGB and AO. The correct answer is usually: delete everything except the records the law requires, restrict those from further processing, and be able to explain the split.

The part everyone forgets is scope. Personal data is rarely in one table. Sessions, audit logs, uploaded files, email provider, payment provider, support inbox, error tracker, analytics, backups — each is a separate deletion.

The 2-minute check

  1. Create a test account, use the product properly for five minutes, then ask for deletion through whatever route a real user would have.
  2. Go into the database and look for the row. Then look for it in every other table that references the user ID.
  3. Open each third-party dashboard in turn — payments, email, error tracking, support — and search for the test user. This is where it falls apart.
  4. Try the export. Is it machine-readable, or is it a PDF? Does it contain what the user gave you?
  5. Write down your backup retention and what happens to a deleted user sitting inside a backup.

The fix

Keep one function that enumerates every store holding personal data, and make adding a new store mean editing that function. A checklist in a wiki drifts; a function that fails to compile does not.

Be explicit about what survives erasure and why, and record the decision. "We keep invoices for the statutory retention period under Art. 17(3)(b)" is an answer; silently keeping the row is not.

lib/gdpr/subject-requests.ts
/**
 * Every store that holds personal data for a user.
 * Adding a new integration means adding it here — that is the point.
 */
const STORES = [
  'profiles', 'sessions', 'posts', 'comments',
  'uploads', 'notifications', 'audit_log',
] as const

/** Art. 20 — structured, commonly used, machine-readable. JSON qualifies. */
export async function exportUserData(userId: string) {
  const data: Record<string, unknown> = {}
  for (const store of STORES) {
    data[store] = await db.from(store).select('*').eq('user_id', userId)
  }

  return {
    exportedAt: new Date().toISOString(),
    // Name the systems you cannot dump automatically, so the person
    // knows they exist and can ask the processor directly.
    externalProcessors: ['payment provider', 'transactional email'],
    data,
  }
}

/** Art. 17 — erasure, with the Art. 17(3) exceptions made explicit. */
export async function eraseUser(userId: string) {
  // 1. Anything you must keep, you keep deliberately and restrict.
  //    Invoices are subject to statutory retention (§ 147 AO, § 257 HGB),
  //    so they are retained and flagged, not deleted.
  await db.from('invoices')
    .update({ processing_restricted: true, personal_data_removed_at: new Date() })
    .eq('user_id', userId)

  // 2. Everything else actually goes.
  for (const store of STORES) {
    await db.from(store).delete().eq('user_id', userId)
  }

  // 3. Stores you do not own need their own call. Silence here is the bug.
  await paymentProvider.deleteCustomer(userId)
  await emailProvider.deleteContact(userId)
  await objectStorage.deletePrefix(`users/${userId}/`)

  // 4. Backups age out rather than being edited. Write down the window,
  //    tell the user, and make sure a restore re-applies the deletion.
}

The statutory retention periods cited are the usual German ones for accounting records. Which of them apply to you depends on what you actually sell — worth confirming rather than copying.

Is this already true of your app?

Build "delete my account" and "export my data" — does this apply to your app?

Impressum and Datenschutzerklärung

A generated privacy policy that describes a stack you do not run is worse than none — it is a false statement about your processing. The Impressum has a threshold question most people skip.

Why it matters · the 2-minute check · the fix

Why it matters

§ 5 DDG (formerly § 5 TMG, renamed on 14 May 2024) requires an Impressum for business-like digital services. "Business-like" is broader than commercial: a free site refinanced by advertising counts, and so does a site that promotes your professional services. Name, a valid postal address, an email address and a second means of fast electronic contact are the core; register number, VAT ID and supervisory authority are added where they apply.

The postal address must be a ladungsfähige Anschrift — somewhere legal documents can actually be served on you. A Postfach is explicitly not enough. For a freelancer with no office that means publishing a home address, which is the real reason so many developer sites quietly skip the Impressum and hope. The lawful ways out are to rent an address that can accept service, or to stay genuinely outside § 5 in the first place.

That threshold question is worth taking seriously, because it is the one place where doing less work is also the safer answer. A page that sells nothing, prices nothing and solicits nothing is not a geschäftsmäßiger Dienst, and needs no Impressum at all. Add one "hire me" button and it very likely does. This site is the example: there is no Impressum here, deliberately, and keeping it that way constrains what the page is allowed to say.

It also has to be leicht erkennbar, unmittelbar erreichbar und ständig verfügbar — easily recognisable, directly accessible and permanently available. In practice: a link labelled "Impressum" in the footer of every page, reachable in no more than two clicks.

Art. 13 GDPR is the separate obligation: at the point you collect personal data you must tell the person who the controller is, what you do with their data, on what legal basis, who receives it, whether it goes to a third country, how long you keep it, what rights they have and that they may complain to a supervisory authority.

Here is the failure mode specific to AI-generated sites. Ask for a privacy policy and you get a plausible one — describing Google Analytics you do not use, a contact form you never built, and cookies you do not set, while saying nothing about the Supabase instance holding your users. Every sentence in that document is a statement about your processing, and the wrong ones are not neutral filler.

The document is also downstream of something else: Art. 30 requires a record of processing activities, and that is where the list of what you actually run belongs. Write that first and the privacy notice mostly falls out of it.

The 2-minute check

  1. First the threshold question: does your site offer, price, advertise or solicit anything at all — including your own freelance services? If yes, you need an Impressum with a servable postal address. If it is purely informational, you probably do not.
  2. If you do need one: check the link is in the footer of every page, including the 404 page, and that it says "Impressum".
  3. Check that it still cites DDG rather than TMG, and TDDDG rather than TTDSG — both were renamed on 14 May 2024. Check too that it does not link the EU ODR platform, which was switched off on 20 July 2025.
  4. Then the privacy notice, which you need either way the moment you process anything — server logs count. Read it line by line against your package.json and your list of third-party dashboards.
  5. For every service named in the document, confirm you use it. For every service you use, confirm it is named.

The fix

Keep a machine-readable list of processors in the repository, next to the code, and generate or review the privacy notice from it. When you add a dependency that phones home, the list is the thing you update, and the review becomes a diff rather than a rewrite.

This is also your Art. 30 record in embryo, and the input to a transfer assessment under Chapter V.

Get the final legal text reviewed. A developer can guarantee the document matches the system; only a lawyer can tell you the document is sufficient.

legal/processors.ts
/**
 * Every third party that can see personal data.
 * Reviewed on each dependency change — a PR that adds a service and not a
 * row here should not pass review.
 *
 * Doubles as the input to the Art. 30 record of processing activities
 * and to the Chapter V transfer assessment.
 */
export const processors = [
  {
    service: 'Firebase Hosting',
    company: 'Google Ireland Limited / Google LLC',
    purpose: 'Serving static files; server logs',
    personalData: ['IP address', 'user agent', 'requested URL'],
    region: 'Global CDN — may include the USA',
    legalBasis: 'Art. 6(1)(f) GDPR',
    transferMechanism: 'EU-US Data Privacy Framework',
    dpa: 'Google Cloud Data Processing Addendum',
    retention: 'Per Google Cloud logging defaults',
  },
  {
    service: 'Cloudflare DNS',
    company: 'Cloudflare, Inc.',
    purpose: 'Authoritative DNS only — proxy disabled',
    personalData: ['resolver IP (not visitor IP)'],
    region: 'Global',
    legalBasis: 'Art. 6(1)(f) GDPR',
    transferMechanism: 'EU-US Data Privacy Framework',
    dpa: 'Cloudflare DPA',
    retention: 'Per Cloudflare DNS logging policy',
  },
] as const

Those two rows are the real, complete processor list for the site you are reading. If your list is longer than your privacy notice suggests, you have found something.

Is this already true of your app?

Impressum and Datenschutzerklärung — does this apply to your app?

Check your US services

Most of your stack is American. That is workable, but it is a decision you have to make on purpose and be able to defend.

Why it matters · the 2-minute check · the fix

Why it matters

Chapter V GDPR (Art. 44–49) governs transfers of personal data outside the EEA. A transfer is not only "we shipped the database abroad" — it includes remote access. If a US parent can reach data stored in an EU region, that is a transfer.

Since 10 July 2023 the Commission’s adequacy decision for the EU-US Data Privacy Framework allows transfers to US organisations that are certified under it, without further safeguards. Two things matter and are routinely missed: certification is per organisation, so check the recipient on the official DPF list rather than assuming; and it covers only the scope that organisation certified.

For recipients that are not certified, you are back to standard contractual clauses under Art. 46 plus a transfer impact assessment — an actual assessment of whether the law where the recipient sits undermines the clauses.

The framework is under challenge. The General Court dismissed the first action on 3 September 2025 (Latombe v Commission, T-553/23) and it was appealed to the Court of Justice (C-703/25 P), which was still pending as of mid-2026. The framework is valid law today. The practical response is not to panic but to know which of your services depend on it, so you can act quickly if that changes.

Choosing an EU region is still the cheapest risk reduction available, and it is usually a dropdown you did not look at when you created the project.

The 2-minute check

  1. List every service that touches personal data: hosting, database, auth, email, payments, error tracking, analytics, LLM APIs, CI, object storage.
  2. For each one write down three things: where it processes, whether a DPA is in place, and what the transfer mechanism is if it is outside the EEA.
  3. For anything relying on the DPF, look the company up on the official framework list rather than taking the marketing page at its word.
  4. Check the region on projects you created quickly. Defaults are usually us-east-1, and the region is normally fixed at creation time.

The fix

Pick EU regions wherever the provider offers them, and pin them in configuration rather than leaving them to a console default, so the next environment you create inherits the decision.

Where you cannot avoid a US service — and often you cannot, realistically — document the mechanism, accept the DPA, and describe it honestly in your privacy notice. An honest description of a US transfer is lawful. A privacy notice claiming everything stays in Germany when it does not is not.

infra/regions.ts
/**
 * Regions are pinned in code, not chosen in a console. The default for
 * most providers is us-east-1, and for most of them the region cannot be
 * changed after the project is created.
 */
export const REGIONS = {
  // Firestore location — set at project creation and immutable afterwards.
  firestore: 'eur3',              // not 'nam5'
  // Cloud Functions / Cloud Run.
  functions: 'europe-west3',      // Frankfurt
  // Object storage.
  storage: 'europe-west3',
} as const

// Supabase: the region is chosen when the project is created and cannot
// be changed later. Frankfurt is eu-central-1.

// A note worth writing down somewhere permanent: an EU region reduces the
// transfer, it does not always eliminate it. If the provider's US entity
// can access the data for support or operations, that access is itself a
// transfer under Chapter V and needs its own mechanism.

Is this already true of your app?

Check your US services — does this apply to your app?

02

Where does your app actually stand?

Answer for what is true today, not what you intend to do this week. Nothing is sent anywhere — this runs in your browser.

0of 8
0 of 8 checks in place, 0 marked as gaps.

Nothing answered yet.

Work down the list. If you are not sure about one, open the matching check above and run the two-minute version — guessing here helps nobody.

0 in place0 gaps8 not checked
  • All fonts, icons and stylesheets are served from my own domain.

    Read check 1Self-host your fonts
    Self-host your fonts — does this apply to your app?
  • Nothing is written to or read from the visitor’s device before they actively opt in — and rejecting is as easy as accepting.

    Read check 2No tracking before consent
    No tracking before consent — does this apply to your app?
  • Row Level Security / security rules are enabled on every table and collection, with policies that scope rows to their owner.

    Read check 3Lock down your database
    Lock down your database — does this apply to your app?
  • I know which personal data reaches each LLM provider, in which region it is processed, how long it is kept, whether it trains models, and I have a DPA.

    Read check 4Know where your AI prompts go
    Know where your AI prompts go — does this apply to your app?
  • No request bodies, user objects, tokens or headers are logged in full; sensitive fields are redacted before they are written.

    Read check 5Keep personal data out of logs
    Keep personal data out of logs — does this apply to your app?
  • A user can obtain a copy of their data and have their account erased, and I know every system their data lives in.

    Read check 6Build "delete my account" and "export my data"
    Build "delete my account" and "export my data" — does this apply to your app?
  • I have checked whether § 5 DDG applies to me, and my privacy notice matches the services I actually run.

    Read check 7Impressum and Datenschutzerklärung
    Impressum and Datenschutzerklärung — does this apply to your app?
  • For every non-EEA service I know the hosting region, the transfer mechanism, and that a DPA is in place.

    Read check 8Check your US services
    Check your US services — does this apply to your app?

Your answers stay in this browser tab. They are not stored on your device and never leave it — this site is static and has no endpoint to send them to. That also means they are gone on reload, so copy them out if you want to keep them.

03

Found something wrong on this page?

Every statute and judgment here is cited so you can read the source instead of trusting me. Law moves — TMG became DDG, TTDSG became TDDDG, the EU's ODR platform was switched off — and this page will drift out of date like any other. If something is wrong, outdated, or misses a case you have actually hit, tell me and I will fix it.

There is deliberately no contact form. A form would process personal data — meaning a legal basis, a retention policy and a paragraph in the privacy notice — for something an email link does just as well. What happens to your data if you write.