Skip to content
Slicekit

Backend guides

Data export & GDPR

Export a user's personal data and handle deletion, the GDPR-oriented personal-data tooling.

View .md
On this page

Two obligations, two slices

Every record keyed by a user is subject to two GDPR rights, and Slicekit wires each to its own vertical slice:

  • Article 15 (right of access): GET /api/v1/users/me/export returns everything the system holds about the caller as a single JSON document. The slice lives in api/src/Slicekit.Core/Features/Users/ExportMyData/.
  • Article 17 (right to erasure): deleting an account removes or anonymises every user-owned row. The slice lives in api/src/Slicekit.Core/Features/Users/DeleteUser/.

The two are deliberately symmetric. Any new entity you add that references a user must be reflected in both handlers in lockstep. Forgetting either side is a compliance defect, and an architecture test fails the build to make sure you don’t (see The guardrail below).

What counts as personal data

If your feature stores rows keyed by UserId, directly via a foreign key or transitively via a join through another user-owned table, it is almost certainly personal data. Consents, refresh-token sessions, API keys, passkeys, and permission grants all qualify and are already handled.

When you’re unsure, grep the EF configurations:

rg "UserId" api/src/Slicekit.Core/Persistence/Configurations/

Each result is a candidate that needs a place in the export and a deletion strategy.

How the export is produced

The endpoint is a thin adapter in api/src/Slicekit.Api/Endpoints/v1/Me/ExportMeEndpoint.cs. It maps GET /me/export, requires the UserExportMyData permission, rate-limits the call, sets a Content-Disposition: attachment header so browsers download a .json file, and dispatches an ExportMyDataQuery over the Wolverine bus:

routes.Users().MapGet("/me/export", HandleAsync)
    .RequirePermission(Allow.UserExportMyData)
    .RequireRateLimiting(RateLimitPolicies.ExportData)
    .Produces<ExportMyDataResult>();

All the work happens in ExportMyDataQueryHandler. It loads the Identity row and each user-owned table, projects the rows into Exported* records, and emits an audit event before returning:

var consents = await db.UserConsents
    .Where(c => c.UserId == appUser.Id)
    .OrderBy(c => c.GrantedAtUtc)
    .Select(c => new ExportedConsent(c.Id, c.ConsentType, c.Version, c.GrantedAtUtc))
    .ToListAsync(ct);

// ...sessions, apiKeys, passkeys, permissions, permissionExclusions...

return new ExportMyDataResult(
    SchemaVersion: 2,
    ExportedAt: DateTimeOffset.UtcNow,
    User: new ExportedUser(/* id, email, displayName, phone, flags, timestamps */),
    Consents: consents,
    Sessions: sessions,
    ApiKeys: apiKeys,
    Passkeys: passkeys,
    Permissions: permissions,
    PermissionExclusions: permissionExclusions);

The shape is the ExportMyDataResult record in Features/Users/ExportMyData/Query.cs. It serialises to a stable JSON document:

{
  "schemaVersion": 2,
  "exportedAt": "2026-06-13T12:34:56Z",
  "user": { ... },
  "consents": [ ... ],
  "sessions": [ ... ],
  "apiKeys": [ ... ],
  "passkeys": [ ... ],
  "permissions": [ ... ],
  "permissionExclusions": [ ... ]
}

SchemaVersion is part of the contract. Once a downstream consumer relies on a key, renaming or removing it is a breaking change: bump SchemaVersion and call it out in the PR.

Extend the export when you add a feature

In ExportMyDataQueryHandler.HandleAsync, add a query for your data, project it into a new Exported<Thing> record (defined alongside the others in Query.cs), and surface it on ExportMyDataResult. Filter every join to the requesting user so peer data never leaks.

What never goes in the export

The export is meant for the user, so it must never carry security material an attacker could use:

FieldWhy
Password hash (ApplicationUser.PasswordHash)Excluded by Identity convention; do not re-add.
Refresh-token hashesA hash is useless to the user and useful to an attacker.
API-key material beyond KeyHintExport the hint and metadata only, never the hash or plaintext.
SecurityStamp / ConcurrencyStampIdentity-internal columns with no user-facing meaning.
Audit chain hashes / sequence pointersThe user’s audit events are exportable; the chain structure isn’t.
Other users’ identifiers in shared resourcesFilter joins to the requesting user; redact peer identifiers.

Rate limiting and auditing

