GDPR in a FastAPI app: data export, account deletion and cookies
The GDPR features a small SaaS actually has to build come down to three things: let people download their data, let them delete their account, and don't set optional cookies without asking. The interesting part is where these collide with the real world: a deleted customer who is still being charged, and tax law that says some records must stay.
This is how I built it, not legal advice. Have someone who knows the law where you operate review your processes and your privacy policy.
Contents
Data export
Articles 15 and 20 give people the right to get their data, in a machine-readable format for the portability part. A JSON download from the settings page covers both, and answers these requests without any manual work on your side. The service gathers the data into plain dicts:
# app/gdpr/service.py
async def export_user_data(session: AsyncSession, user: User) -> dict:
"""Everything the app stores about the user, as JSON-ready data (GDPR art. 15 and 20).
Password hashes and security stamps are left out: they're not useful to the user."""
data: dict = {
"exported_at": datetime.now(UTC).isoformat(),
"account": {
"id": user.id,
"email": user.email,
"language": user.locale,
"created_at": _iso(user.created_at),
"email_verified_at": _iso(user.email_verified_at),
"is_active": user.is_active,
},
}
if not get_settings().billing_enabled:
return data
subscriptions = await session.scalars(
select(Subscription).where(Subscription.user_id == user.id)
)
data["billing"] = {
"stripe_customer_id": await billing.get_customer_id(session, user),
"subscriptions": [
{
"plan": s.plan,
"status": s.status,
"current_period_end": _iso(s.current_period_end),
"created_at": _iso(s.created_at),
}
for s in subscriptions
],
...
}
return data
The fields are listed by hand on purpose. Dumping every column with a loop is shorter, but then a new password_reset_secret column quietly ends up in everyone's export. An explicit list means new data gets exported by a deliberate decision.
The route sends it as a file:
# app/gdpr/routes.py
@router.post("/account/export")
async def export_data(session: SessionDep, user: CurrentUser):
data = await service.export_user_data(session, user)
slug = get_settings().app_name.lower().replace(" ", "-")
filename = f"{slug}-data-{datetime.now(UTC):%Y-%m-%d}.json"
return Response(
json.dumps(data, ensure_ascii=False, indent=2),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
It's a POST with a CSRF token rather than a GET link, even though it changes nothing. A GET could be triggered from another site, and ensure_ascii=False keeps Polish or German characters readable in the file.
Keeping the export complete
An export is only correct while someone remembers to update it. No test can know that your new table holds personal data, so make it part of the routine: a comment at the top of the service, and a line in your new-feature checklist.
"""Exporting and deleting a user's data.
When you add a table that holds personal data, add it to export_user_data(). Deleting
the user removes their rows automatically if your foreign key has ondelete="CASCADE".
"""
Deleting an account: the route
Article 17 covers erasure. Let people do it themselves, with enough friction that it isn't an accident or the work of someone at an unlocked laptop:
@router.post("/account/delete")
async def delete_account(
request: Request,
session: SessionDep,
user: CurrentUser,
delete_password: Annotated[str, Form()],
confirm: Annotated[str, Form()] = "",
):
def fail(error: str):
context = {"user": user, "errors": {"delete": error}}
return templates.TemplateResponse(request, "account/settings.html", context, 400)
if confirm != "yes":
return fail("gdpr.delete.not_confirmed")
if not await asyncio.to_thread(verify_password, user.password_hash, delete_password):
return fail("settings.error.wrong_password")
try:
await service.delete_account(session, user)
except service.LastAdminError:
return fail("gdpr.delete.last_admin")
logout_session(request)
flash(request, "gdpr.flash.deleted")
return RedirectResponse("/", status_code=303)
The settings page has several forms that ask for the current password, so this field gets a name of its own, delete_password. Asking again even though the user is signed in means a borrowed laptop isn't enough to delete an account. And the last admin can't delete their account, or nobody could run the admin panel.
Stop the billing first
The worst bug this feature can have is a deleted customer who keeps being charged. Their subscription lives in Stripe, and deleting a row in your database doesn't touch it. So cancel first, and only then delete:
# Subscriptions in these states can still charge the customer.
LIVE_SUBSCRIPTION_STATUSES = ("active", "trialing", "past_due", "unpaid", "incomplete", "paused")
async def delete_account(session: AsyncSession, user: User) -> None:
if user.is_admin:
admins = await session.scalar(
select(func.count()).select_from(User).where(User.is_admin.is_(True))
)
if admins <= 1:
raise LastAdminError
if get_settings().billing_enabled:
live = await session.scalars(
select(Subscription).where(
Subscription.user_id == user.id,
Subscription.status.in_(LIVE_SUBSCRIPTION_STATUSES),
)
)
for subscription in live:
try:
await gateway.cancel_subscription(subscription.stripe_subscription_id)
except stripe.InvalidRequestError:
# Already cancelled or gone in Stripe; nothing left to stop.
logger.info("Subscription %s was already gone", subscription.stripe_subscription_id)
if customer_id := await billing.get_customer_id(session, user):
deleted_on = datetime.now(UTC).date().isoformat()
await gateway.update_customer(
customer_id, {"metadata": {"app_account_deleted": deleted_on}}
)
...
Mind the order of failures. If Stripe is down, cancel_subscription raises something other than InvalidRequestError, the request fails, and the account still exists. The user sees an error and can try again. That's the correct way to fail: the opposite order would leave someone with no account and no way to stop the charges.
The status list is longer than "active" for the same reason. A past_due subscription is still being retried, and catching only active misses it.
The Stripe customer itself stays, marked with metadata, because your invoices and payments belong to it.
Foreign keys decide what happens
Deleting rows by hand from every table is a list that goes out of date the moment you add a table. Let the database do it, and make the decision once, on each foreign key:
# Deleted with the user
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
# The row stays, without the person
granted_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
The service then needs one line: await session.delete(user). Everything else follows. CASCADE is the normal choice; SET NULL suits a row that has its own reason to exist, like an audit note saying a plan was granted.
The confirmation email has to come after the commit, and you need the address before the row is gone:
email, locale = user.email, user.locale
await session.delete(user)
await session.commit()
await send_template(email, "account_deleted", locale)
What has to stay: tax records
Erasure isn't absolute. Article 17(3)(b) makes an exception where a legal obligation requires you to keep data, and tax law is exactly that. Invoices stay in Stripe. There's also a less obvious case: if you invoiced a business without VAT under the reverse charge, you need to be able to show its VAT number was valid at the time.
So when an account is deleted, the VIES checks that back an invoice stay, along with those invoices, and everything else goes:
evidence = select(Invoice.vat_check_id).where(
Invoice.user_id == user.id, Invoice.vat_check_id.is_not(None)
)
await session.execute(
delete(VatCheckRecord).where(
VatCheckRecord.user_id == user.id, VatCheckRecord.id.not_in(evidence)
)
)
await session.execute(
delete(Invoice).where(Invoice.user_id == user.id, Invoice.vat_check_id.is_(None))
)
The kept rows have ondelete="SET NULL" on user_id, so the database cuts the link to the account when the user is deleted. What's left is the evidence and nothing more: VAT number, registered name, result, time, and the invoice it applies to. A new account with the same email can't see any of it.
Keeping data "because the law requires it" has two duties attached. Say so in your privacy policy, and delete it when the period ends. The code doesn't do that by itself, so schedule something once a year:
DELETE FROM invoices WHERE user_id IS NULL AND issued_at < date_trunc('year', now()) - interval '5 years';
DELETE FROM vat_checks c WHERE c.user_id IS NULL
AND NOT EXISTS (SELECT 1 FROM invoices i WHERE i.vat_check_id = c.id);
Check how long your country actually requires; five years is common but not universal.
Cookies: you may not need a banner
Under the ePrivacy rules, cookies that are strictly necessary for a service the user asked for need no consent. A signed-in session qualifies, and so does remembering a language choice the user made. If that's all you set, you don't need a banner at all, which is one setting here:
| Cookie | What it does | Lifetime |
|---|---|---|
session | signed-in user, CSRF token, flash messages | up to 30 days |
lang | chosen language | 1 year |
cookie_consent | the visitor's cookie choice | 1 year |
# app/config.py
# Show the cookie banner. The kit itself only sets strictly necessary cookies, which
# need no consent; the banner matters once you add analytics or ads.
cookie_banner: bool = True
The moment you add analytics that sets cookies, an ad pixel or a chat widget, you need consent first. To make that hard to get wrong, those scripts go in one template, partials/optional_scripts.html, and the base layout includes it only when the visitor's choice was "all". Nothing inside it runs before they accept. Analytics without cookies avoids the question for that one tool; check what your provider actually stores.
A banner that works without JavaScript
The banner is a normal form with two buttons side by side, posting to one route. With HTMX it disappears in place; without JavaScript the browser goes back to the page it was on:
CONSENT_COOKIE = "cookie_consent"
CONSENT_CHOICES = ("all", "necessary")
@router.post("/cookies")
async def save_cookie_choice(
request: Request,
choice: Annotated[str, Form()],
next_url: Annotated[str, Form(alias="next")] = "/",
):
# HTMX just removes the banner; without JavaScript the browser goes back.
if request.headers.get("hx-request"):
response = Response(status_code=200)
else:
response = RedirectResponse(safe_redirect_path(next_url), status_code=303)
if choice in CONSENT_CHOICES:
response.set_cookie(
CONSENT_COOKIE,
choice,
max_age=60 * 60 * 24 * 365,
samesite="lax",
secure=get_settings().is_production,
)
return response
The empty 200 response works because the form has hx-target="#cookie-banner" and hx-swap="outerHTML": HTMX replaces the banner with nothing. safe_redirect_path keeps the hidden next field from being used as an open redirect, and checking choice against a fixed list keeps arbitrary values out of the cookie.
"Reject" must be as easy as "accept", and people must be able to change their mind, so a /cookies page linked in the footer shows the same two choices.
The privacy policy
The law requires one, and the useful way to write one is from what the code does: what you store, why, who processes it, how long you keep it, and what's kept after deletion. Keep it as a template, one per language, rather than in your translation files, because it's a document a lawyer reviews as a whole. Fill the company details from settings, and show a visible warning until they're set, so an unfinished template can't go live unnoticed:
LEGAL_COMPANY_NAME=Acme sp. z o.o.
LEGAL_ADDRESS=ul. Prosta 1, 00-001 Warszawa, Poland
LEGAL_EMAIL=privacy@acme.example
@router.get("/privacy")
async def privacy_policy(request: Request):
# Legal text lives in one template per language rather than in the translation files.
name = "legal/privacy_pl.html" if request.state.locale == "pl" else "legal/privacy_en.html"
return templates.TemplateResponse(request, name)
Tests
Check that secrets stay out of the export, that deletion stops billing and removes the rows, and that the evidence stays with no link to the person:
async def test_export_downloads_everything_but_secrets(client, make_user, db, stripe_fake):
user = await make_user()
await subscribe(client, db, stripe_fake, user)
await login(client)
response = await post_form(client, "/account/export")
assert "attachment;" in response.headers["content-disposition"]
data = json.loads(response.text)
assert data["account"]["email"] == "ada@example.com"
assert "argon2" not in response.text
assert user.auth_stamp not in response.text
async def test_delete_account(client, make_user, db, stripe_fake):
user = await make_user()
subscription = await subscribe(client, db, stripe_fake, user)
await login(client)
await delete(client)
# Stripe stops charging, and the customer is marked as deleted.
assert stripe_fake.cancelled == [subscription["id"]]
assert "app_account_deleted" in stripe_fake.customer_updates["cus_1"]["metadata"]
# Every local row is gone.
for model in (User, BillingCustomer, Subscription, Invoice, VatCheckRecord):
assert await db.scalar(select(func.count()).select_from(model)) == 0, model
Checklist
- A JSON export with explicitly listed fields, behind a POST with CSRF.
- A reminder in your feature checklist to add new personal data to the export.
- Deletion that asks for the password and a confirmation.
- Every subscription that can still charge is cancelled before anything is deleted.
- Each foreign key to users says
CASCADEorSET NULL. - Records required by tax law kept without the link to the person, and mentioned in the policy.
- Something that removes kept records when the retention period ends.
- Optional scripts in one place, rendered only after consent; no banner if you set no optional cookies.
- A privacy policy that describes what the code really does.
These GDPR tools ship with getpylaunch, a FastAPI SaaS starter I sell, including a privacy policy template in English and Polish.