Authenticated user submits an account deletion request with password re-authentication. A 14-day grace period applies before permanent data purge. An undo link is sent by email. A scheduled job purges data after the grace period expires. Implements GDPR Article 17 — Right to Erasure.
- Deletion requires password re-authentication (typing the current password in the modal) to prevent accidental or unauthorized requests.
- The user must also retype the word “DELETE” to confirm intent.
- After submitting, the user is immediately logged out (refresh token invalidated server-side) and cannot log in again until the grace period expires or they undo the request.
- Login attempts during the grace period return
403 Forbiddenwith a message indicating the undo deadline. - The undo link in the email contains a random secure token (
deletion_undo_token). Following the link cancels the deletion without requiring login. - Hard purge runs as a nightly scheduled job (cron: daily at 03:00 UTC,
@Scheduled(cron = "0 0 3 * * *")). It processes all users wheredeletion_scheduled_at < now(). - Hard purge cascade: users table row, projects, conversations, messages, usage_events, budget, topups, gdpr_export_request, cluster B schemas (per-project), K8s Deployments/Ingresses for published apps.
- Audit events are logged at INFO level with structured keys:
gdpr.delete.requested,gdpr.delete.cancelled,gdpr.delete.purged. - Email sending (undo link + purge confirmation) uses Mailgun. See ADR-025: Transactional Email via Mailgun. If Mailgun is in stub mode, see limitations.md — Transactional E-mail.
- Invoices are retained in Stripe for 7 years per Czech tax law — purge does NOT delete Stripe invoice records; only the local
usersrow and associated data is removed.
sequenceDiagram
actor User
%% --- Submit delete request ---
User->>+FE: clicks "Delete account" in Danger Zone
FE->>FE: open Delete Account modal <br> (retype "DELETE" + enter password)
User->>+FE: types "DELETE" and enters current password, submits
FE->>FE: validate: confirmation text == "DELETE" AND password not blank
alt validation fails
FE-->>User: show inline error, keep modal open
end
FE->>+BE: POST /api/v1/users/me/gdpr/delete <br> Authorization: Bearer {accessToken} <br> DeleteAccountRequest
BE->>BE: validate JWT access token
alt access token invalid or missing
BE-->>FE: 401 Unauthorized <br> ErrorResponse
end
BE->>DB: find user by id (from token)
BE->>BE: verify password against stored hash
alt password incorrect
BE-->>FE: 400 Bad Request <br> ErrorResponse
end
BE->>DB: check if deletion_requested_at already set
alt deletion already requested
BE-->>FE: 409 Conflict <br> ErrorResponse
end
BE->>BE: generate deletion_undo_token (256-bit, hex)
BE->>DB: set deletion_requested_at=now(), <br> deletion_scheduled_at=now()+14d, <br> deletion_undo_token={token}
BE->>DB: invalidate all refresh tokens for user <br> (delete from refresh_token where user_id=?)
BE->>BE: send email via Mailgun <br> "Account scheduled for deletion — undo link"
BE->>BE: emit audit log: gdpr.delete.requested {userId}
BE->>-FE: 200 OK <br> DeleteAccountResponse
FE->>FE: clear localStorage (access token + refresh token)
FE->>-User: redirect to /login with query ?deleted=1 <br> Login page shows: "Your account is scheduled for deletion. Check your email."
%% --- Login blocked during grace period ---
User->>+FE: tries to log in
FE->>+BE: POST /api/v1/auth/login <br> LoginRequest
BE->>DB: find user by email
BE->>BE: check deletion_requested_at is set
alt user is in deletion grace period
BE-->>FE: 403 Forbidden <br> ErrorResponse (code=ACCOUNT_SCHEDULED_FOR_DELETION)
end
BE->>-FE: (normal login flow if not in grace period)
FE->>-User: show error: "Account scheduled for deletion. You can undo until {deletion_scheduled_at}."
%% --- Cancel from profile page (while still logged in via fresh session) ---
User->>+FE: clicks "Cancel deletion" button (visible in Danger Zone while grace active)
FE->>+BE: POST /api/v1/users/me/gdpr/delete/cancel <br> Authorization: Bearer {accessToken}
BE->>BE: validate JWT access token
alt access token invalid or missing
BE-->>FE: 401 Unauthorized <br> ErrorResponse
end
BE->>DB: find user by id
BE->>BE: check deletion_requested_at is set and grace period not yet expired
alt no pending deletion
BE-->>FE: 409 Conflict <br> ErrorResponse
end
BE->>DB: clear deletion_requested_at, deletion_scheduled_at, deletion_undo_token
BE->>BE: emit audit log: gdpr.delete.cancelled {userId}
BE->>-FE: 200 OK <br> (no body)
FE->>-User: show toast "Account deletion cancelled. Your account is restored."
%% --- Undo via email link ---
User->>+FE: opens email, clicks undo link <br> POST /api/v1/gdpr/delete/{token}/confirm-undo
FE->>+BE: POST /api/v1/gdpr/delete/{token}/confirm-undo <br> (no auth — public token endpoint)
BE->>DB: find user by deletion_undo_token
alt token not found
BE-->>FE: 404 Not Found <br> ErrorResponse
end
BE->>BE: check deletion_scheduled_at > now()
alt grace period already expired (user already purged or hard purge ran)
BE-->>FE: 410 Gone <br> ErrorResponse
end
BE->>DB: clear deletion_requested_at, deletion_scheduled_at, deletion_undo_token
BE->>BE: emit audit log: gdpr.delete.cancelled {userId}
BE->>-FE: 200 OK <br> UndoDeleteResponse
FE->>-User: show confirmation page: "Account deletion cancelled. You can log in normally."
%% --- Nightly hard purge job ---
Note over BE,DB: Scheduled job — runs daily at 03:00 UTC
BE->>DB: SELECT users WHERE deletion_scheduled_at < now()
loop for each user
BE->>DB: cascade DELETE all user data <br> (projects, conversations, messages, usage_events, <br> budget, topups, gdpr_export_request, …)
BE->>BE: drop cluster B schemas for each project <br> (talkide_dataplane: tk_t{tenantId}_p{slug}_{env})
BE->>BE: delete K8s Deployments + Ingresses <br> for published apps (provisioner cleanup)
BE->>DB: DELETE FROM users WHERE id = ?
BE->>BE: send final email: "Your data has been permanently deleted"
BE->>BE: emit audit log: gdpr.delete.purged {userId}
end
POST /api/v1/users/me/gdpr/delete
POST /api/v1/users/me/gdpr/delete DeleteAccountRequest:
{
"password": "myCurrentPassword123"
}
Note:
confirmationText(the “DELETE” text field) is validated client-side only and is NOT sent in the request body.
200 OK DeleteAccountResponse:
{
"data": {
"deletionScheduledAt": "2026-06-06T10:00:00Z",
"message": "Your account is scheduled for permanent deletion on 2026-06-06. Check your email for the undo link."
}
}
400 Bad Request (wrong password) ErrorResponse:
{
"status": 400,
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [
{ "field": "password", "message": "current password is incorrect" }
]
}
401 Unauthorized (missing or invalid access token) ErrorResponse:
{
"status": 401,
"code": "AUTHENTICATION_FAILED",
"message": "Access token is missing or invalid"
}
409 Conflict (deletion already requested) ErrorResponse:
{
"status": 409,
"code": "CONFLICT_GDPR_DELETE",
"message": "Account deletion is already scheduled for this account."
}
POST /api/v1/users/me/gdpr/delete/cancel
POST /api/v1/users/me/gdpr/delete/cancel (no request body)
200 OK (no body)
401 Unauthorized ErrorResponse:
{
"status": 401,
"code": "AUTHENTICATION_FAILED",
"message": "Access token is missing or invalid"
}
409 Conflict (no pending deletion) ErrorResponse:
{
"status": 409,
"code": "CONFLICT_GDPR_DELETE_CANCEL",
"message": "No pending deletion request found for this account."
}
POST /api/v1/gdpr/delete/{token}/confirm-undo
POST /api/v1/gdpr/delete/{token}/confirm-undo (no request body, public — no auth)
200 OK UndoDeleteResponse:
{
"data": {
"message": "Your account deletion has been cancelled. You can log in normally."
}
}
404 Not Found (token does not exist) ErrorResponse:
{
"status": 404,
"code": "NOT_FOUND",
"message": "Undo link not found or already used."
}
410 Gone (grace period already expired) ErrorResponse:
{
"status": 410,
"code": "GONE_GDPR_DELETE",
"message": "The undo period has expired. Your account data has been permanently deleted."
}
403 from login during grace period
POST /api/v1/auth/login — when user is in grace period:
403 Forbidden ErrorResponse:
{
"status": 403,
"code": "ACCOUNT_SCHEDULED_FOR_DELETION",
"message": "Account scheduled for deletion. Use undo link in email.",
"data": {
"deletionScheduledAt": "2026-06-06T10:00:00Z"
}
}
FE formats the deletion date from
data.deletionScheduledAt— do NOT parse themessagefield to extract the date.
DB Schema
New columns in users table
Liquibase migration: 0049-add-user-deletion-fields.xml
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">
<changeSet id="0049-add-user-deletion-fields" author="talkide">
<addColumn tableName="users">
<column name="deletion_requested_at" type="timestamptz">
<constraints nullable="true"/>
</column>
<column name="deletion_undo_token" type="varchar(64)">
<constraints nullable="true" unique="true"/>
</column>
<column name="deletion_scheduled_at" type="timestamptz">
<constraints nullable="true"/>
</column>
</addColumn>
<createIndex tableName="users" indexName="idx_users_deletion_scheduled_at">
<column name="deletion_scheduled_at"/>
</createIndex>
<createIndex tableName="users" indexName="idx_users_deletion_undo_token">
<column name="deletion_undo_token"/>
</createIndex>
</changeSet>
</databaseChangeLog>
Deletion lifecycle fields
| Column | Type | Description |
|---|---|---|
deletion_requested_at | TIMESTAMPTZ NULL | When the deletion was requested. NULL = account active. |
deletion_undo_token | VARCHAR(64) NULL | 256-bit cryptographic token (hex). Used in the email undo link. Cleared after undo or purge. |
deletion_scheduled_at | TIMESTAMPTZ NULL | Purge date = deletion_requested_at + 14 days. Indexed for the nightly purge job. |
Hard purge cascade order
FK cascade is NOT set at the DB level — deletion is explicit in the application layer for full control and auditability.
The scheduled job deletes data in the following explicit order:
usage_eventwhereuser_id = ?credit_topupwhereuser_id = ?user_budgetwhereuser_id = ?gdpr_export_requestwhereuser_id = ?+ DO Spaces objectsmessagewhereconversation_id IN (SELECT id FROM conversation WHERE project_id IN (SELECT id FROM project WHERE user_id = ?))conversationwhereproject_id IN (SELECT id FROM project WHERE user_id = ?)refresh_tokenwhereuser_id = ?- Cluster B schemas in
talkide_dataplane:DROP SCHEMA IF EXISTS tk_t{tenantId}_p{slug}_{env} CASCADEfor each project - K8s Deployments + Ingresses for published apps (provisioner cleanup)
projectwhereuser_id = ?tenantwhereid = user.tenant_id(only if personal tenant)userswhereid = ?- Stripe: no delete (invoices retained 7 years per Czech tax law) — only internal reference cleared
Frontend
UX Guidelines
Delete Account card in DangerSection.vue:
Styling: background: var(--rose-soft), border: 1px solid var(--rose-line). Delete button: background: var(--rose) (solid rose).
Flow — submitting delete request:
- User clicks “Delete account” button.
- Modal opens with title “Delete your account”. Body:
- Warning paragraph: “This action is irreversible. Your account will be permanently deleted on {deletion_scheduled_at} (14 days from now). You’ll receive an undo link by email.”
- Field 1 — confirmation text: label “Type DELETE to confirm”,
<input type="text" placeholder="DELETE">. FE validates: value must equal exactly"DELETE"(case-sensitive). - Field 2 — password: label “Enter your current password”,
<input type="password">. FE validates: not blank. - Two buttons: “Cancel” (ghost) and “Delete my account” (rose primary, disabled until both fields valid).
- On submit:
POST /api/v1/users/me/gdpr/delete.- On
200 OK: clear localStorage, redirect to/login?deleted=1. Login page shows banner: “Your account is scheduled for deletion on {date}. Check your email for the undo link.” - On
400(wrong password): show field error “Incorrect password” on password field, keep modal open. - On
409(already requested): show info toast “Account deletion is already scheduled. Check your email.”
- On
- “Delete my account” button shows loading spinner while request is in flight.
Flow — cancelling from profile (while grace period active):
- If
GET /api/v1/users/mereturns a user withdeletionScheduledAtset, the Danger Zone section shows a dismissible amber banner: “Your account is scheduled for deletion on {date}. You can cancel below.” - A “Cancel deletion” ghost button replaces the “Delete account” button.
- On confirm:
POST /api/v1/users/me/gdpr/delete/cancel. On200: show success toast “Account deletion cancelled. Your account is restored.”
Flow — undo via email link (public page /gdpr/undo/{token}):
- Vue Router route
/gdpr/undo/:token— the Vue component callsPOST /api/v1/gdpr/delete/{token}/confirm-undoautomatically inonMounted(). No form is shown; the user sees a loading spinner while the request is in flight, then transitions to a success or error screen. - On page refresh, the POST is attempted again — this is safe because the endpoint is idempotent: if the token has already been consumed (deletion fields cleared), the next call returns
404(token no longer exists in DB).200 OK: show “Your account deletion has been cancelled. You can log in normally.” with a “Go to login” button.404: show “This undo link is invalid or has already been used.”410: show “The undo deadline has passed. Your account data has been permanently deleted.”
Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| confirmationText | not_blank | must equal “DELETE” | case-sensitive exact match; validated client-side only | |
| password | not_blank | password is verified server-side; FE only checks not_blank |
Backend
Validations
| Field | Constraints | Size | Note |
|---|---|---|---|
| JWT token | must be valid, non-expired | 401 AUTHENTICATION_FAILED otherwise | |
| password | not_blank | 400 VALIDATION_ERROR otherwise | |
| password | must match stored hash | 400 VALIDATION_ERROR (field: password) if wrong | |
| deletion_requested_at | must be NULL | 409 CONFLICT_GDPR_DELETE if already set | |
| confirmationText | not sent to server | FE-only validation — field is NOT part of DeleteAccountRequest DTO |
Security Considerations
- Password re-auth at request time prevents unauthorized deletion (e.g., stolen access token alone is not sufficient).
deletion_undo_tokenmust be at least 256 bits of cryptographic randomness — not derived from user ID or timestamp. Generated usingSecureRandom.- Token must be single-use conceptually: once the undo is confirmed, clear all three deletion fields atomically.
- The public undo endpoint (
POST /api/v1/gdpr/delete/{token}/confirm-undo) does not require a valid JWT — security relies solely on token unpredictability. Do NOT log the token at INFO level. - Refresh tokens are invalidated immediately upon delete request — the user cannot re-authenticate during the grace period.
- Login endpoint must check
deletion_requested_at IS NOT NULLbefore issuing tokens and return403 ACCOUNT_SCHEDULED_FOR_DELETIONwith the undo deadline in the error payload.
Scheduled Purge Job
- Cron:
@Scheduled(cron = "0 0 3 * * *")— runs daily at 03:00 UTC. - Processes users where
deletion_scheduled_at IS NOT NULL AND deletion_scheduled_at < now(). - Each user is purged in a separate DB transaction. If purge for one user fails, other users in the batch are still processed (fail-per-user, not fail-all).
- K8s Deployment/Ingress cleanup: call provisioner cleanup API for each project. If K8s API is unavailable, log ERROR and continue — admin must manually clean up. See Known Limitations below.
- Cluster B schema drop:
DROP SCHEMA IF EXISTS tk_t{tenantId}_p{slug}_{env} CASCADEfor each project of the user. - After successful purge: send final confirmation email via Mailgun. If Mailgun unavailable: log ERROR, continue (data is already gone — email is best-effort at this stage).
- Emit structured audit log:
gdpr.delete.purged {userId: …, projectsDropped: N, schemasDropped: N}.
Test Cases
| GIVEN | WHEN | THEN | Scope |
|---|---|---|---|
| authenticated user, correct password, no pending deletion | POST /gdpr/delete is called | 200 OK, deletion fields set in DB, refresh tokens invalidated, email sent | integration |
| authenticated user, wrong password | POST /gdpr/delete is called | 400 VALIDATION_ERROR (field: password) returned | integration |
| authenticated user, blank password body | POST /gdpr/delete is called | 400 VALIDATION_ERROR returned | unit |
| authenticated user, deletion already requested | POST /gdpr/delete is called | 409 CONFLICT_GDPR_DELETE returned | integration |
| no Authorization header | POST /gdpr/delete is called | 401 AUTHENTICATION_FAILED returned | unit |
| authenticated user, pending deletion within grace period | POST /gdpr/delete/cancel is called | 200 OK, deletion fields cleared in DB | integration |
| authenticated user, no pending deletion | POST /gdpr/delete/cancel is called | 409 CONFLICT_GDPR_DELETE_CANCEL returned | integration |
| no Authorization header | POST /gdpr/delete/cancel is called | 401 AUTHENTICATION_FAILED returned | unit |
| valid undo token, grace period active | POST /gdpr/delete/{token}/confirm-undo | 200 OK, deletion fields cleared | integration |
| non-existent undo token | POST /gdpr/delete/{token}/confirm-undo | 404 NOT_FOUND returned | integration |
| valid undo token, grace period expired | POST /gdpr/delete/{token}/confirm-undo | 410 Gone returned | integration |
| user in grace period | POST /auth/login with correct credentials | 403 ACCOUNT_SCHEDULED_FOR_DELETION returned | integration |
| user NOT in grace period | POST /auth/login with correct credentials | normal 200 OK login flow | integration |
| user with deletion_scheduled_at < now() exists | scheduled purge job runs | user data fully purged from DB, cluster B schemas dropped, K8s resources deleted, confirmation email sent, audit log emitted | integration |
| K8s API unavailable during purge | scheduled purge job runs | user data purged from DB, ERROR logged for K8s step, job continues with remaining users | integration |
| user fills DELETE + password, submits | delete modal submit | POST called, localStorage cleared, redirect to /login?deleted=1 | e2e |
| user clicks undo link in email | /gdpr/undo/{token} page | confirm-undo POST called, success page shown | e2e |
Known Limitations
- K8s Deployment/Ingress cleanup during hard purge — The nightly purge job attempts to delete K8s Deployments and Ingresses for all published apps of the deleted user. If the K8s API is unavailable at purge time, these resources are NOT deleted and require manual admin cleanup. This is tracked as a known limitation and logged at ERROR level. Admin runbook:
kubectl delete deployment -n talkide -l talkide.app/user-id={userId}. - Stripe invoice retention — Invoices are NOT deleted from Stripe during purge (Czech tax law requires 7-year retention). The Stripe Customer object and associated invoices remain. Local
usersrow reference is removed but Stripe Customer ID is not stored post-purge. This is intentional. - Cluster B schema drop on provisioner error — If the data-plane PgBouncer is unreachable,
DROP SCHEMAmay fail. Job logs ERROR and continues. Manual cleanup:DROP SCHEMA IF EXISTS tk_t{tenantId}_p{slug}_{env} CASCADEon cluster B. - No self-service during grace period — The user cannot log in, access their data, or submit a new export request during the 14-day grace period. Only the undo action is available (via email link or — if they retained a valid access token — the cancel endpoint).
- Grace period is not extendable — Once set,
deletion_scheduled_atis fixed atrequested_at + 14 days. No UI or API to extend.
Thanks for the feedback.