GDPR — export, erasure and consents
On Zkrinet: add the block under Building blocks in the panel — one click, your own code is untouched and a snapshot is taken first. The installation steps below apply if you run PocketBase yourself, for example after an export.
For apps with users in the EU. The logged-in user can themselves:
- export their data (the right to data portability / access) as a JSON file,
- delete their account (the right to erasure), with password confirmation and an optional grace period,
- give and withdraw consents — logged in a list that cannot be rewritten afterwards.
The users and the login are PocketBase's own (the collection users). The plugin adds the routes and the log on top.
Tested against PocketBase 0.40.4.
#Installation
Copy pb_hooks/ and pb_migrations/ to your PocketBase and restart. Three collections appear: zk_gdpr_installningar, zk_gdpr_samtycken and zk_gdpr_raderingar.
Note, a change to the default behaviour: by default PocketBase lets a user delete their own account directly (DELETE /api/collections/users/records/{id}, the rule id = @request.auth.id) — without confirming anything. With the plugin that door is closed, so that deletion always goes through the password confirmation below. Superusers can always delete. If you want to keep the old door: turn on tillat_direktradering in the settings.
#Usage from the app
import PocketBase from "pocketbase"
const pb = new PocketBase("https://din-app.se")
// 1. Export my data (save as a file)
const data = await pb.send("/api/zk/gdpr/export", { method: "GET" })
const fil = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" })
// 2. Delete my account — requires the password
try {
const svar = await pb.send("/api/zk/gdpr/radera", { method: "POST", body: { losenord } })
if (svar.raderad) pb.authStore.clear() // gone immediately
else visa(`Kontot raderas ${svar.verkstalls}`) // grace period: can be undone
} catch (err) {
// 400 = password missing · 403 = wrong password · 429 = too many attempts · 409 = see err.data.hinder
}
// 2b. Undo during the grace period
await pb.send("/api/zk/gdpr/angra", { method: "POST" })
// 3. Consents
await pb.send("/api/zk/gdpr/samtycke", { method: "POST", body: { typ: "nyhetsbrev", version: "2026-11", givet: true } })
await pb.send("/api/zk/gdpr/samtycke", { method: "POST", body: { typ: "nyhetsbrev", givet: false } }) // withdraw
const lage = await pb.send("/api/zk/gdpr/samtycke", { method: "GET" })
// → { nyhetsbrev: { givet: false, version: "", tid: "..." } }
// The full history (only my own rows are visible)
await pb.collection("zk_gdpr_samtycken").getFullList({ sort: "tid" })
All routes require a logged-in user in users. Guests get 401; a superuser token does not count as a user.
#The export
{
"exporterad": "2026-11-07T19:00:00.000Z",
"anvandare": { "id": "...", "email": "...", "name": "...", ... },
"samlingar": {
"anteckningar": { "falt": ["agare"], "poster": [ ... ], "avkortad": false },
"zk_gdpr_samtycken": { ... }
},
"inloggning": { "_externalAuths": [ ... ], "_authOrigins": [ ... ] }
}
- The user's own record — with the email, but without hidden fields: the password hash,
tokenKeyand all fields you have marked as hidden are never included. - All records that point to the user via a relation field to
users. The plugin finds the fields itself from the collections' schemas, including multi-value fields (e.g.medlemmar). Hidden fields are removed here too. Relations are shown as ids and file fields as file names (the files themselves are not included). - PocketBase's own login links: linked accounts (Google etc.,
_externalAuths) and known devices (_authOrigins).
What is NOT included, and why:
- Other auth collections, including other users in
usersthat point to you (e.g. a fieldinbjuden_av): those are other people's accounts, not your data. - System collections (
_superusers,_otps,_mfas…) and views (their content comes from other collections). zk_gdpr_installningar(the app's settings, not user data).
zk_ collections from other plugins are included when they point to the user — e.g. zk_ai_logg (questions to the AI are the user's data), zk_stripe_betalningar and zk_gdpr_samtycken. So the rule is: if the record has a relation field to the user, it is user data, regardless of who created the collection.
#Settings
One row in zk_gdpr_installningar (only superusers see and change it; there can only be one). It is created at installation with a 7-day grace period:
| Field | Default | |
|---|---|---|
endast | [] = all | List of collection names: only these are included in the export |
undanta | [] | List of collection names that are never included |
karens_dagar | 7 (the default row) | Grace period in days (max 365). 0 = delete immediately. If there is no row at all, deletion is immediate. |
tillat_direktradering | off | On = PocketBase's regular self-deletion (without password) is open again |
#The deletion
Confirmation: the password is sent along ({ "losenord": "..." }) and checked against PocketBase's password hash. The choice of password (not "recently logged in") is deliberate: a stolen or left-behind token is not enough to delete the account. After 5 failures within 15 minutes, the account's deletion attempts are blocked for 15 minutes (429), even with the correct password.
What happens to other records — PocketBase's own rules for the relation field, tested:
| The relation field | Happens |
|---|---|
cascadeDelete on | The record is deleted with the account (e.g. consents, the AI log). |
| Without cascade, not required | The record remains, the link is removed (the field becomes empty; in multi-value fields only the id disappears). Chosen because e.g. orders and payments often must be kept under accounting law. |
| Without cascade, required | The deletion is stopped (409) with a list: hinder: [{ samling, falt, poster }]. The plugin does not guess — the app owner gets to choose cascade, an optional field, or removing the records. |
We do not anonymise the content of records that remain. If the user's name or email is in a regular text field in such a record, it stays there — that is for the app to deal with (e.g. with an onRecordDelete hook for users).
Grace period: with karens_dagar > 0 the deletion becomes a request (202, verkstalls = date). The user can log in and undo it (/api/zk/gdpr/angra) until then. The deletions run every hour (minute 7), or immediately with POST /api/zk/gdpr/verkstall (superuser). If a blocker has appeared in the meantime, it is stated in the response and in the server log, and the request remains. The user sees their own request in zk_gdpr_raderingar.
#The consent log
zk_gdpr_samtycken: anvandare, typ (e.g. nyhetsbrev, villkor; lowercase letters, digits, _ . -), version (e.g. the version of the terms), givet (true/false), tid.
- Append-only: every give/withdraw becomes a new row. Nobody can create, change or delete rows via the collection API — not even superusers (the admin panel gives 403). Rows are only created via the route, always for the logged-in user (an
anvandarein the body is ignored). - The user reads only their own rows.
- Current state = the latest row per type (
GET /api/zk/gdpr/samtycke). - If the account is deleted, the consents are deleted with it (cascade). If you need to be able to show a consent after the account is gone — export beforehand, or remove cascade and make the field optional (then the rows remain without a link).
#Limitations — read before you promise a customer anything
- Only relation fields are found. Data that points to the user with an email or id in a text field (e.g.
zk_granskning.vem_id,zk_mejllogg,zk_inbjudningar.epost) is not found and not deleted. Convert such fields to relations, or handle them yourself. - The files are not included in the export, only the file names.
- Max 10,000 records per collection in the export (
avkortad: trueif more). The export is built in memory. - Accounts without a known password (only Google login or one-time code) cannot confirm. They first need to set a password via PocketBase's "forgot password" (
requestPasswordReset). - The block against password guessing lives in memory — it is reset when the server restarts, and applies per server (not shared between several instances).
- Backups still contain the deleted user until they are rotated. That is your operations routine, not the plugin's.
- Tamper-proof via the API, not against the database file. Anyone with access to
data.dbcan change the consent log. - The export shows data — it is not a legal assessment. Which collections are personal data, and how long they may be kept, is decided by whoever runs the app.
#Testing
./bank.py gdpr
14 tests: the export contains the right things and nothing else (other users' records, accounts and emails never; hidden fields, password hash and tokenKey never; multi-value relations), the settings (endast/undanta, one row, superuser only), guests and superusers rejected, deletion requires password, guessing block, PocketBase's own self-deletion closed, required relation stops (and multi-value with others remaining does not), cascade/remaining records, grace period with undo and execute (and blockers that appear during the grace period), consents append-only (even for superuser), the user sees only their own, and validation. The security protections are mutant-verified (18 mutants, all caught).