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):
// 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}");By hash name directly:
// 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}");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.
// 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.");Query by Blind Index and Decrypt
// 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}");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:
// 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);Marten
Save
Marten stores documents as JSONB. The companion property is included in the document JSON and can be queried with LINQ.
// 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.");Query by Companion Property
// 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.");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:
// 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.");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.
Array Blind Index Queries
When a property holds a string collection with [ArrayBlindIndex], the companion is a parallel collection of HMAC hashes. Query it with native LINQ - Contains for a single value and Any for a set of values. No Tayra-specific LINQ operators are involved.
Single value with Contains
Compute one search hash and ask whether the companion collection contains it:
var hash = await tayra.ComputeBlindIndexAsync(
"alice@example.com", "EmailsIndex", typeof(User));
var users = await session.Query<User>()
.Where(u => u.EmailsIndex.Contains(hash))
.ToListAsync();Set of values with Any
Use ComputeBlindIndexesAsync to hash a batch of search terms in one call (the HMAC key is fetched once and reused), then match any of them:
var hashes = await tayra.ComputeBlindIndexesAsync(
["alice@example.com", "bob@example.com"], "EmailsIndex", typeof(User));
var users = await session.Query<User>()
.Where(u => u.EmailsIndex.Any(h => hashes.Contains(h)))
.ToListAsync();ComputeBlindIndexesAsync is an extension on ITayra returning IReadOnlyList<string> in input order, so it feeds straight into Contains / Any. See Array Blind Indexes for per-backend storage (Marten JSONB + GIN, EF Core Npgsql text[] + GIN, MongoDB multikey) and the security trade-offs of collection indexes.
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:
// 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}");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
DefaultCryptoEnginememory cache. Subsequent calls toComputeBlindIndexAsyncare 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
- Blind Indexes Overview - How HMAC blind indexes work
- Array Blind Indexes - Querying encrypted string collections
- Transforms - Normalisation options
- Key Management - HMAC key storage and rotation
- EF Core Integration - Transparent encryption setup
- Marten Integration - Marten document encryption
