Skip to content

Crypto-Shredding — GDPR Erasure Without Deleting Rows

The problem: GDPR says delete, but you can’t

Section titled “The problem: GDPR says delete, but you can’t”

GDPR Article 17 grants every data subject the right to erasure — their personal data must be irreversibly deleted upon request. Failure to comply costs up to €20 million or 4% of global annual turnover, whichever is higher (Art. 83(5)(b)). Under the Belgian DPA, fines apply even for delayed compliance with an erasure request.

In theory, deletion is simple: DELETE FROM patients WHERE id = @id. In practice, it is anything but.

flowchart LR
    Request["🗑️ Erasure request<br/>GDPR Art. 17"] --> Problem1
    Request --> Problem2
    Request --> Problem3

    Problem1["❌ Audit trail<br/>ISO 27001 requires<br/>immutable records"]
    Problem2["❌ Legal hold<br/>Active litigation or<br/>regulatory investigation"]
    Problem3["❌ Append-only<br/>Event sourcing, WORM,<br/>backups"]

    Problem1 --> Conflict["Conflicting<br/>obligations"]
    Problem2 --> Conflict
    Problem3 --> Conflict
  1. Audit trails break. ISO 27001 (A.8.15) requires an immutable audit trail. The moment you DELETE a row, you destroy evidence of what happened, when, and to whom. A regulator auditing your data processing activities will find gaps where records used to be — which looks worse than the original data retention.

  2. Legal holds conflict. Active litigation, tax audits, or regulatory investigations may legally require you to retain data — even when a GDPR erasure request arrives. Deleting data under legal hold is spoliation of evidence in most EU jurisdictions.

  3. Append-only architectures resist deletion. Event sourcing, WORM (Write Once Read Many) storage, Kafka topics, and database backups cannot physically erase individual records. You would need to rewrite entire log segments or restore-and-filter backups — a multi-day, error-prone operation with no guarantee of completeness.

The core tension: GDPR demands erasure, but other regulations and architectures demand retention. You need to satisfy both at the same time.

The solution: destroy the key, not the data

Section titled “The solution: destroy the key, not the data”

Crypto-shredding resolves this tension with a simple insight: if data is encrypted and you permanently destroy the encryption key, the ciphertext is mathematically equivalent to random noise. The data is still there structurally — row counts, join relationships, audit timestamps all remain intact — but the personal information is irreversibly unreadable. No key, no data.

This is not a creative interpretation of the law. The European Data Protection Board (Guidelines 5/2019), the UK ICO, and the French CNIL all explicitly recognize cryptographic erasure as a valid implementation of the right to be forgotten, provided that:

  • The encryption algorithm is strong (AES-256 qualifies)
  • The key destruction is irreversible (no backup key, no recovery path)
  • The destruction is auditable (you can prove when the key was destroyed)
StrategyData gone?Audit trail intact?Works with backups?Reversible?
Physical deletion (DELETE)YesNo — row is goneNo — backup still has itNo
Soft delete (IsDeleted = true)No — still readable by adminYesYesYes — that’s the problem
Anonymization (replace PII)Depends on qualityYesNo — backup has originalNo
Crypto-shreddingYes — mathematicallyYes — row staysYes — backup ciphertext is uselessNo — key is gone

Crypto-shredding is the only strategy that satisfies all four requirements simultaneously. It is particularly valuable when you need granular erasure — erase one patient’s data without touching any other row in the same table.

The Granit crypto-shredding feature builds on the existing field-level encryption infrastructure. Properties marked with [Encrypted(KeyIsolation = true)] get their own encryption key per entity instance, stored in HashiCorp Vault KV v2. When the DPO triggers erasure, ICryptoShredder.ShredAsync() deletes the key from Vault — one API call, and the data is gone forever.

