LaunchKit
← All posts
· 17 min read · by The LaunchKit team · 0 views

Active Record encryption and the query that finds nothing

Active Record encryption is four characters of configuration and it does what it says: the column holds ciphertext, the attribute holds plaintext, and nothing in between needs to know. The part that catches people is not the encryption. It is that a database column stops being a database column the moment you encrypt it, and Rails does not announce which of your existing queries just stopped working.

Everything below ran on activerecord 8.1.3.1 and Ruby 4.0.5 against PostgreSQL 17.7 on port 15432. The ciphertexts, the plans and the byte counts are copied out of the runs.

What encrypts writes into the column

encrypts :ssn decorates the attribute type. Nothing else changes: the column stays whatever you declared, the model stays a model, and the encryption happens in serialize and deserialize on ActiveRecord::Encryption::EncryptedAttributeType. So the INSERT looks entirely normal and the bound parameter is the only surprise:

Person Create  INSERT INTO "people" ("name", "email", "ssn", ...) VALUES ($1, $2, $3, ...)
  [["name", "a"],
   ["email", "{\"p\":\"sVFI4YJ9GROMUu7LJV7I\",\"h\":{\"iv\":\"NDHiRXi8pm2mpKua\",\"at\":\"UpvEIpOT4da66U0Qwecc4A==\"}}"],
   ["ssn",   "{\"p\":\"LgWSj7Mrt3mYs2Y=\",\"h\":{\"iv\":\"oCYBDY41WbWjEF+r\",\"at\":\"PCr2dFVZ2TfyWWL5LMjP4w==\"}}"]]

Three fields, all base64. p is the payload, iv is the 12 byte initialization vector, at is the 16 byte GCM authentication tag. The cipher is aes-256-gcm, hardcoded as CIPHER_TYPE in active_record/encryption/cipher/aes256_gcm.rb, and ActiveRecord::Encryption.cipher.key_length answers 32 with iv_length 12.

"ada@example.com" is 15 bytes of plaintext and 90 bytes in the column. That ratio is the whole sizing problem, and it has its own section below.

One thing you get for free and should know about: encrypts registers the attribute with ActiveRecord::Encryption::AutoFilteredParameters, which appends "person.ssn" to config.filter_parameters and adds :ssn to the model's filter_attributes. So the column is redacted in the log and in inspect without you writing a filter. Turn it off with config.active_record.encryption.add_to_filter_parameters = false, which you will want approximately never.

The three keys and which column each one reaches

Generating the Rails encryption keys is one rake task, and the task is small enough to read. bin/rails db:encryption:init is one puts of a heredoc in railties/databases.rake, and the whole of it is three calls to SecureRandom.alphanumeric(32) printed inside a YAML block:

active_record_encryption:
  primary_key: YehXdfzxVKpoLvKseJMJIEGs2JxerkB8
  deterministic_key: uhtk2DYS80OweAPnMLtrV2FhYIXaceAy
  key_derivation_salt: g7Q66StqUQDQk9SJ81sWbYZXgiRogBwS

The task generates and prints. It does not write anything, so paste them into bin/rails credentials:edit yourself. None of the three is an AES key: each is a password that gets run through PBKDF2. Context#build_default_key_provider returns DerivedSecretKeyProvider.new(config.primary_key), which calls ActiveSupport::KeyGenerator#generate_key, which is OpenSSL::PKCS5.pbkdf2_hmac at 2**16 iterations with key_derivation_salt as the salt and 32 as the length.

The division of labour is the fact worth memorising. primary_key derives the key for every non-deterministic column. deterministic_key goes through DeterministicKeyProvider, a subclass that adds one guard and nothing else, and serves every column declared deterministic: true. key_derivation_salt is the salt on both paths. The two key spaces never meet, which you can watch: write a row, restart with a different primary_key, and the non-deterministic column raises while the deterministic one reads fine.

non-deterministic column: ActiveRecord::Encryption::Errors::Decryption
deterministic column:     "vip"