The endpoint uses the ExportData rate-limit policy (per user) so the heavy query can’t be hammered. Every successful export emits an AuditCategory.DataAccess event with action Me.Export, a chain-linked record that the user themselves requested their data. See Auditing for how those events are chained and stored, and Rate limiting for the policy definitions.

How erasure is handled

Deletion runs through DeleteUserCommandHandler in Features/Users/DeleteUser/Handler.cs. It hard-deletes the user’s owned tables with ExecuteDeleteAsync, then anonymises the Identity row in place: the row stays so foreign keys remain valid, but every PII field is cleared.

await db.RefreshTokens.Where(r => r.UserId == appUser.Id).ExecuteDeleteAsync(ct);
await db.ApiKeys.Where(k => k.UserId == appUser.Id).ExecuteDeleteAsync(ct);
await db.Set<PasskeyCredential>().Where(p => p.UserId == appUser.Id).ExecuteDeleteAsync(ct);
await db.UserPermissions.Where(p => p.UserId == appUser.Id).ExecuteDeleteAsync(ct);

// Clear PII on the Identity row, keep the row for referential integrity.
appUser.Email = null;
appUser.NormalizedEmail = null;
appUser.PhoneNumber = null;
appUser.PasswordHash = null;
appUser.SecurityStamp = Guid.NewGuid().ToString();
await userManager.UpdateAsync(appUser);

domainUser.MarkDeleted(command.ActorId, originalEmail);
domainUser.Anonymize();
await db.SaveChangesAsync(ct);

When you add user-owned data, pick one of three strategies for it:

  • Hard delete: add a db.<Set>.Where(x => x.UserId == appUser.Id).ExecuteDeleteAsync(ct). Use for anything with no value once the user is gone. This is the default and matches the existing rows above.
  • Anonymise in place: clear the PII fields, keep the row. Use when a downstream aggregate (the audit trail, analytics) needs referential continuity. This is what the Identity and domain User rows do.
  • Retain unchanged: exempt the entity entirely. Use only when no PII remains on the row once the linked user is anonymised and retention is legally required (for example GDPR Article 7(1), “demonstrate consent”). UserConsent is the precedent: it keeps only ConsentType, Version, and the granted/withdrawn timestamps, non-PII bookkeeping pointed at an already-anonymised user.

Match the handler’s existing style: ExecuteDeleteAsync for owned tables, mutate-then-UpdateAsync for the Identity row.

The guardrail

api/tests/Slicekit.Architecture.Tests/PersonalDataCompletenessTests.cs is the safety net. It enumerates every type under Slicekit.Core.Domain that has a Guid UserId property and asserts each one is referenced by both ExportMyDataQueryHandler (the Article 15 check) and DeleteUserCommandHandler (the Article 17 check). Add a user-owned entity without touching both handlers and the build goes red.

Two exemption lists let you opt out per side, each with an inline justification:

  • ExportExemptions: rare; needs an Article 15 rationale.
  • DeleteExemptions: for entities that must legally be retained (the UserConsent precedent above).

Tests for new data

For every branch you add to either slice, add a feature test:

  • tests/Slicekit.Feature.Tests/Features/Users/ExportMyData/<Thing>ExportTests.cs: assert the data appears in the export and that the secrets-exclusion list is honoured.
  • tests/Slicekit.Feature.Tests/Features/Users/DeleteUser/<Thing>DeletionTests.cs: assert the data is gone or anonymised after the handler runs.

Run them with:

dotnet test api/tests/Slicekit.Feature.Tests --nologo \
  --filter "FullyQualifiedName~ExportMyData|FullyQualifiedName~DeleteUser"
dotnet test api/tests/Slicekit.Architecture.Tests --nologo

Checklist

When a feature touches user data, before you merge:

  1. List every table your feature owns or extends that references a user, directly or via a join.
  2. Add a query to ExportMyDataQueryHandler and a new Exported<Thing> record on ExportMyDataResult.
  3. Pick a deletion strategy (hard delete, anonymise, or retain) and wire it into DeleteUserCommandHandler.
  4. Keep secrets out of the export: no hashes, key material, Identity stamps, or peer identifiers.
  5. Bump SchemaVersion if the export shape breaks consumers, and note it in the PR.
  6. Add the matching ExportMyData and DeleteUser feature tests.
  7. Confirm PersonalDataCompletenessTests passes, or add a justified exemption.