The key insight: standard [Encrypted] properties share a single key ring across all entities. Destroying that shared key would make every record unreadable — that is catastrophic data loss, not controlled erasure. Per-entity key isolation is what makes selective crypto-shredding possible.

  • DirectoryGranit.Encryption/ IEntityEncryptionKeyStore, ICryptoShredder, ICryptoShreddingAuditRecorder
    • DirectoryGranit.Encryption.EntityFrameworkCore/ [Encrypted(KeyIsolation = true)], isolation interceptors
  • DirectoryGranit.Vault.HashiCorp/ HashiCorpEntityEncryptionKeyStore (Vault KV v2)
  • DirectoryGranit.Privacy/ DeletionAction.CryptoShredding (integration point)
PackageRoleDepends on
Granit.EncryptionIEntityEncryptionKeyStore, ICryptoShredder, DefaultCryptoShredder, ICryptoShreddingAuditRecorderGranit
Granit.Encryption.EntityFrameworkCore[Encrypted(KeyIsolation = true)], EncryptionIsolationSaveChangesInterceptor, EncryptionIsolationMaterializationInterceptorGranit.Encryption, Granit.Persistence
Granit.Vault.HashiCorpHashiCorpEntityEncryptionKeyStore — Vault KV v2 implementation with in-memory cacheGranit.Vault
Granit.PrivacyDeletionAction.CryptoShredding — integration with GDPR erasure workflowGranit

Each entity instance gets its own 32-byte AES-256 key, stored in Vault KV v2. The lifecycle has three phases: key creation (first write), normal operation (reads and updates), and shredding (key destruction).

sequenceDiagram
    participant App as Application
    participant EF as EF Core Interceptor
    participant Store as IEntityEncryptionKeyStore
    participant Vault as Vault KV v2
    participant DB as Database

    Note over App,DB: Write path — new entity with isolated encryption

    App->>EF: SaveChangesAsync (Patient with SSN)
    EF->>Store: GetOrCreateKeyAsync("Patient", "a1b2c3")
    Store->>Vault: PUT secret/granit/encryption/isolated/Patient/a1b2c3
    Vault-->>Store: 32-byte AES-256 key
    Store-->>EF: Key bytes
    EF->>EF: AES-256-CBC + HMAC-SHA256 encrypt SSN
    EF->>DB: INSERT (ciphertext)

    Note over App,DB: Crypto-shredding — destroy the key

    App->>App: ICryptoShredder.ShredAsync("Patient", "a1b2c3")
    App->>Store: DeleteKeyAsync("Patient", "a1b2c3")
    Store->>Vault: DELETE secret/granit/encryption/isolated/Patient/a1b2c3
    Vault-->>Store: 204 No Content
    Note over DB: Row intact — ciphertext permanently unreadable

After shredding, reading the entity returns null for isolated encrypted properties. The row stays in the database — audit queries, join counts, and aggregations continue to work. Only the PII is gone.

  1. Add the NuGet packages

    Terminal window
    dotnet add package Granit.Encryption.EntityFrameworkCore
    dotnet add package Granit.Vault.HashiCorp
  2. Register the modules

    [DependsOn(
    typeof(GranitEncryptionEntityFrameworkCoreModule),
    typeof(GranitVaultHashiCorpModule))]
    public sealed class AppModule : GranitModule { }

    GranitVaultHashiCorpModule automatically registers HashiCorpEntityEncryptionKeyStore as IEntityEncryptionKeyStore. GranitEncryptionEntityFrameworkCoreModule registers the isolation interceptors.

  3. Configure Vault KV

    {
    "Vault": {
    "Address": "https://vault.example.com",
    "AuthMethod": "Kubernetes",
    "KvMountPoint": "secret",
    "TransitMountPoint": "transit"
    }
    }

    The KvMountPoint setting controls which Vault KV v2 engine stores per-entity keys. Default: "secret".

  4. Wire interceptors in your DbContext

    The encryption isolation interceptors must run after UseGranitInterceptors (which assigns entity IDs via AuditedEntityInterceptor):

    using Granit.Encryption.EntityFrameworkCore.Extensions;
    using Granit.Persistence.Extensions;
    builder.Services.AddDbContextFactory<AppDbContext>((sp, options) =>
    {
    options.UseNpgsql(connectionString);
    options.UseGranitInterceptors(sp);
    options.UseGranitEncryptionInterceptors(sp); // must be after UseGranitInterceptors
    }, ServiceLifetime.Scoped);

