blind_index: exact search over encrypted Rails columns
Securely search encrypted database fields
At a glance
- What is it?
- blind_index is a Ruby gem that lets you query an encrypted attribute exactly by storing a keyed Argon2id hash of the value alongside the ciphertext. The trade-off is deliberate and documented: rows with the same plaintext leak that fact, and LIKE searches are not supported.
- Who is it for?
- Adopt blind_index when the field is an identifier or credential you must look up exactly, you already encrypt it with Lockbox or attr_encrypted, and the equality leak of repeated values is acceptable for that column. Do not adopt it for free-text fields, for columns where frequency analysis would expose the value, or when you need prefix or substring search, since the README states that efficient LIKE queries are not possible.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 116 days ago.
- What is it written in?
- Mainly Ruby, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
A keyed hash column beside the encrypted column is the whole mechanism
The README describes the design in a few sentences and points to Scott Arciszewski's approach. A keyed hash of the sensitive data is computed and stored in a column. To query, the same keyed hash function is applied to the value you are searching for, and a normal database search runs against the stored hash.
Because the search column holds a hash rather than the plaintext, the database never sees the value it is filtering on, and the query stays a plain equality lookup, so it uses an ordinary index and stays fast. The encrypted value itself is untouched, still handled by Lockbox or attr_encrypted. blind_index sits beside that encryption layer rather than replacing it, and the README states that your model should already be set up with one of those two gems, for example `has_encrypted :email` or `attr_encrypted :email`.
There is one important consequence that follows from using a hash rather than a cipher. Equal plaintext produces equal hash, so two rows holding the same email are visible as equal to anyone who can read the index column. The README states this plainly: blind indexing leaks that rows have the same value, and it has the same leakage as deterministic encryption.
The leakage the README puts front and center
Most encryption libraries leave you to reason about leakage. This one spends a section on it, and the examples are specific. If you index a last name, an attacker can use frequency analysis to predict the values. In an active attack where the attacker controls input values, they can learn which other values in the database match, because a value they submit hashes to a row that already exists.
That last point matters for any interactive application. If a registration form accepts an email and reports whether it is already taken, the blind index turns that form into an oracle for testing candidate addresses against a live database. The README links an article on the expanding leakage function of searchable encryption and states that blind indexing has the same leakage as deterministic encryption.
So the fit is narrower than the name suggests. Equality search on a low-entropy identifier is one thing. Free text, notes, names, and anything a user can submit and then query back is another. The gem does not add a range or prefix capability that would change the picture, since the README is explicit that efficient LIKE queries are not possible.
From Gemfile to a working email_bidx column
Installation is a single Gemfile line, since blind_index is distributed as a Ruby gem:
gem "blind_index"Then the model needs a column to hold the index. The migration the README shows adds a string column and an index on it, with a comment noting the unique option if needed:
add_column :users, :email_bidx, :string
add_index :users, :email_bidx # unique: true if neededThe model then declares the blind index. The README gives this for a `User` model:
class User < ApplicationRecord
blind_index :email
endFor more sensitive fields, the same declaration takes a `slow` option, which raises the Argon2id cost parameters. Records that already exist need a backfill, and the README shows the class-level call:
BlindIndex.backfill(User)After that, querying looks like ordinary ActiveRecord, with the attribute name rather than the index column name:
User.where(email: "[email protected]")The column name convention is the `email_bidx` suffix, and the same convention appears later for the case-insensitive variant `email_ci_bidx` and the rotated key variant `email_bidx_v2`. If you use attr_encrypted rather than Lockbox, the README also tells you to generate a key first, pointing to a key generation section.
Expressions are how you get case-insensitive and multi-column lookups
The expression option applies a transformation to the value before it is indexed and before it is searched, and it is the only lever the gem gives you for shaping what matches. The README uses it to enable case-insensitive search:
class User < ApplicationRecord
blind_index :email, expression: ->(v) { v.downcase }
endThe same mechanism supports uniqueness validation, which is the reason many teams reach for this gem. A plain `validates :email, uniqueness: true` on a model with a blind index checks against the hash column rather than the ciphertext. The README recommends adding a unique index to the blind index column through a database migration, and shows that as `add_index :users, :email_bidx, unique: true`.
For blank values, the expression becomes `->(v) { v.presence }` paired with `uniqueness: {allow_blank: true}`. For case-insensitive uniqueness, the README uses the `downcase` expression and warns in a comment to leave out `{case_sensitive: false}` for best performance, since a case-sensitive check would otherwise run a second query.
Multiple indexes on one attribute work by adding a second column and a second declaration. `blind_index :email_ci, attribute: :email, expression: ->(v) { v.downcase }` derives a case-insensitive index from the same source attribute, and a separate backfill call is needed for that column, `BlindIndex.backfill(User, columns: [:email_ci_bidx])`. The query then names the virtual attribute, for example `User.where(email_ci: "[email protected]")`.
Key rotation adds a column instead of rewriting one
Rotation is handled the long way, and that is the point. The README does not describe mutating the existing index column in place. To rotate keys without downtime, you add a new column such as `email_bidx_v2`, index it, and declare the rotation in the model with a `rotate` option carrying a version and a master key taken from the environment:
class User < ApplicationRecord
blind_index :email, rotate: {version: 2, master_key: ENV["BLIND_INDEX_MASTER_KEY_V2"]}
endThe new column is kept in sync going forward. Then the data is backfilled into it with `BlindIndex.backfill(User, columns: [:email_bidx_v2])`, the model is updated to `version: 2` with the same environment variable, and only at the end is the old column dropped.
Two practical notes. The rotation path requires a migration, a second backfill pass over the full table, and a window where both columns exist, so the operational cost is real for a large table. And the key comes from `ENV["BLIND_INDEX_MASTER_KEY_V2"]`, which means deployment configuration has to supply it before the code that reads it runs.
The `migrating` option covers a different transition, where a column is being encrypted and indexed at the same time. With `blind_index :email, migrating: true`, a backfill can run while the unencrypted field is still queried, and the option is removed once the backfill completes.
Argon2id cost parameters, derived keys, and the limits of the hash
The algorithm section names Argon2id as the choice for best security, with default cost parameters of 3 iterations and 4 MB of memory. Setting `slow: true` raises that to 4 iterations and 32 MB of memory. A number of other algorithms are supported and documented in `docs/Other-Algorithms.md`, and the README tells you to go with Argon2id unless you have specific reasons.
The `slow` option is a real performance decision. The higher cost parameters make each hash slower to compute, which slows both the write path and the backfill, and it is aimed at more sensitive fields.
Key handling follows the CipherSweet key hierarchy. The master key generates a unique key for each blind index, and the table name and the blind index column name both feed that derivation. An individual derived key can be extracted with `BlindIndex.index_key(table: "users", bidx_attribute: "email_bidx")`, which is useful for inspection but also means the derivation inputs are visible in the application.
Renaming is handled with the same idea: `key_table: "original_table"` keeps a renamed table pointing at the old derived key, and `key_attribute: "original_column"` does the same for a renamed column. Without those options, a rename would silently change the derived key and every existing index value would stop matching.
One more feature to weigh: the README mentions using blind indexes in fixtures, and the example in the repository is a YAML fixture, but the section is truncated before the full snippet is shown, so the exact fixture wiring is not visible.
Where blind_index differs from deterministic encryption
The README names the alternative directly: deterministic encryption has the same leakage as blind indexing. The difference is where the tradeoff is made and what you can still do afterwards.
With deterministic encryption, the ciphertext column is itself searchable, so the stored value doubles as the lookup value. There is no separate index column, no backfill of a second column, and no key rotation routine that needs a parallel column. What you give up is the ability to distinguish two different plaintexts, and the algorithm choice is limited to ciphers that can encrypt the same plaintext to the same ciphertext.
With blind_index, the ciphertext column stays a proper randomized encryption, and a separate keyed hash carries the search capability. The price is extra columns, a backfill whenever the index is added or a key is rotated, and a second artifact to keep in sync. The benefit is that the search key can be derived per column and rotated independently, and that the hash can use a slow KDF such as Argon2id with 32 MB of memory, which a general purpose encryption routine would not normally apply to every value.
For an application that needs equality lookups on identifiers and nothing else, either approach can work. The choice comes down to whether you want the searchable value and the encrypted value to be the same bytes, and whether you are willing to operate a backfill process when the key changes.
Editorial conclusion
Adopt blind_index when the field is an identifier or credential you must look up exactly, you already encrypt it with Lockbox or attr_encrypted, and the equality leak of repeated values is acceptable for that column. Do not adopt it for free-text fields, for columns where frequency analysis would expose the value, or when you need prefix or substring search, since the README states that efficient LIKE queries are not possible. Verify first that the master key you plan to use is the same one you will keep, because every index column depends on it and rotating it means a new column plus a backfill.
Frequently asked questions
How do I add blind_index to a Rails app?
Add `gem "blind_index"` to the Gemfile, then add a string column such as `email_bidx` to the model table and declare `blind_index :email` in the model. The model should already be set up with Lockbox or attr_encrypted. Call `BlindIndex.backfill(User)` to populate the column for existing rows.
Can blind_index do partial or LIKE searches?
No. The README states that efficient LIKE queries are not possible, and that the hash is computed for the value being searched, which gives performant queries for exact matches only. Expressions can transform the value before indexing, for example downcasing it, but they do not change the equality-only nature of the lookup.
What leaks if I use blind_index on an email column?
Blind indexing leaks that rows have the same value, and the README states it has the same leakage as deterministic encryption. An attacker who can submit values can learn which other values in the database match, and low-entropy fields such as last names are exposed to frequency analysis.
How do I rotate the blind index master key without downtime?
Add a new column, for example `email_bidx_v2`, declare the rotation with `rotate: {version: 2, master_key: ENV["BLIND_INDEX_MASTER_KEY_V2"]}`, backfill the new column with `BlindIndex.backfill(User, columns: [:email_bidx_v2])`, switch the model to `version: 2`, and only then drop the old column.
Which algorithm does blind_index use and can I make it slower?
Argon2id is used for best security, with default cost parameters of 3 iterations and 4 MB of memory. The `slow: true` option raises the cost to 4 iterations and 32 MB of memory. Other algorithms are supported and documented in `docs/Other-Algorithms.md`.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/ankane-blind-index)Was this useful?