ZKRINET Docs
Building blocks

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:

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": [ ... ] }
}

What is NOT included, and why:

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:

FieldDefault
endast[] = allList of collection names: only these are included in the export
undanta[]List of collection names that are never included
karens_dagar7 (the default row)Grace period in days (max 365). 0 = delete immediately. If there is no row at all, deletion is immediate.
tillat_direktraderingoffOn = 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 fieldHappens
cascadeDelete onThe record is deleted with the account (e.g. consents, the AI log).
Without cascade, not requiredThe 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, requiredThe 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.

zk_gdpr_samtycken: anvandare, typ (e.g. nyhetsbrev, villkor; lowercase letters, digits, _ . -), version (e.g. the version of the terms), givet (true/false), tid.

Limitations — read before you promise a customer anything

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).