Users can download a machine-readable export of their personal data
Why it matters
GDPR Art. 20 gives every EU user a statutory right to receive a structured, machine-readable copy of the personal data you hold about them, and CCPA §1798.110 gives California users a parallel right-to-know. Both have a one-month (GDPR Art. 12) response clock that starts the moment a request lands in a support inbox. Without a working export endpoint the only way to satisfy a portability request is a panic-mode manual database dump — which regulators treat as a risk factor in its own right, because ad-hoc dumps leak data they should not include. The GDPR fine ceiling for Art. 20 non-compliance is 4% of global turnover or €20M; CCPA AG enforcement has reached $7,500 per affected consumer. AI coding tools reliably build login and profile-edit flows, but rarely scaffold a /api/me/export route, because training corpora skew heavily toward CRUD-happy-path code and away from regulator-facing plumbing. The quiet failure mode is a live site that looks compliant until the first SAR lands and nobody knows where to point it.
Severity rationale
High because missing export directly blocks Art. 20 / §1798.110 compliance on a one-month regulator clock, scales liability per-user, and cannot be handwaved as "best effort" — the obligation is binary.
Remediation
Add an authenticated GET route at app/api/me/export/route.ts that fetches every row tied to the requesting user across your main tables, serializes to JSON, and returns it with a Content-Disposition: attachment; filename="my-data.json" header.
// app/api/me/export/route.ts
export async function GET() {
const session = await getServerSession()
if (!session?.user?.id) return new Response('Unauthorized', { status: 401 })
const data = await db.user.findUnique({
where: { id: session.user.id },
include: { orders: true, activityLog: true, preferences: true },
})
return new Response(JSON.stringify(data, null, 2), {
headers: { 'Content-Type': 'application/json', 'Content-Disposition': 'attachment; filename="my-data.json"' },
})
}
Rate-limit to one export per user per 24 hours. Wire a "Download my data" button in app/settings/page.tsx. Deeper coverage of the portability workflow (file uploads, third-party processor data, completeness checks) lives in the gdpr-readiness Pro audit.
Detection
- ID:
data-export-endpoint-exists - Severity:
high - What to look for: First confirm user accounts exist (same evidence as account deletion: auth library in
package.json, auth routes,Usermodel). If none, skip. Otherwise enumerate export surfaces. Look for a route at one ofapp/api/user/export,app/api/me/export,app/api/account/export,app/api/privacy/data,app/api/export,app/me/download,app/settings/export,pages/api/user/export, or a server action whose name matches/^(export|download|request)(UserData|Account|MyData|DataExport)$/i. Inspect the handler body: it must actually build a payload from user data and return it (JSON, CSV, ZIP), typically viadb.user.findUnique/supabase.from('users').selectfollowed bynew Response(JSON.stringify(...))with aContent-Disposition: attachmentheader or equivalent. Reject stubs: a route returning{ message: 'Coming soon' }, a page that only links tomailto:privacy@with no server handler, or a response body that is literally empty. - Pass criteria: User accounts exist AND an authenticated export endpoint is present AND the handler body constructs and returns a real data payload (JSON, CSV, or ZIP) rather than a placeholder message or a redirect to a contact form.
- Fail criteria: User accounts exist AND either (a) no export endpoint is found, or (b) the endpoint exists but returns a stub ("Coming soon", empty object, mailto redirect).
- Skip (N/A) when: No user accounts exist. Quote:
"No auth library, auth routes, or user model detected — project has no user accounts." - Do NOT pass when: A file exists at an export path but the handler body is a stub: returning
{ message: 'Contact support' }, redirecting to an email link, throwing 501 Not Implemented, or commented-out TODO. The endpoint must actually emit data. - Before evaluating, quote: Quote the exact file path and 2–4 lines of the handler body that construct the response payload. If no handler exists, quote the auth evidence showing accounts exist:
"<file>: <line referencing auth>". - Report even on pass:
"Export handler at <path>; format: <json|csv|zip>; includes: <tables queried>." - Detail on fail:
"User accounts present (Clerk in package.json, sign-in route at app/sign-in/) but no export route found under app/api/user/, app/api/me/, app/api/account/, or app/api/export/."or"Route at app/api/me/export/route.ts exists but handler body only returns new Response('Coming soon', { status: 501 })." - Remediation: See the fenced
app/api/me/export/route.tsexample above inremediation_prose. Rate-limit to one export per user per 24 hours and surface the trigger inapp/settings/page.tsx. Deeper coverage lives in thegdpr-readinessPro audit.
External references
- gdpr:eu-2016-679 · Art. 20 — Right to data portability
- ccpa:cal-civ-1798 · §1798.110 — Right to know
Taxons
History
- 2026-04-23·v1.0.0·Initial Phase 9.1 v3.1 Stack Scan promotion — portability endpoint is a coded GDPR Art. 20 + CCPA §1798.110 obligation with a one-month regulator clock.·by phase-9-1-stack-scan-v3-1