All posts

How a database password ends up in git history, and why rotating it is not enough

The four ways a credential gets into a migration file, what happens to it after the commit, and why the fix is scanning the diff rather than hoping the reviewer notices.

Nobody sets out to commit a password. It happens because a migration file is, from the point of view of the person writing it, just SQL, and SQL sometimes needs to know things that are secret.

The four ways in

  1. A settings row. The application reads its configuration from a table, the migration seeds the table, and one of the values is a connection string to another service. INSERT INTO settings VALUES ('warehouse_dsn', 'postgres://etl:Sup3rSecret@wh.internal/dw'). This is the one that spreads furthest, because every service that loads settings now has the credential too.
  2. A role. The migration creates a database user for a new service and gives it a password, in plaintext, in the file. CREATE ROLE reporting LOGIN PASSWORD 'hunter2'. Postgres would have accepted a SCRAM verifier in the same position.
  3. A vendor key. An API key seeded into a table, or set as a column default, so the app "just works" after deploy. Stripe, AWS, GitHub and Slack all give their keys a recognisable prefix precisely so that this can be caught.
  4. A helpful agent. Ask a coding agent to make a script self-contained, or to make a migration runnable without setup, and it will do the obvious thing: read the value out of .env and write it into the file. It is not being careless. It is doing exactly what was asked, and the request did not say "except the secrets" because nobody thought it needed to.

What happens next

The commit lands. The pull request diff shows the value to everyone with read access to the repository, and to every integration that reads pull requests. CI prints the file, so the value is in build logs. If log_statement covers DDL, the database server writes the statement to its own log. And git history is permanent: reverting the commit adds a second commit and leaves the first one exactly where it was, readable by anyone who clones the repository, for as long as the repository exists.

This is why rotating the credential is necessary but not sufficient. Rotation closes the door for the value that leaked. It does nothing about the fact that the file, the process and the reviewer all let a plaintext secret through, and the same path is open for the next one. Rewriting history to remove the value is possible and painful, and every fork and every developer's local clone still has it.

How to do each one right

-- wrong: a live credential in a committed file
INSERT INTO settings (key, value)
  VALUES ('warehouse_dsn', 'postgres://etl:Sup3rSecret@wh.internal/dw');
-- right: seed the key with a placeholder; the value comes from the environment at runtime
INSERT INTO settings (key, value) VALUES ('warehouse_dsn', NULL);

-- wrong: the password is now in git history and in the server log
CREATE ROLE reporting LOGIN PASSWORD 'hunter2';
-- right: commit the SCRAM verifier, which is useless without the password, or set the password
-- out of band after the role exists
CREATE ROLE reporting LOGIN PASSWORD 'SCRAM-SHA-256$4096:...$...:...';

-- wrong: a vendor key as a default
ALTER TABLE integrations ALTER COLUMN api_key SET DEFAULT 'sk_live_51H...';
-- right: no default; the application injects the key from its secret store per environment
ALTER TABLE integrations ALTER COLUMN api_key DROP DEFAULT;

The rule is the same in all three cases: a migration may create the place a secret lives, and it must never contain the secret. The value belongs in the environment, the secret manager, or a one-off command a person runs, none of which get committed.

Why the reviewer does not catch it

Because it looks like data. A DSN in an INSERT is a string like any other string in the file, and a reviewer reading a 200-line migration for logic errors is not pattern-matching on sk_live_. Secret scanning at the platform level helps, but it runs after the push, which is after the leak. The check has to happen on the diff, before the commit, and it has to be a program, because a person's attention is the thing that was already exhausted.

What the rules look for

BC003 fires when a string literal parses as a libpq connection string with a real password in it, and stays quiet on placeholders and empty passwords. BC004 matches the published key formats, not entropy, so a uuid or a bcrypt hash does not trip it. BC001 catches CREATE ROLE ... PASSWORD with a plaintext value and is silent on a SCRAM verifier. All three run in npx bolvrk check on migration files, and the first two also run on any file or on piped output with bolvrk secrets, which is the one to put in front of an agent's output before it is commited:

git diff --cached | npx bolvrk secrets -

The point of scanning the diff rather than the repository is that the diff is the moment the secret is still only on one machine. Everything after that is cleanup.

The rules behind this post