Marten
Tayra integrates with Marten by wrapping the configured serializer with TayraSerializer. This provides transparent encryption and decryption of [PersonalData] fields in both documents and events stored in PostgreSQL's JSONB columns.
Prerequisites
Tayra ships a package per Marten major (pick by your Marten version), both requiring PostgreSQL 13+:
Tayra.Marten9- Marten 9.x, .NET 9+.Tayra.Marten8- Marten 8.x, .NET 8+.
The framework major is in the package name; the version number is Tayra's own. See Installation.
Install
Install the package that matches your Marten major:
dotnet add package Tayra.Marten9dotnet add package Tayra.Marten8Either package exposes the same Tayra.Marten namespace and API, so the code below is identical regardless of which you install.
Setup
Call UseTayra() on Marten's StoreOptions to enable PII encryption. This wraps Marten's default serializer with TayraSerializer:
// Configure Marten with Tayra encryption.
// UseTayra() wraps the serializer so PII fields are encrypted in JSONB storage.
var connectionString = "Host=localhost;Port=5432;Database=tayra_sample;Username=postgres;Password=postgres";
services.AddMarten(opts =>
{
opts.Connection(connectionString);
// Enable Tayra encryption for documents and events
opts.UseTayra(tayra);
});How TayraSerializer Works
TayraSerializer is a decorator around Marten's existing ISerializer. When serializing (e.g., ToJson()), it encrypts [PersonalData] fields before passing the object to the inner serializer. When deserializing (e.g., FromJson<T>()), it decrypts the fields after the inner serializer reconstructs the object. All non-PII fields pass through untouched.
Custom serializers
Because TayraSerializer is a decorator, a custom serializer is fully supported - Tayra wraps yours and delegates all serialization (naming policy, converters, enum storage, casing) to it, adding only the encrypt/decrypt of [PersonalData]. This works for opts.Serializer(new MyCustom()), opts.UseSystemTextJsonForSerialization(...), opts.UseNewtonsoftForSerialization(...), or any third-party ISerializer.
The only rule is that Tayra must wrap last:
- DI registration (
services.UseTayra()) - recommended. Tayra runs after your entireAddMarten(opts => …)lambda, so it wraps whatever serializer you configured, wherever you set it. opts.UseTayra(tayra)inside the lambda. This wraps the serializer set at that moment. Callingopts.Serializer(...)(orUseSystemTextJsonForSerialization, etc.) afteropts.UseTayra(tayra)overwrites the wrapper - callUseTayralast.
Encryption-off is a startup failure, not a silent one
If a custom serializer (or another library's IConfigureMarten registered after Tayra) replaces the wrapper so the store's final serializer is not a TayraSerializer, the DI registration's startup guard throws at host startup rather than letting PII write in cleartext. If you hit this, ensure UseTayra() is applied last.
How It Works
Tayra wraps Marten's ISerializer to transparently encrypt and decrypt PII fields during serialization. Encryption is selective by type - only entities with [PersonalData] annotations are encrypted. Types without PII attributes pass through unmodified.
Documents
Annotate your Marten document classes with [DataSubjectId] and [PersonalData]:
public class CustomerDocument
{
public Guid Id { get; set; }
[DataSubjectId]
public string SubjectId { get; set; } = "";
[PersonalData]
public string Name { get; set; } = "";
[PersonalData(MaskValue = "redacted@example.com")]
public string Email { get; set; } = "";
/// <summary>
/// Not annotated - stored as plaintext in JSONB.
/// </summary>
public string AccountType { get; set; } = "";
}[DataSubjectId]onSubjectIdidentifies the data owner.[PersonalData]onNameandEmailmarks them for encryption.AccountTypeis not annotated and stored as plaintext in JSONB.
When you store this document with session.Store(customer), the Name and Email values in the JSONB column will contain AES-256-GCM ciphertext. When you load it with session.Load<CustomerDocument>(id), the fields are decrypted back to plaintext automatically.
Events
Event payloads are encrypted the same way as documents:
public class CustomerRegisteredEvent
{
[DataSubjectId]
public string SubjectId { get; set; } = "";
[PersonalData]
public string CustomerName { get; set; } = "";
[PersonalData(MaskValue = "redacted@example.com")]
public string CustomerEmail { get; set; } = "";
public DateTime RegisteredAt { get; set; }
}When you append this event to a stream, the CustomerName and CustomerEmail values are encrypted in the event store. Events are immutable in Marten, so the encrypted values are permanent - which is exactly what makes crypto-shredding work.
Event Sourcing and GDPR
Crypto-shredding is the recommended approach for GDPR compliance in event-sourced systems. Instead of rewriting event history (which violates event sourcing principles), you delete the encryption key. The events remain intact, but the PII fields become permanently unreadable.
Crypto-Shredding
Delete a data subject's encryption keys to make all their PII permanently unreadable:
// GDPR "right to be forgotten" - destroy encryption keys for a data subject.
// After this, any attempt to decrypt the subject's data returns replacement values.
//
// Usage with Marten IDocumentSession:
// await session.ShredDataSubjectAsync(cryptoEngine, subjectId);
// await session.SaveChangesAsync();
// Example (standalone, without a live Marten session):
var subjectId = "cust-42";
await cryptoEngine.DeleteAllKeysAsync(subjectId);
Console.WriteLine($"Crypto-shredded all keys for subject '{subjectId}'.");The ShredDataSubjectAsync extension method on IDocumentSession calls cryptoEngine.DeleteAllKeysAsync() to destroy all encryption keys for the specified subject.
Check if Shredded
Verify whether a data subject has been crypto-shredded:
// Check if a data subject's keys have been shredded.
//
// Usage with Marten IDocumentSession:
// bool shredded = await session.IsDataSubjectShreddedAsync(cryptoEngine, subjectId);
//
// Standalone equivalent:
var keyExists = await cryptoEngine.KeyExistsAsync(subjectId);
var isShredded = !keyExists;
Console.WriteLine($"Subject '{subjectId}' is shredded: {isShredded}");The IsDataSubjectShreddedAsync extension checks whether the subject's encryption key still exists in the key store. If the key is gone, the subject has been shredded.
Projections
Projections work with no extra configuration. Once UseTayra() is registered, every event is deserialized through TayraSerializer, so [PersonalData] fields are already decrypted by the time they reach your projection. Register projections exactly as you would in plain Marten:
// Projections need no Tayra-specific wiring. Because UseTayra() wraps the
// serializer, every event is decrypted on read before it reaches your
// projection — inline, live aggregation, and the async daemon alike.
// Register projections exactly as in plain Marten:
services.AddMarten(opts =>
{
opts.Connection(connectionString);
opts.UseTayra(tayra);
// Standard Marten registration — events arrive decrypted in Apply/ApplyAsync,
// and any [PersonalData] on the projected document is re-encrypted at rest.
// opts.Projections.Add(myProjection, ProjectionLifecycle.Inline);
});
Console.WriteLine("\nProjections require no special API: the serializer decrypts events on every read path.");
Console.WriteLine("[PersonalData] on projected read models is re-encrypted at rest automatically.");Why No Special API Is Needed
TayraSerializer is the single seam through which Marten deserializes event data (ISerializer.FromJson). Marten uses that same seam for inline projections, live aggregations (AggregateStreamAsync, FetchForWriting), and the async projection daemon alike. Your Apply/ApplyAsync handlers therefore receive cleartext values and never need to know about encryption.
If a projection writes a document with its own [PersonalData] fields (a read model), those fields are re-encrypted at rest automatically, because the projected document is written back through TayraSerializer as JSONB.
Binary events Tayra.Marten9
Marten 9 can serialize events to mt_events.bdata via a separate IEventBinarySerializer ([BinaryEvent], opts.Events.UseBinarySerializer<T>(), or a store-wide opts.Events.DefaultBinarySerializer). That path bypasses the document serializer, so Tayra protects it with a binary-format counterpart, TayraBinaryEventSerializer:
- Store-wide default - set
opts.Events.DefaultBinarySerializerbeforeUseTayra()and Tayra wraps it automatically; every[BinaryEvent]is then protected. - Per event type - wrap explicitly:
opts.Events.UseBinarySerializer<MyEvent>(new TayraBinaryEventSerializer(inner, tayra)).
PII fields are encrypted inside the bdata blob exactly as they are in JSONB, and crypto-shredding works unchanged.
Binary PII is guarded, not silent
If an event type carries [PersonalData] and uses a binary serializer that is not a TayraBinaryEventSerializer, UseTayra() throws at startup rather than writing cleartext to mt_events.bdata. The TAYRA011 analyzer also flags [PersonalData] on a [BinaryEvent] type at compile time. To opt a binary event out of encryption, remove the [PersonalData] annotation.
Flat-table projections
FlatTableProjection writes event member values straight into SQL columns via a generated upsert function. That path never passes through TayraSerializer, and StatementMap<T>.Map(...) only accepts a member expression (x => x.Email), so there is no seam to encrypt a flat-table column - and encrypting one would defeat its purpose, since flat tables exist to be queried in SQL.
There is no such thing as a flat column that is both the readable PII value and protected at rest. The choices are mutually exclusive:
| Column holds | Readable value? | SQL-queryable? | Protected at rest? |
|---|---|---|---|
The PII member (x.Email) | yes | yes | no - cleartext |
A blind-index companion (x.EmailIndex) | no (one-way HMAC) | equality only | yes |
| A Tayra-encrypted blob | no (until app-side decrypt) | no | yes |
So the strategy is to keep personal data out of flat tables entirely:
- The flat table carries non-PII columns plus blind-index (HMAC) companions for equality search. Map
x => x.EmailIndex, neverx => x.Email. The hash gives you search without the value - it is not a protected copy of the email. - The actual PII value stays in the encrypted JSONB document projection. Look it up by id (optionally found via the flat table's blind-index column) when you need the value.
Flat-table PII is not protected
Mapping a [PersonalData] member into a flat-table column stores it in cleartext, and Tayra cannot prevent this at runtime - there is no encryption seam for flat tables. The TAYRA007 analyzer flags this at compile time. Map a blind-index companion instead, or keep the data in the encrypted JSONB projection.
Duplicated fields
Marten's duplicated fields copy a document member into its own indexed relational column. Marten populates that column by reading the value off your .NET object, outside TayraSerializer, so:
- Never duplicate a
[PersonalData]member. Because Tayra encrypts the field in place during serialization, the duplicated column ends up holding non-deterministic ciphertext - unqueryable (the whole point of duplication) and not decryptable on read. TheTAYRA008analyzer flags[DuplicateField]andDuplicate(x => x.PiiMember)on a personal-data member. - Duplicate the blind-index companion instead. The companion (
EmailIndex) is a deterministic HMAC that Tayra writes onto your object before Marten reads it, so the duplicated column is correctly populated and fully queryable:
public class Customer
{
[DataSubjectId] public string Id { get; set; }
[PersonalData, BlindIndex] public string Email { get; set; }
public string EmailIndex { get; set; } = ""; // companion, Tayra fills it
}
opts.Schema.For<Customer>().Duplicate(x => x.EmailIndex); // ✅ indexed, queryable, no plaintextA Where(x => x.EmailIndex == hash) then hits the indexed column while the email stays encrypted in the JSONB.
Index vs Duplicate
If you only need query speed (not a materialized relational column for joins/external SQL), you don't need a duplicated field at all - the HMAC is already in the JSONB, so opts.Schema.For<Customer>().Index(x => x.EmailIndex) indexes data->>'EmailIndex' directly.
Shredded Events in Projections
If a data subject has been crypto-shredded, their events will contain replacement values instead of the original data. Your projections should handle these gracefully - for example, by checking for known replacement values or empty strings.
Blind Indexes
Tayra's blind index support works transparently with Marten. Blind index services are registered automatically by AddTayra() when [BlindIndex] attributes are present, so TayraSerializer automatically computes HMAC blind indexes on companion properties before encrypting PII fields during serialization.
Define a model with [BlindIndex] on encrypted fields:
public class CustomerDocument
{
public Guid Id { get; set; }
[DataSubjectId]
public string SubjectId { get; set; } = "";
[PersonalData, BlindIndex(Transforms = ["lowercase", "trim"])]
public string Email { get; set; } = "";
public string? EmailIndex { get; set; } // auto-populated
}Query by blind index using LINQ:
var hash = await blindIndexer.ComputeHashAsync(
"alice@example.com", "EmailIndex", typeof(CustomerDocument));
var customer = await session.Query<CustomerDocument>()
.Where(c => c.EmailIndex == hash)
.FirstOrDefaultAsync();For efficient queries, add a Marten computed index on the companion property:
opts.Schema.For<CustomerDocument>().Index(x => x.EmailIndex);See Blind Indexes for the full guide including transforms, compound indexes, and security considerations.
Data Migration
If you're adding Tayra to an existing application with pre-existing cleartext documents, use the Marten migration service to bulk-encrypt them. See the Brownfield Adoption guide for step-by-step instructions.
Register the migration service and set up a raw store:
// Register Tayra Marten migration services (requires AddTayra() first)
var migrationServices = new ServiceCollection();
migrationServices.AddTayra(opts => opts.LicenseKey = licenseKey);
migrationServices.AddTayraMartenMigrations();
var migrationProvider = migrationServices.BuildServiceProvider();// Create a "raw" store WITHOUT UseTayra() - reads cleartext field values as-is.
// This is required so the migration service can detect which documents need encrypting.
var rawStore = DocumentStore.For(opts =>
{
opts.Connection(connectionString);
// Do NOT call opts.UseTayra() here - we need raw access to read cleartext
});Then encrypt existing documents and verify:
// Bulk-encrypt existing cleartext documents in batches
var migrationService = migrationProvider.GetRequiredService<ITayraMartenMigrationService>();
var result = await migrationService.EncryptExistingDocumentsAsync<CustomerDocument>(
rawStore,
batchSize: 100);
Console.WriteLine($"\n=== Marten Migration Result ===");
Console.WriteLine($" Scanned: {result.TotalScanned}");
Console.WriteLine($" Encrypted: {result.Encrypted}");
Console.WriteLine($" Skipped: {result.Skipped}");
Console.WriteLine($" Errors: {result.Errors}");
Console.WriteLine($" Duration: {result.Duration.TotalMilliseconds:F0}ms");// Verify all documents are now properly encrypted
var verification = await migrationService.VerifyDocumentEncryptionAsync<CustomerDocument>(
rawStore,
batchSize: 100);
Console.WriteLine($"\n=== Marten Verification Result ===");
Console.WriteLine($" Verified: {verification.TotalVerified}");
Console.WriteLine($" Valid: {verification.Valid}");
Console.WriteLine($" Invalid: {verification.Invalid}");
Console.WriteLine($" Duration: {verification.Duration.TotalMilliseconds:F0}ms");
foreach (var invalid in verification.InvalidRows)
{
Console.WriteLine($" [INVALID] Document {invalid.EntityId}, Property: {invalid.PropertyName} - {invalid.Reason}");
}See Also
- Marten Coverage Reference - What's protected at every Marten surface, and at what level
- Getting Started - End-to-end encryption tutorial
- Wolverine Integration - Message pipeline encryption
- EF Core Integration - Entity Framework Core integration
- Key Stores - Production key store options