Add [Encrypted(KeyIsolation = true)] to properties that must support crypto-shredding. Non-isolated [Encrypted] properties continue to use the shared key ring:

using Granit.Encryption.EntityFrameworkCore;
namespace MyApp.Domain;
public sealed class Patient : AuditedAggregateRoot
{
public string LastName { get; private set; } = string.Empty;
[Encrypted]
public string NationalId { get; private set; } = string.Empty; // shared key ring
[Encrypted(KeyIsolation = true)]
public string MedicalNotes { get; private set; } = string.Empty; // per-entity key
[Encrypted(KeyIsolation = true)]
public string? DiagnosisCode { get; private set; } // per-entity key
}

In this example, NationalId uses shared encryption (the same key encrypts all patients’ national IDs). MedicalNotes and DiagnosisCode use isolated keys — when patient “abc-123” is shredded, only their medical notes and diagnosis code become unreadable. Other patients are unaffected.

The ApplyEncryptionConventions method handles both encryption modes automatically:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyGranitConventions(currentTenant, dataFilter);
modelBuilder.ApplyEncryptionConventions(encryptionService);
}

Internally, ApplyEncryptionConventions:

  • Applies EncryptedStringConverter to [Encrypted] properties (shared key)
  • Skips the converter for [Encrypted(KeyIsolation = true)] properties and annotates them with "Granit:EncryptionIsolated" — these are handled by the interceptors instead

No code changes in OnModelCreating are needed to support key isolation.

Inject ICryptoShredder and call ShredAsync:

public sealed class PatientDeletionHandler(ICryptoShredder shredder)
{
public async Task HandleAsync(
PersonalDataDeletionRequestedEto request,
AppDbContext db,
CancellationToken cancellationToken)
{
// Find all patient records for this user
List<Guid> patientIds = await db.Patients
.Where(p => p.CreatedBy == request.UserId.ToString())
.Select(p => p.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
// Shred each patient's encryption keys
foreach (var id in patientIds)
{
await shredder.ShredAsync("Patient", id.ToString(), cancellationToken)
.ConfigureAwait(false);
}
}
}

For bulk operations, use ShredBatchAsync:

await shredder.ShredBatchAsync(
"Patient",
patientIds.Select(id => id.ToString()),
cancellationToken).ConfigureAwait(false);

When you read a shredded entity, the materialization interceptor detects that the key is gone and clears the isolated properties:

var patient = await db.Patients.FindAsync([id], cancellationToken);
patient.MedicalNotes; // null (was non-nullable string — now cleared)
patient.DiagnosisCode; // null (nullable string)
patient.NationalId; // still readable — uses shared key, not shredded
patient.LastName; // still readable — not encrypted at all

The Privacy module supports two erasure flows: immediate (the application calls ICryptoShredder directly) and deferred (a cooling-off saga delays destruction to give the user time to cancel). Both flows end the same way — per-entity keys are destroyed and DeletionAction.CryptoShredding is reported.

stateDiagram-v2
    [*] --> Choice: Erasure request (GDPR Art. 17)

    state Choice <<choice>>
    Choice --> Direct: No cooling-off
    Choice --> Waiting: With cooling-off (recommended)

    Direct --> Shredding: ICryptoShredder.ShredAsync

    state "Deferred flow (GdprDeletionSaga)" as DeferredFlow {
        Waiting --> Reminder: T minus 3 days
        Reminder --> Deadline: T (30 days)
        Waiting --> Cancelled: User cancels
    }

    Deadline --> Shredding: PersonalDataDeletionRequestedEto
    Cancelled --> DataRetained: Data intact