Note that the guide says the recommended minimum is "12 bytes for the primary key and 20 bytes for the salt", so 32 is generous rather than required. Note also that a missing key is a boot-time problem only if something reads it: Config raises Missing Active Record encryption credential: active_record_encryption.deterministic_key lazily, on first use, which in practice means the first request that touches an encrypted column in an environment where nobody ran the task.

Deterministic mode is twelve bytes of HMAC

The entire difference between the two modes is one private method:

def generate_deterministic_iv(clear_text)
  OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, @secret, clear_text)[0, ActiveRecord::Encryption.cipher.iv_length]
end

Non-deterministic encryption calls cipher.random_iv. Deterministic encryption derives the IV from the key and the plaintext, so the same input always produces the same twelve bytes, and AES-GCM with a fixed key and a fixed IV is a pure function. Same plaintext, same ciphertext, forever.

That identity is global, not per row and not per table. Two different models with a deterministic email produced byte-identical column values:

authors.email == readers.email ? true
{"p":"EK74G1vPYWPTkNVSqEe/pvrAYg==","h":{"iv":"POVaFyxbo+igRvFk","at":"BVK+2cPBf2tR7FXle6OhDg=="}}

Which is the feature. Equality comparison in SQL works because the server is comparing two byte strings that Ruby produced from the same input, and it has no idea either of them is ciphertext. It is also the cost, and the guide is blunt about it: "The :deterministic option allows for querying by trading off lesser security. The data is still encrypted but the determinism makes crypto-analysis easier."

The query that finds nothing and raises nothing

Here is the behaviour that costs people an afternoon. A where on a non-deterministic column is accepted, runs, and returns zero rows.

class Person < ActiveRecord::Base
  encrypts :email, deterministic: true
  encrypts :ssn
end

Person.create!(name: "a", email: "ada@example.com", ssn: "111-22-3333")
Person.create!(name: "b", email: "ada@example.com", ssn: "111-22-3333")

Person.where(ssn: "111-22-3333").count  # => 0

Two rows hold that exact value. The count is 0. No exception, no warning, no log line saying anything is wrong. Active Record did precisely what you asked: it serialized the search term through the attribute type, which encrypted it with a fresh random IV, and handed PostgreSQL a bound parameter that has never existed in that column and never will.

Watch the two statements in one process, both searching for the same string:

Person Load   SELECT "people".* FROM "people" WHERE "people"."ssn" = $1
  [["ssn", "{\"p\":\"cCjthKJnsbbEHQI=\",\"h\":{\"iv\":\"X+Ysvsvso17eKkPm\",\"at\":\"QzPtXA9UJdm5L6YY3p1lZA==\"}}"]]
Person Count  SELECT COUNT(*) FROM "people" WHERE "people"."ssn" = $1
  [["ssn", "{\"p\":\"6KUBUs/E1YHW+Ps=\",\"h\":{\"iv\":\"cxLJACldCIIzHgm4\",\"at\":\"kWXBks9OK1Y4l4vh5GJRiQ==\"}}"]]

Different bytes, same string, milliseconds apart. Randomised IV is the point of non-deterministic encryption and it is working exactly as designed.

What makes this expensive is that it is a silent behaviour change on code that already exists. You add encrypts :ssn to a model, every find_by, every where, every uniqueness validation on that column quietly starts answering "no such record", and the specs that covered them only fail if a fixture actually matched. A spec that asserts an empty result stays green. A spec that asserts one result fails, and the failure says "expected 1, got 0", which reads like a fixture problem.

There is no framework-level guard here. deterministic: false columns are write-only as far as SQL is concerned, and you are expected to know that.

What the index on an encrypted column still does

The index does not break. That is the problem with it.

Fifty thousand contacts, the same email in two columns, one plain and one deterministic, a plain B-tree index on each:

rows: 50000
avg plaintext bytes:  23
avg ciphertext bytes: 102
index_contacts_on_email_plain: 3504 kB
index_contacts_on_email_enc:   8632 kB

Two and a half times the index for the same 50,000 values, which follows from indexing 102 bytes instead of 23. Equality lookups work and are fast, because that is what deterministic encryption buys. Everything else about a B-tree is now decorative.

A prefix search, on both columns, same statement shape:

-- email_plain
Index Scan using index_contacts_on_email_plain on contacts (actual rows=11 loops=1)
  Index Cond: (((email_plain)::text >= 'person4999'::text) AND ((email_plain)::text < 'person499:'::text))

-- email_enc
Index Scan using index_contacts_on_email_enc on contacts (actual rows=0 loops=1)
  Index Cond: (((email_enc)::text >= 'person4999'::text) AND ((email_enc)::text < 'person499:'::text))

Identical plans. Both use their index. Both are fast. One returns 11 rows and one returns 0, and nothing in the plan suggests which is which. The planner is doing correct range work over a sort order that has no relationship to the sort order of the plaintext, because base64 of AES-GCM output is noise.

ORDER BY has the same shape of failure. Ordering 20,000 rows by the encrypted email column returned ids [17395, 5500, 10337], which is a real, stable, repeatable order, and it is the alphabetical order of the ciphertexts. Sorting a user list by email is now sorting it by nothing.

Unique indexes are the exception and they work properly, because a unique index only ever asks about equality. So does validates_uniqueness_of: ActiveRecord::Validations::UniquenessValidator needed no help to reject a duplicate on a deterministic column, and answered Email has already been taken. Just make sure the column is deterministic before you rely on either, because on a non-deterministic column the unique index is satisfied by every row and the validation passes always.

What a deterministic column gives away

Determinism leaks the frequency distribution of your data to anyone holding a copy of the table, and they do not need the key to read it.

Eight rows, a deterministic plan column, and one GROUP BY:

5 rows share {"p":"FgA6sg==","h":{"iv":"kDHmlq7xOvxgV5i9","at":"d...
2 rows share {"p":"o0Nz","h":{"iv":"Vlu8RLm2B8FAbv4T","at":"Hjnd+...
1 rows share {"p":"S5WvT7OBI9wJSw==","h":{"iv":"K412vv2H+zHlUZP1"...

Nothing is decrypted and the whole shape of the column is visible. On a low cardinality column such as a plan name, a country, a status or a boolean-ish flag, that histogram plus one known value plus public knowledge of your pricing page is usually enough to label every group. The payload lengths are visible too: p is base64 of a GCM ciphertext, which below the compression threshold is exactly as long as the plaintext, so every row advertises the length of its secret.

This is why the default is non-deterministic and why the guide says non-deterministic "is recommended for all data unless you need to query by the encrypted attribute". Read that as a per-column decision, not a per-application one. An email address on a deterministic column is defensible, because the reason you encrypted it was the dump and not the frequency analysis, and you genuinely need find_by. A diagnosis column on eleven possible values is not.

Sizing the column, and where the 255 bytes come from

Measured overhead, real English prose, non-deterministic, in a text column:

plain    stored   ratio    compressed?
11       86       7.82     false
40       126      3.15     false
139      258      1.86     false
141      231      1.64     true
300      231      0.77     true
1000     239      0.24     true
4000     259      0.06     true

Two regimes, and the boundary is a constant in Encryptor:

# This threshold cannot be changed.
THRESHOLD_TO_JUSTIFY_COMPRESSION = 140.bytes

Below it, you pay roughly 70 bytes of envelope plus a third again for base64. Above it, Zlib runs first and long text gets cheaper than it was. The comment explains why the number is frozen: change it and the same plaintext would produce a message with or without the "c" header depending on which version of Rails wrote it, and deterministic lookups on existing data would stop matching.

The guide's "worst-case overhead to be around 255 bytes" is not the default configuration. The default DerivedSecretKeyProvider put a 19 byte plaintext in 98 bytes. Swapping in EnvelopeEncryptionKeyProvider, which generates a random data key per operation and stores it encrypted in a k header, put the same value in 240 bytes, and adding store_key_references = true put it in exactly 255. That is where the number comes from, and if you are on the default provider the practical rule for short Western text is about 120 bytes, not 255.

The guide's other point stands whatever provider you use, and it is the one that surprises people. string(255) in PostgreSQL counts 255 characters, and a UTF-8 character is up to four bytes, so that declaration was holding up to 1020 bytes of Cyrillic. Ciphertext is ASCII base64, where a character is a byte, so the same declaration now buys you 255. That is why the guide's table recommends string(510) for email addresses and string(1020) for a short sequence of emojis. Size encrypted string columns in bytes, and when the answer is awkward use text and stop counting.

The length validation that checks the wrong string

config.active_record.encryption.validate_column_size defaults to true and sounds like it protects you from the previous section. It does not. The implementation, in EncryptableRecord#validate_column_size, is one line:

validates_length_of attribute_name, maximum: limit

That validates the attribute, which is the plaintext. On a string(40) column holding an encrypted attribute, a 41 character plaintext is rejected in Ruby:

41 plaintext chars valid? false -> ["Short is too long (maximum is 40 characters)"]

and a 5 character plaintext passes validation and then hits the database:

5 plaintext chars: ActiveRecord::ValueTooLong: PG::StringDataRightTruncation: ERROR:  value too long for type character varying(40)

Five characters, 40 character column, ValueTooLong. The validation enforces a limit the stored value was never going to respect, and it enforces it against the one string that is not going into the column. Treat it as a leftover from the unencrypted schema rather than as a guard, and size the column yourself.

While you are looking at the column: encrypts on a non-string column does not do anything useful. An integer column got PG::InvalidTextRepresentation: ERROR: invalid input syntax for type integer: "{"p":"zYc=",...}", because the ciphertext is text and the column is not. Encrypted columns are string, text or binary.

Turning it on for a table that already has rows

Two settings exist for the migration window and both default to false.

support_unencrypted_data = true makes reads tolerate plaintext. The mechanism is a fallback in EncryptedAttributeType#handle_deserialize_error: decryption raises, and if the attribute supports unencrypted data the raw value is returned as-is. Without it, the same row raises ActiveRecord::Encryption::Errors::Decryption, and that error is worth seeing once before you meet it in production, because e.message is "ActiveRecord::Encryption::Errors::Decryption". The class is the whole message. Nothing tells you which record, which attribute or which key was tried.

extend_queries = true is the one people miss, and it is what makes queries find the rows that have not been backfilled yet. With it off, a deterministic lookup matched the 5 encrypted rows and missed the 1 plaintext row. With it on it found all 6, by putting both forms in the statement:

SELECT "notes".* FROM "notes"
WHERE "notes"."plan" IN ('{"p":"FgA6sg==","h":{"iv":"kDHmlq7xOvxgV5i9","at":"dq29q8L9aV4slAvIZiwTVQ=="}}', 'free')

Its default is false and the code says why, in a comment on Config#set_defaults: "TODO: Setting to false for now as the implementation is a bit experimental". ExtendedDeterministicQueries.install_support prepends a module onto ActiveRecord::Relation and includes one into ActiveRecord::Base, so it is a global patch on query building, and the file carries its own note that support for every kind of query is pending. Turn it on for the migration, turn it off after.

The backfill itself is record.encrypt, one row at a time. It goes through update_columns, so it writes one UPDATE per record, skips validations and callbacks, and leaves updated_at where it was:

Lead Update  UPDATE "leads" SET "email" = $1 WHERE "leads"."id" = $2
after: [[1, 90, 2020-01-01 00:00:00 UTC], [2, 90, 2020-01-01 00:00:00 UTC]]

A leftover timestamp is usually what you want, for the reason Counter caches by hand gives about <lastmod>: a backfill is not a content change. It also means a half-finished backfill leaves no trace on the row, so track progress with an id watermark rather than with updated_at.

The key you can rotate and the key you cannot

primary_key accepts an array, and the last entry is the one new writes use. Written under the old key, read back under both, across separate processes:

both:    "written under the old key"
newonly: ActiveRecord::Encryption::Errors::Decryption

So a rotation is: add the new key to the end of the list, deploy, re-encrypt every row with record.encrypt, then drop the old key from the list. Skip the middle step and the second deploy turns every unmigrated row into a Decryption error at read time.

Deterministic columns do not get this. DeterministicKeyProvider is a DerivedSecretKeyProvider with one added line:

raise ActiveRecord::Encryption::Errors::Configuration, "Deterministic encryption keys can't be rotated" if passwords.length > 1

It has to refuse, and the reason is the feature. Determinism means the ciphertext for a value is a function of the key, so two keys means two ciphertexts for one value, and every equality query and every unique index would have to know about both. Putting two values in deterministic_key raises at configuration time rather than corrupting anything, which is the right call and also means "rotate the deterministic key" is not a maintenance task you can plan. It is a full table rewrite with a migration window, designed in advance or not at all.

Rails already had encryption, and it is a different thing

The LaunchKit boilerplate does not call encrypts anywhere. Its secrets live in per-environment Rails credentials, config/credentials/<env>.yml.enc with a matching key file, read through one AppConfig module. That is ActiveSupport::EncryptedConfiguration, which encrypts a file at rest and decrypts it once at boot, and it is the right tool for a Stripe key because a Stripe key is configuration.

The two get conflated in search results and they solve opposite problems. Encrypted credentials protect a value that every request needs and no request writes, and the whole file is decrypted in memory for the process lifetime. Active Record encryption protects values that arrive from users at runtime, and the point is that a database dump, a replica, a backup or a log line does not contain them. If somebody asks for "rails encryption" and means an API token, they want credentials.

Something in the framework does use encrypts, and it is worth reading as the shortest real example. Solid Cache's encrypt: true resolves to a single call in SolidCache::Entry::Encryption that encrypts the value column and leaves key in the clear, which Solid Cache vs Redis pulls apart along with the custom encryption context it passes. Same mechanism as everything above, with different serializer and compression choices, for reasons specific to a cache table.

The call, and what would change it

Use Active Record encryption, non-deterministic, for anything you would not want in a database dump and never query by: notes, tokens, bank details, free text a user typed. It is one word in the model and there is no second system to run.

Reach for deterministic: true only on columns you actually look up, accept the frequency leak explicitly, and prefer high cardinality values. Email address, yes. Plan name, no.

Skip it entirely when you need real search over the encrypted values. Prefix, range, sort and ILIKE are not coming: the ciphertext has no order and no substrings. The established answer is Lockbox 2.2.0, released 2026-04-04, MIT, Ruby 3.3 or newer, 48.5 million downloads, "Modern encryption for Ruby and Rails", paired with Blind Index 2.8.1 from 2026-06-29, whose one line description is the entire pitch: "Securely search encrypted database fields". Lockbox's README sends you there in a sentence: "If you need to query encrypted fields, check out Blind Index." A blind index is, in its README's words, "a keyed hash of the sensitive data" stored in a column of its own, Argon2id by default at 3 iterations and 4 MB of memory. It is deterministic on purpose and it lets you keep the ciphertext itself non-deterministic, so you get lookups without the ciphertext being a histogram. That is a better shape than deterministic: true, and it costs a gem, a column and a backfill.

The cost of recommending the framework is that you inherit its ceiling. No blind index, no searchable encryption, no per-tenant keys without writing a key provider, and a deterministic key you cannot rotate. What would change it: a deterministic mode built on a separate hash column rather than on a fixed IV would remove both the frequency leak and the rotation dead end, and it would make most of this post about a historical design.

attr_encrypted, the pre-7.0 answer, is at 4.2.0 from 2025-01-23 with 80.5 million downloads, and it is not the one to start with in 2026. Nothing has been released in over eighteen months and the framework now covers its use case.

What this post does not cover

Custom key providers, which is where per-tenant and per-record keys live, and the ActiveRecord::Encryption::Key and KeyProvider contracts you implement to build one. KMS integration, which is what EnvelopeEncryptionKeyProvider exists to be combined with. Encrypting binary columns and the MessagePackMessageSerializer that makes them worthwhile. previous: schemes and the fixed: option, which are how you change encryption properties on a column that already has data. And the ignore_case: true option, which needs a second column named original_<name>, encrypts both, and therefore doubles the storage for that attribute, which is a tradeoff that deserves its own measurements rather than a footnote.

No timings appear anywhere above. Row counts, byte counts, index sizes and ciphertexts reproduce on your machine; a millisecond figure from mine would not.

#rails #active-record #security

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.