Skip to content

[PersonalData]

The [PersonalData] attribute marks a property or public instance field as containing personal data that should be encrypted. It is the single attribute for field-level PII: it works on string members, string collections, and every other serializable type (int, DateOnly, decimal, Guid, enums, records, ...). Tayra dispatches on the member's type to pick the right mechanism.

How it dispatches on member type

When Tayra builds the metadata for a type, each [PersonalData] member is classified by its type:

Member typeMechanismCompanion needed?
stringEncrypted in place - the plaintext is replaced with Base64-encoded AES-256-GCM ciphertext.No
IEnumerable<string> (List<string>, string[], IList<string>, HashSet<string>, ...)Each element is encrypted in place in the collection.No
anything else (int, DateTime, DateOnly, decimal, Guid, enum, TimeSpan, records, ...)The value is JSON-serialized, encrypted, and stored in a companion byte[]? member; the original is zeroed.Yes - a byte[]? companion

A value type cannot hold Base64 ciphertext in place - there is no string to overwrite - so a non-string member's ciphertext goes into a separate companion byte[]?. That split is the only difference, and Tayra infers it from the member type; you do not choose it.

[SerializedPersonalData] is now a deprecated alias

Before 2.4.0 non-string PII used a separate [SerializedPersonalData] attribute. It is now an [Obsolete] alias of [PersonalData] and behaves identically - [PersonalData] on a non-string member does exactly what [SerializedPersonalData] used to. Use [PersonalData] everywhere; the alias remains only so existing code keeps compiling.

Basic Usage (strings)

Apply [PersonalData] to any string property alongside a [DataSubjectId] on the same class:

cs
public class ContactInfo
{
    [DataSubjectId]
    public Guid UserId { get; set; }

    [PersonalData]
    public string FirstName { get; set; } = "";

    [PersonalData]
    public string LastName { get; set; } = "";

    [PersonalData]
    public string PhoneNumber { get; set; } = "";
}
anchor

All three fields (FirstName, LastName, PhoneNumber) will be encrypted using the key derived from UserId. When EncryptAsync is called, each field's plaintext is replaced with Base64-encoded ciphertext in place; DecryptAsync restores the original value, unless the encryption key has been deleted (crypto-shredded), in which case a replacement value is returned.

Non-string members and the companion byte[]?

For a non-string member, add a companion byte[]? member to hold the ciphertext. The companion follows the naming convention {MemberName}Encrypted:

csharp
public class EmployeeRecord
{
    [DataSubjectId]
    public Guid EmployeeId { get; set; }

    [PersonalData]
    public string Name { get; set; } = "";   // string: encrypted in place

    [PersonalData]
    public DateTime DateOfBirth { get; set; }
    public byte[]? DateOfBirthEncrypted { get; set; }   // companion for DateOfBirth

    [PersonalData]
    public int SocialSecurityNumber { get; set; }
    public byte[]? SocialSecurityNumberEncrypted { get; set; }   // companion for SocialSecurityNumber
}
Source memberCompanion member
DateOfBirthDateOfBirthEncrypted
SocialSecurityNumberSocialSecurityNumberEncrypted

During EncryptAsync, DateOfBirth is serialized to UTF-8 JSON, encrypted, and stored in DateOfBirthEncrypted; the original property is left at its type default. During DecryptAsync, the bytes are decrypted, deserialized, and written back to DateOfBirth, and the companion is set to null.

Custom companion member name

If your companion member does not follow the default {MemberName}Encrypted convention, set EncryptedFieldName:

csharp
public class LegacyRecord
{
    [DataSubjectId]
    public Guid Id { get; set; }

    [PersonalData(EncryptedFieldName = "CustomBytes")]
    public DateTime HireDate { get; set; }
    public byte[]? CustomBytes { get; set; }   // named companion
}

Here the encrypted bytes for HireDate are stored in CustomBytes instead of the default HireDateEncrypted.

Companion member convention

The companion member must be:

  • A public instance property or field.
  • Of type byte[]? (nullable byte array).
  • On the same class as the [PersonalData] member.
  • Writable - a settable property (init-only is fine) or a non-readonly, non-const field. Both the source member and the companion must be settable.

The companion is mandatory

A value type cannot hold ciphertext in place, so without a companion there is nowhere to put it. If the companion cannot be found, EncryptAsync and DecryptAsync throw an InvalidOperationException naming the member, and analyzer TAYRA013 reports it as a compile-time error.

Before 2.4.0 a missing companion only logged a warning and skipped encryption, which left the value in plaintext and persisted it. If you upgrade and hit TAYRA013 or this exception, that field was never being encrypted: treat the data already in your database as compromised plaintext.

Database Mapping