    Shredding --> AuditTrail: ICryptoShreddingAuditRecorder
    AuditTrail --> DataUnreadable: Keys destroyed, ciphertext remains

Immediate flow — the application calls ICryptoShredder directly, for example from an admin endpoint or a background job. Use this when no cooling-off period is required (e.g., internal data cleanup, test data, or when the DPO has already validated the request).

Deferred flow — the recommended approach for end-user erasure requests. GdprDeletionSaga enforces a configurable cooling-off period (default: 30 days, max: 90 days per CNIL guidance). The user can cancel during this period. When the deadline expires, the saga publishes PersonalDataDeletionRequestedEto — your data provider handler catches it and calls ICryptoShredder.

Report the action back using DeletionAction.CryptoShredding:

public sealed class PatientDataProvider(ICryptoShredder shredder)
{
// Wolverine handler for PersonalDataDeletionRequestedEto
public async Task<PersonalDataDeletedEto> HandleAsync(
PersonalDataDeletionRequestedEto request,
AppDbContext db,
CancellationToken cancellationToken)
{
List<string> entityIds = await db.Patients
.Where(p => p.CreatedBy == request.UserId.ToString())
.Select(p => p.Id.ToString())
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
await shredder.ShredBatchAsync("Patient", entityIds, cancellationToken)
.ConfigureAwait(false);
return new PersonalDataDeletedEto(
request.RequestId,
"PatientModule",
DeletionAction.CryptoShredding,
entityIds.Count,
DateTimeOffset.UtcNow);
}
}

Every crypto-shredding operation should be recorded in an immutable audit trail (ISO 27001 A.10.1.2 — key destruction traceability). DefaultCryptoShredder automatically calls all registered ICryptoShreddingAuditRecorder implementations after each successful key deletion.

To record shredding events in the Timeline module:

using Granit.Encryption.CryptoShredding;
using Granit.Timeline.Abstractions;
using Granit.Timeline.Domain;
public sealed class TimelineCryptoShreddingRecorder(
ITimelineWriter writer) : ICryptoShreddingAuditRecorder
{
public async Task RecordAsync(
string entityType,
string entityId,
DateTimeOffset shreddedAt,
CancellationToken cancellationToken = default)
{
string body = $"""
**Encryption key destroyed** (crypto-shredding)
- Entity: `{entityType}/{entityId}`
- Timestamp: {shreddedAt:O}
- Action: Per-entity AES-256 key permanently deleted from Vault KV
- Effect: All `[Encrypted(KeyIsolation = true)]` fields are permanently unreadable
""";
await writer.PostEntryAsync(
entityType,
entityId,
TimelineEntryType.SystemLog, // immutable — cannot be deleted
body,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
}

Register it in DI:

builder.Services.AddScoped<ICryptoShreddingAuditRecorder, TimelineCryptoShreddingRecorder>();

EncryptionMetrics (meter: Granit.Encryption) exposes three counters:

MetricDescription
granit.encryption.key.createdPer-entity key generated in Vault KV
granit.encryption.key.shreddedPer-entity key permanently destroyed
granit.encryption.shredding.errorsShredding operations that failed

All metrics include tenant_id and entity_type tags.

EncryptionActivitySource (source: Granit.Encryption) records spans for: encryption.key-create, encryption.key-retrieve, encryption.key-delete, encryption.shred, encryption.shred-batch.

[AttributeUsage(AttributeTargets.Property)]
public sealed class EncryptedAttribute : Attribute
{
public bool KeyIsolation { get; set; }
}

When KeyIsolation is true, each entity instance gets its own AES-256 key stored in Vault KV. Default: false (shared key ring).

public interface IEntityEncryptionKeyStore
{
Task<byte[]> GetOrCreateKeyAsync(string entityType, string entityId, CancellationToken ct = default);
Task<byte[]?> GetKeyAsync(string entityType, string entityId, CancellationToken ct = default);
Task DeleteKeyAsync(string entityType, string entityId, CancellationToken ct = default);
Task<bool> KeyExistsAsync(string entityType, string entityId, CancellationToken ct = default);
}

Provider-agnostic abstraction. Implemented by HashiCorpEntityEncryptionKeyStore (Vault KV v2, path: granit/encryption/isolated/{entityType}/{entityId}).

public interface ICryptoShredder
{
Task ShredAsync(string entityType, string entityId, CancellationToken ct = default);
Task ShredBatchAsync(string entityType, IEnumerable<string> entityIds, CancellationToken ct = default);
}

Permanently destroys per-entity encryption keys. DefaultCryptoShredder delegates to IEntityEncryptionKeyStore.DeleteKeyAsync and calls all registered ICryptoShreddingAuditRecorder instances.

public interface ICryptoShreddingAuditRecorder
{
Task RecordAsync(string entityType, string entityId, DateTimeOffset shreddedAt, CancellationToken ct = default);
}

Extension point for recording shredding events. Register implementations via DI. DefaultCryptoShredder iterates all registered recorders after each successful key deletion.

public static DbContextOptionsBuilder UseGranitEncryptionInterceptors(
this DbContextOptionsBuilder options,
IServiceProvider serviceProvider);

Adds EncryptionIsolationSaveChangesInterceptor and EncryptionIsolationMaterializationInterceptor if IEntityEncryptionKeyStore is registered. Must be called after UseGranitInterceptors.

Per-entity keys are stored in Vault KV v2 with this path layout:

secret/
granit/
encryption/
isolated/
Patient/
a1b2c3d4-e5f6-... → { "key": "base64-encoded-aes-256-key" }
f7g8h9i0-j1k2-... → { "key": "base64-encoded-aes-256-key" }
Invoice/
x9y8z7w6-v5u4-... → { "key": "base64-encoded-aes-256-key" }

DeleteKeyAsync calls DeleteMetadataAsync — permanently destroying all versions of the secret with no recovery path. The in-memory cache is evicted on deletion to prevent stale reads.

RegulationRequirementCoverage
GDPR Art. 17Right to erasureKey destruction makes ciphertext permanently unreadable
GDPR Art. 5(1)(e)Storage limitationData is cryptographically erased without physical deletion
ISO 27001 A.10.1.2Key destructionAudit trail via ICryptoShreddingAuditRecorder + TimelineEntryType.SystemLog
ISO 27001 A.8.10Media sanitizationCiphertext remains but is mathematically equivalent to random noise

Use crypto-shredding when:

  • You have append-only or immutable storage (event sourcing, WORM, immutable backups) where physical deletion is impossible
  • You need to satisfy concurrent obligations: erasure and audit trail retention
  • Legal holds may prevent row deletion, but GDPR requires data to be unreadable
  • You need granular erasure — destroy one patient’s data without affecting others in the same table

Skip it when:

  • Physical deletion is acceptable and sufficient for your compliance requirements — it is simpler
  • The data is not PII — crypto-shredding adds Vault KV overhead per entity instance
  • You can use anonymization instead — replacing PII with pseudonymized values is cheaper and reversible
  • Your application has very high read throughput on encrypted fields — the materialization interceptor adds per-entity Vault lookups (mitigated by caching, but still more overhead than shared encryption)
[Fact]
public async Task Shredded_entity_returns_null_for_isolated_properties()
{
// Arrange
var keyStore = Substitute.For<IEntityEncryptionKeyStore>();
var key = RandomNumberGenerator.GetBytes(32);
keyStore.GetOrCreateKeyAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(key);
keyStore.GetKeyAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(key);
await using var context = CreateSqliteContext(keyStore);
var patientId = Guid.NewGuid();
// Act — save with encryption
context.Patients.Add(new Patient { Id = patientId, MedicalNotes = "Confidential diagnosis" });
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
// Act — simulate crypto-shredding (key store returns null)
keyStore.GetKeyAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((byte[]?)null);
// Assert — isolated property is cleared
var loaded = await context.Patients.FindAsync([patientId], TestContext.Current.CancellationToken);
loaded!.MedicalNotes.ShouldBeNull();
}