Backend guides
Data export & GDPR
Export a user's personal data and handle deletion, the GDPR-oriented personal-data tooling.
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/exportreturns everything the system holds about the caller as a single JSON document. The slice lives inapi/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:
| Field | Why |
|---|---|
Password hash (ApplicationUser.PasswordHash) | Excluded by Identity convention; do not re-add. |
| Refresh-token hashes | A hash is useless to the user and useful to an attacker. |
API-key material beyond KeyHint | Export the hint and metadata only, never the hash or plaintext. |
SecurityStamp / ConcurrencyStamp | Identity-internal columns with no user-facing meaning. |
| Audit chain hashes / sequence pointers | The user’s audit events are exportable; the chain structure isn’t. |
| Other users’ identifiers in shared resources | Filter 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
Userrows 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”).
UserConsentis the precedent: it keeps onlyConsentType,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 (theUserConsentprecedent 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:
- List every table your feature owns or extends that references a user, directly or via a join.
- Add a query to
ExportMyDataQueryHandlerand a newExported<Thing>record onExportMyDataResult. - Pick a deletion strategy (hard delete, anonymise, or retain) and wire it into
DeleteUserCommandHandler. - Keep secrets out of the export: no hashes, key material, Identity stamps, or peer identifiers.
- Bump
SchemaVersionif the export shape breaks consumers, and note it in the PR. - Add the matching
ExportMyDataandDeleteUserfeature tests. - Confirm
PersonalDataCompletenessTestspasses, or add a justified exemption.