When using Entity Framework Core or Marten, make sure the companion byte[]? member is mapped to a column in your database. The original typed member may also need to be mapped if you want it available after decryption. Some developers mark the original as a computed/transient member and only persist the encrypted bytes.

Supported types for non-string members

Any type System.Text.Json can serialize is supported. That covers every primitive (int, short, byte, long, uint, ulong, float, double, decimal, bool, char), DateTime, DateTimeOffset, DateOnly, TimeOnly, TimeSpan, Guid, Uri, enums (any backing integral type), their Nullable<T> forms, and complex types such as records, classes, and collections.

DateTime round-trips with its Kind preserved, and DateTimeOffset keeps its offset.

For an exotic type that System.Text.Json cannot handle, encryption fails fast with an InvalidOperationException naming the member; register a custom JsonConverter for it or store a simpler representation.

Tayra also supports string collections - any IEnumerable<string> member is detected, including List<string>, string[], IList<string>, and HashSet<string>. Each element is encrypted in place (no companion needed). The runtime collection instance must be mutable; a read-only collection (e.g. ImmutableList<string>) throws an InvalidOperationException instead of being silently skipped. See Collection Encryption for details.

Replacement values vs. masking

Two independent mechanisms decide what a shredded field returns. They have opposite security profiles, so they are kept separate.

  • Replacement value - a constant written back after crypto-shredding. It is universal (works on every member type) and leaks nothing - the original plaintext is gone.
  • Masking - retains a partial cleartext view (e.g. Ja******, jane@***.***) that survives shredding. It is string-only and a deliberate, permanent partial-cleartext leak: the mask is computed at encrypt time and stored so it can be shown after the plaintext is destroyed.

Replacement values (all types)

Set ReplacementValue to control what DecryptAsync returns after the key is deleted:

cs
public class UserAccount
{
    [DataSubjectId]
    public Guid Id { get; set; }

    [PersonalData(ReplacementValue = "[deleted user]")]
    public string DisplayName { get; set; } = "";

    [PersonalData(ReplacementValue = "deleted@example.com")]
    public string Email { get; set; } = "";

    [PersonalData(ReplacementValue = "000-00-0000")]
    public string TaxId { get; set; } = "";
}
anchor

After crypto-shredding:

  • DisplayName returns "[deleted user]"
  • Email returns "deleted@example.com"
  • TaxId returns "000-00-0000"

If ReplacementValue is not set, the default for a string member is an empty string ("").

For a non-string member the same property works: give the value as a string and Tayra parses it into the member's type (invariant culture; an enum member name or number) once when the type's metadata is first built, so an unparseable value fails fast:

csharp
[PersonalData(ReplacementValue = "-1")]
public int Bsn { get; init; }
public byte[]? BsnEncrypted { get; init; }

