Skip to content

Querying with Blind Indexes

A blind index is only useful if you can efficiently query by it. This page covers how to compute a blind index for a search term and use it in EF Core and Marten queries.

Computing a Blind Index

Use ITayra.ComputeBlindIndexAsync() to compute a blind index hash for a search term:

By property expression (recommended):

cs
// Compute a blind index hash using a strongly-typed property expression
string emailHash = await biTayra.ComputeBlindIndexAsync(
    "jane@example.com", "EmailHash", typeof(BlindIndexedCustomer));
Console.WriteLine($"  Typed hash: {emailHash}");
anchor

By hash name directly:

cs
// Compute a blind index hash by index name and entity type
string hash = await biTayra.ComputeBlindIndexAsync(
    "jane@example.com", "EmailHash", typeof(BlindIndexedCustomer));
Console.WriteLine($"  Raw hash: {hash}");
anchor

Both methods apply the configured transforms in order before computing the HMAC. The resulting hash string is URL-safe Base64.

EF Core

Save

When using the EF Core interceptor, the blind index is computed automatically during SaveChanges. You do not need to call EncryptAsync manually if transparent encryption is enabled.

cs
// With transparent encryption enabled, blind indexes are computed automatically
// during SaveChanges — no manual call to EncryptAsync is required.
//
// var customer = new ProtectedCustomer
// {
//     Id = Guid.NewGuid(),
//     Name = "Jane Doe",
//     Email = "jane@example.com",
// };
// dbContext.Customers.Add(customer);
// await dbContext.SaveChangesAsync();
// // customer.EmailHash is now populated with the HMAC
Console.WriteLine("  Blind index is computed automatically during SaveChanges.");
anchor

Query by Blind Index and Decrypt

cs
// Compute the hash for the search term
string searchHash = await blindIndexer.ComputeHashAsync(
    "jane@example.com", "EmailHash", typeof(ProtectedCustomer));

// Query by the companion column
// var customer = await dbContext.Customers
//     .Where(c => c.EmailHash == searchHash)
//     .FirstOrDefaultAsync();

// The interceptor decrypts automatically on materialization
// Console.WriteLine($"Found: {customer?.Name}");
Console.WriteLine($"  EF Core search hash: {searchHash}");
anchor

Use AsNoTracking for read-only queries

When you only need to read and display data, AsNoTracking() avoids the overhead of change tracking and is safe to combine with Tayra's materialization interceptor.

Add a Database Index

Blind index queries are only efficient if the database has an index on the companion column. Add one in your migration or OnModelCreating:

cs
// In your DbContext.OnModelCreating, add a database index on the companion column
// so blind-index queries are efficient (avoids full table scans):
modelBuilder.Entity<ProtectedCustomer>()
    .HasIndex(c => c.EmailHash);
anchor

Marten

Save

Marten stores documents as JSONB. The companion property is included in the document JSON and can be queried with LINQ.

cs
// EncryptAsync computes the blind index before encryption.
// The EmailHash companion property is populated automatically.

// await tayra.EncryptAsync(customer);

// Store the document — EmailHash is included in the JSONB.
// await using var session = store.LightweightSession();
// session.Store(customer);
// await session.SaveChangesAsync();
Console.WriteLine("\nBlind index save: EmailHash is populated before the document is stored.");
anchor

Query by Companion Property

cs
// Compute the search hash for the lookup term (same transforms applied as during save)
// var blindIndexer = provider.GetRequiredService<IBlindIndexer>();
// string searchHash = await blindIndexer.ComputeHashAsync(
//     "jane@example.com", "EmailHash", typeof(MartenCustomer));

// Query by the companion property in the JSONB document
// await using var querySession = store.QuerySession();
// var customer = await querySession.Query<MartenCustomer>()
//     .Where(c => c.EmailHash == searchHash)
//     .FirstOrDefaultAsync();

// Decrypt the loaded document to restore plaintext PII
// await tayra.DecryptAsync(customer);
Console.WriteLine("Blind index query: filter by EmailHash, then decrypt on load.");
anchor

Create a Marten index on the companion property

Use Marten's Index<T>() API in StoreOptions to add a GIN or B-tree index on the companion JSONB field for efficient querying.

Compound Blind Index Queries

When you need to search by multiple encrypted fields simultaneously, compute a hash for each and combine them in a single query:

cs
// For compound indexes, the hash is computed automatically by EncryptAsync
// when you call it on the entity — all fields are combined into one HMAC.
// Retrieve the stored hash from the entity to build your query:

// var entity = new IndexedCustomer { FirstName = "Jane", LastName = "Doe", ... };
// await tayra.EncryptAsync(entity);
// string fullNameHash = entity.FullNameIndex;

// Query the companion column:
// db.Query<IndexedCustomer>().Where(c => c.FullNameIndex == fullNameHash)
Console.WriteLine("  Compound blind index hash computed during EncryptAsync.");
anchor

Compound indexes on multiple companion columns

If you frequently query by two companion columns together, consider creating a composite database index on both columns (FirstNameHash, LastNameHash) to avoid index intersection overhead.

Paginated Lookups

Blind indexes support exact-match lookups only. You cannot perform prefix searches, range queries, or LIKE comparisons on a blind index hash. For paginated result sets, use the blind index to identify matching rows, then apply additional ordering:

cs
// Blind indexes support exact-match lookups only.
// Use the hash to filter, then apply ordering and paging.
string pagedEmailHash = await biTayra.ComputeBlindIndexAsync(
    "jane@example.com", "EmailHash", typeof(BlindIndexedCustomer));

// var results = await dbContext.Customers
//     .Where(c => c.EmailHash == pagedEmailHash)
//     .OrderBy(c => c.Id)
//     .Skip(page * pageSize)
//     .Take(pageSize)
//     .ToListAsync();
Console.WriteLine($"  Paginated hash computed: {pagedEmailHash}");
anchor

Performance Notes

  • HMAC computation is fast. HMAC-SHA256 on a short string is sub-microsecond on modern hardware. It does not meaningfully impact write throughput.
  • The HMAC key is cached. After the first call, the key is held in the DefaultCryptoEngine memory cache. Subsequent calls to ComputeBlindIndexAsync are memory-only operations.
  • Add a database index. Without an index on the companion column, every query becomes a full table scan. This is by far the biggest performance factor.
  • Companion columns add storage. A SHA-256 hash stored as URL-safe Base64 is 43 characters. Budget accordingly for large tables.

See Also