CredentialParity
Catch Rails credential drift between environments before it reaches production.
Rails ships nothing that compares your credential files to each other. Because
the whole file is encrypted rather than just the values, a key added to three of
four files and misspelled in the fourth is invisible in a diff, and
credentials.dig returns nil for it rather than complaining. The first sign of
trouble is the environment that missed it failing after a deploy, found by
whoever ran the deploy rather than whoever made the change.
Credential keys are out of sync between environments.
staging is missing vendor.indexing_api.private_key, which production defines
staging defines vendor.indexing.private_key, which production does not
Environment Roles
Every environment is either a mirror of the reference or a subset of it. The defaults describe the shape almost every Rails app already has:
| Environment | Role | May omit a key | May add its own key |
|---|---|---|---|
| production | reference | n/a | n/a |
| staging | mirror | No | No |
| development | subset | Yes | No |
| test | subset | Yes | No |
The asymmetry is the point. A flat "all files must be identical" rule is wrong on its face, because development and test legitimately go without credentials for services only the deployed environments talk to. Letting them be subsets means no allowlist of exceptions to maintain, and no allowlist to go stale.
Requirements
Ruby 3.2 or newer, and ActiveSupport 7.0 or newer.
Installation
gem "credential_parity"Then add the check to your test suite, next to the pending migration check that is already there:
# spec/rails_helper.rb
ActiveRecord::Migration.maintain_test_schema!
require "credential_parity/rspec"On Minitest, call it directly:
# test/test_helper.rb
CredentialParity.check!That is all. The development middleware installs itself.
Usage
In development
A middleware raises on page load when the files stop agreeing, the same way a
pending migration does. It wraps an ActiveSupport::FileUpdateChecker, so the
files are only decrypted again after one of them actually changes and the per
request cost is a stat call. Correct the file and reload; no restart needed.
In your test suite
require "credential_parity/rspec" at boot, next to maintain_test_schema!.
This is the hook that catches the person who introduced the drift, because
adding a credential is always followed by running something.
From the command line
bundle exec rake credential_parity:verifyPrints the environments it compared, or exits non-zero listing every violation. Useful in a deploy step, or when you want an answer without booting a server.
Anywhere else
CredentialParity.check! # raises DriftError, or returns nil
CredentialParity.violations # array of Violation, empty when clean
CredentialParity.checkable? # false when the keys are not all presentConfiguration
All configuration is optional. CredentialParity works out of the box on an app with the usual four environments.
# config/initializers/credential_parity.rb
CredentialParity.configure do |config|
# The environment every other one is compared against (default: :production)
config.reference = :production
# Must declare exactly the same key paths as the reference (default: [:staging])
config.mirrors = [:staging, :demo]
# May omit keys, may never add their own (default: [:development, :test])
config.subsets = [:development, :test]
# Where the .yml.enc and .key files live
# (default: Rails.root.join("config/credentials"))
config.directory = Rails.root.join("config/credentials")
# Environments the page load middleware is installed in (default: [:development])
config.middleware_environments = [:development]
endAn app with no staging environment sets config.mirrors = [].
How the comparison works
Leaf key paths are compared, never values. Two consequences follow:
- Rotating a secret in one environment is not drift. Every environment is supposed to hold a different value for the same key.
- No secret can reach an error message. Only path names are ever read out, which matters because that message gets pasted into terminals and chat.
Recursing to leaf paths, rather than comparing top level keys, is what catches
the nested case. If production declares vendor.indexing_api.private_key and
every other environment declares vendor.indexing.private_key, all four still
declare a top level vendor, so a shallow comparison sees nothing wrong.
A declared key with nothing under it, some_key: or some_key: {}, counts as a
leaf. It is still a key one environment declares and another does not.
When it does not run
The check needs every environment's key, so it runs on developer machines, which is the only place all of those keys exist, and quietly does nothing anywhere else. On CI and on deployed hosts only one environment's key is present, so there is nothing to compare and nothing fails.
rails credentials:edit is deliberately never gated. It is the command used to
repair drift, so a check that blocked it would lock you out of the fix. The
scoping is copied from ActiveRecord::Migration::CheckPending, which Rails runs
on web requests and at test boot but not for generators, console, or runner.
Set SKIP_CREDENTIAL_PARITY_CHECK=1 to bypass it entirely.
Error Handling
All errors inherit from CredentialParity::Error, so you can catch everything
with one rescue or handle specific cases:
begin
CredentialParity.check!
rescue CredentialParity::Error => e
# Catch any credential_parity error
endSpecific error classes:
| Error | When |
|---|---|
CredentialParity::DriftError |
The environments disagree (message lists every path and where it is missing or extra) |
CredentialParity::ConfigurationError |
No credentials directory could be resolved, or a violation carried an unknown kind |
Deployment Notes
CI
Nothing to do. CI holds at most one environment's key, usually through
RAILS_MASTER_KEY, so the check reports itself unable to run and your build is
unaffected. Adding rake credential_parity:verify to CI is harmless but will
skip for the same reason.
Deployed hosts
Same story. A production dyno has production.key and nothing else, so parity
cannot be evaluated there. If you want a deploy time gate, the thing to check on
a deployed host is that every credential your code reads is present, which is a
different question from whether the environments agree.
Contributing
Bug reports and pull requests are welcome on GitHub.
- Fork the repo
- Create your feature branch (
git checkout -b my-feature) - Make your changes with tests
- Ensure all tests pass (
bundle exec rake test) - Commit and push
- Open a pull request
Testing
# Install dependencies
bundle install
# Run the full test suite
bundle exec rake test
# Run tests against a specific ActiveSupport version
bundle exec appraisal activesupport-7.0 rake test
bundle exec appraisal activesupport-8.0 rake test
# Run all appraisals
bundle exec appraisal rake testAvailable appraisals: activesupport-7.0, activesupport-7.1,
activesupport-7.2, activesupport-8.0.
Tests generate their own encrypted fixtures in a tmpdir, so no real credential file is ever decrypted and no external services are needed.
License
Copyright (c) 2026 Velocity Labs, LLC. Released under the MIT License.