If ReplacementValue is not set, a non-string member shreds to its type default (0 for int, default(DateOnly), the enum's 0 member, null for a nullable value type). To change the default for a whole type family instead of per field, register a custom IReplacementValueProvider.

Masking (string members only)

The Masking property retains a partial cleartext view. When set, a masked version of the value is embedded at encrypt time and returned after shredding instead of ReplacementValue:

MaskingStrategies ConstantEffectExample InputExample Output
(not set / null)No masking; uses ReplacementValue--
MaskAfterKeep first N characters, mask the rest"Jane Doe" (N=2)"Ja******"
MaskBeforeKeep last N characters, mask the rest"Jane Doe" (N=3)"*****Doe"
MaskEmailDomainKeep local part, mask domain"jane@example.com""jane@***.***"

Masking is string-only

Masking only makes sense on a string member, and it is a deliberate partial-cleartext leak. Setting Masking on a non-string member is a compile-time error (TAYRA014) and also throws an InvalidOperationException at metadata-build time. Use ReplacementValue for non-string members - it leaks nothing. In the fluent API the WithMask* methods only exist on a string member, so masking a non-string member does not compile at all.

Masking vs. replacement

When Masking is set to a MaskingStrategies constant, the masked value is embedded in the ciphertext at encryption time. After crypto-shredding, the masked value is returned instead of the ReplacementValue string. This lets you retain partial information (e.g. a masked email) while still being GDPR-compliant, at the cost of a permanent partial-cleartext leak.

Groups

The Group property links a [PersonalData] member to a specific [DataSubjectId]. This enables multi-key scenarios where different fields on the same entity use different encryption keys:

cs
public class PatientRecord
{
    [DataSubjectId(Group = "medical")]
    public Guid PatientId { get; set; }

    [PersonalData(Group = "medical")]
    public string Diagnosis { get; set; } = "";

    [PersonalData(Group = "medical")]
    public string Treatment { get; set; } = "";

    /// <summary>
    /// Not in the "medical" group - uses a separate key.
    /// </summary>
    [DataSubjectId]
    public Guid RecordId { get; set; }

    [PersonalData]
    public string PatientName { get; set; } = "";
}
anchor

In this example:

  • Diagnosis and Treatment are encrypted with the key derived from PatientId (group "medical").
  • PatientName is encrypted with the key derived from RecordId (default group).
  • Shredding the "medical" group key destroys only the medical data. The patient's name remains decryptable.

Properties Reference

PropertyTypeDefaultDescription
Groupstring?nullLinks this member to a [DataSubjectId] with the same group name. Members without a group use the default (ungrouped) data subject ID.
ReplacementValuestring?nullValue written back after crypto-shredding. For strings, the value is used as-is (default ""); for non-string members it is parsed into the member's type using the invariant culture (for example "-1" for an int, "1900-01-01" for a DateOnly, or an enum member name/number). An unparseable value fails fast when the type's metadata is first built.
EncryptedFieldNamestring?nullName of the companion byte[]? member for a non-string member. If not set, defaults to {MemberName}Encrypted. Ignored for string members (which encrypt in place).
Maskingstring?nullPartial masking strategy for a string member (using a MaskingStrategies constant). Setting it on a non-string member is an error (TAYRA014).
MaskingParameterint0Mode-specific parameter. For MaskAfter, the number of leading characters to preserve. For MaskBefore, the number of trailing characters.

Members and writability

The attribute can be applied to a property or a public instance field (private and static members are not scanned). Members Tayra writes back to - string members, non-string members, and their companions - must be writable: a settable property (init-only is fine) or a non-readonly, non-const field. A get-only property or a readonly/const field fails with a clear InvalidOperationException at first use.

A field works exactly like a property:

csharp
public class Customer
{
    [DataSubjectId]
    public Guid Id;

    [PersonalData]
    public string Email = "";   // encrypted in place, like a [PersonalData] property
}

required and init-only members

[PersonalData] works on required init-only properties of records. Init-only setters count as writable, so Tayra can encrypt strings in place and, for non-string members, zero the original on encrypt and restore it on decrypt through reflection. The companion byte[]? must also be settable (init-only is fine):

csharp
public sealed record Patient
{
    [DataSubjectId] public required string PatientId { get; init; }

    [PersonalData] public required string Email { get; init; }   // string: in place

    [PersonalData] public required int? Bsn { get; init; }
    public byte[]? BsnEncrypted { get; init; }

    [PersonalData] public required DateOnly DateOfBirth { get; init; }
    public byte[]? DateOfBirthEncrypted { get; init; }

    [PersonalData] public required Gender Gender { get; init; }
    public byte[]? GenderEncrypted { get; init; }
}

Serialization and storage (non-string members)

For a non-string member, encryption moves the value out of the original member and into the companion byte[]?:

  • At rest, the original member holds its type default (0, default(DateOnly), the enum's 0 member, null for a nullable), and the companion holds the ciphertext. Both members are still serialized by Marten / System.Text.Json - the original is not removed from the shape, only zeroed - so make sure the companion byte[]? member exists on every event and read-model shape.
  • After DecryptAsync, the original member holds the plaintext again and the companion is null.

Because the original is only zeroed (never dropped), you do not need upcasters or string migrations to adopt encryption on existing strongly-typed shapes - keep int, DateOnly, and enum members as they are and add the companion byte[]?.

Payload Format

For non-string members, the plaintext inside the ciphertext is UTF-8 JSON, carried in a v4 wire-format payload. Tayra 2.3.x and earlier wrote a hand-rolled binary encoding in a v3 payload, which is why only eleven types were supported.

Existing data keeps working, and there is no migration to run. The decoder is chosen by the version byte stamped on each ciphertext, so a v3 payload written by 2.3.x still decrypts. A field moves to v4 lazily, the next time it happens to be encrypted.

That guarantee is permanent. Tayra no longer writes v3 (BinaryFieldSerializer.Serialize was removed in 2.4.0), but it will always read it: BinaryFieldSerializer.Deserialize is kept indefinitely and is deliberately not deprecated, because deleting it would render already-encrypted PII undecryptable.

Upgrading is one-way

A v4 payload cannot be read by Tayra 2.3.x or earlier. Once a node running 2.4.0 encrypts a non-string field, a node still on 2.3.x cannot decrypt it, so downgrading the library after writing is not supported.

Upgrade the whole fleet together rather than running 2.3.x and 2.4.0 side by side, and take a backup first if you need a rollback path.

This affects non-string members only. [PersonalData] strings and string collections are unchanged, and so are envelope-wrapped DEKs.

See Also