Skip to content

Where Should Secrets Live, and Why It Should Not Be in Your Config

Imagine you are one of the 212,230 AI ChatGPT Wrappers founded in 2025 about to make your grand debut to the world. You push your code, vercel deploys it for you while charging too much money to your bank account, and you sit happily, waiting for the profits to rake in, unaware of the disaster awaiting.

You sleep and have a good night’s sleep, wake up and OH MY God. Your bank account has a 6 figure number. You stand still, shocked and dancing with happiness. You finally made it and have become rich (upper middle class), and about to hit the floor dancing, until you notice the ’-’ sign.

You deflate almost visibly, but take a couple of deep breaths. No way that number is right, you mused. It must be a glitch with the bank, similar to the -99 billions glitch, you thought.

You call the bank, get elevated to the branch manager, and he breaks the news:

This is not a mistake.

he said

You got this charge from checks notes Open AI.

You, my friend, have made one of the worst mistakes a software developer could do. You exposed your api key, a secret, by accident, and now the whole world is using it.

This story is probably too long, and I had too much fun writing it, but it conveys a reality some people actually experienced, especially post the AI boom. A lot of people, new to the industry and intoxicated with a poorly explained tool, thought they could be the next Uber, but they did not realize that by unknowingly allowing AI to push and share their keys, they gave almost unlimited access to anybody on the internet.

You might ask a good question: will somebody really find it? My account on Github is followed by 5 people (4 are my friends and the rest is my other account), so even if I did this mistake, nobody will find it. Unfortunately for you, the latest studies say it is found within two minutes. It sounds crazy, but right now there are thousands if not millions of bots grepping things like .env .config .git *.prod and so on, waiting for the smallest mistake, and if you make one, trust me, they will capitalize on it.

So what can you do? A lot actually, but before we get to the tools, we need to talk about a distinction most of us (me included) never think about.

The thing most folks don’t get is that configuration and secrets are not the same kind of thing, even though we keep dumping them in the same file.

Configuration describes behavior: which port to listen on, which model to call, how many retries before giving up. It belongs in git, in code review, in bug reports, and on every developer’s machine. You actually want it shared.

A secret, on the other hand, grants authority: an API key, a database password, a signing key. It needs restricted access, its own rotation schedule, and ideally nobody ever sees it.

The moment you put both in one file, you have glued together two things with completely different lifecycles and audiences. Your teammate asks “can you send me your config so I can reproduce this bug?” and now you are one paste away from the story above. Or you rotate a database password and suddenly you are redeploying configuration, which makes no sense if you think about it. Changing a password shouldn’t touch the file that says what port you listen on.

You might think this is a beginner mistake, but the whole industry is built on top of it. The SecretSpec folks did a great writeup here where they audited all 445 NixOS modules in nixpkgs that handle a real secret, and classified where each secret value actually ends up:

Where the secret value ends upModulesShare
Merged into a config file at runtime11025%
Delivered as an environment variable16136%
Left in a dedicated file opened by the app5813%
Loaded through systemd credentials5312%
Inlined into a config in the world-readable /nix/store429%
Passed as a command-line argument194%

The interesting number is 110. A quarter of the modules retrieve a secret safely, and then have to copy it back into a config file at runtime, because that is the only interface the application accepts. They do it with envsubst, sed, jq, replace-secret, or hand-rolled scripts that stitch the config back together at startup. The result can be secure, but now every single module carries its own fragile, security-sensitive glue code just to combine two things that shouldn’t have been together to begin with.

And this isn’t a Nix thing. The same workaround shows up everywhere as an entrypoint script in your Dockerfile, a Helm template, an init container, or that one CI step doing string interpolation into a yaml file that everyone is scared to touch. If you have ever written one of these, you already paid the price for an application that only accepts secrets through its config.

(Also, 42 of those modules can end up inlining secrets into /nix/store, which is world-readable. That one is just a straight-up leak.)

password_file >>>>> Password in Your Config

Section titled “password_file >>>>> Password in Your Config”

If you write applications, the fix is honestly simple: stop adding passwords and tokens to your config schema. Let secrets come in through their own door.

Say you started with this:

# config.yaml -- please do not do this
database:
host: db.internal
port: 5432
password: hunter2 # <-- this whole file is now a secret

One field turned the entire file radioactive. Now nobody can commit it, share it, or paste it in a bug report. Instead, accept a reference to the secret:

# config.yaml -- commit me.
database:
host: db.internal
port: 5432
password_file: /run/secrets/db_password

And in code, the change is honestly tiny:

password = config["database"]["password"] # before
password = Path(config["database"]["password_file"]).read_text().strip() # after

You have a couple of options for that door, roughly in order of how nice they are:

  • A password_file / token_file setting: the config points at a file, the file has restricted permissions, and the config itself stays boring and shareable.
  • systemd credentials: on Linux, LoadCredential= hands your service a file that only it can read, and the secret never touches the environment.
  • A narrowly scoped environment variable: the classic. Fine, but be aware env vars are inherited by every child process you spawn, and they love showing up in crash dumps and debug logs.
  • An external secret provider: the app asks Vault (or a keyring, or a cloud secret manager) directly at startup.

None of these are magically safe. Files still need correct permissions, env vars leak to children, and command-line arguments are visible to anyone running ps, which is probably why only 4% of those NixOS modules pass secrets that way. But what the separation does guarantee is that nobody has to build a second, secret-filled version of your config ever again.

AI Loves Shoving Secrets Into Your Config (Especially with Pydantic)

Section titled “AI Loves Shoving Secrets Into Your Config (Especially with Pydantic)”

Remember our friend from the intro who let AI push his keys? The problamatic part is that AI doesn’t just leak secrets, it happily writes them straight into your config for you. Ask any AI assistant to “add settings to my FastAPI app” and I can almost guarantee it gives you this:

from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "myapp"
debug: bool = False
database_url: str = "postgresql://admin:hunter2@localhost/db"
openai_api_key: str = ""
model_config = SettingsConfigDict(env_file=".env")

This looks clean, it type-checks, and it is exactly the disease from earlier. To be fair, it isn’t really the AI’s fault: it was trained on a million tutorials and quickstarts that all do this, so of course it repeats them with full confidence. But there is so much wrong packed into this innocent looking class:

  1. Config and secrets are fused into one class again, so the whole thing is radioactive.
  2. There is a default value with credentials in it, sitting in a .py file that is 100% going to git.
  3. Everything hangs off one .env file, which gravitates toward being committed “just this once, so it works on your machine”.
  4. Worst of all: pydantic models have a lovely repr. The first time you print(settings) while debugging, or an exception handler logs it, your OpenAI key is in plaintext in your logs. Bots grep logs too.

The good news is that pydantic already has the tools to do this right, the tutorials just never use them. SecretStr masks the value everywhere and makes you unwrap it on purpose. Splitting the class in two separates config from secrets, same idea as this whole article. And secrets_dir is pydantic’s built-in version of the password_file pattern: each field gets read from a file named after it, which happens to be exactly the format Docker secrets and systemd credentials hand you.

from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class AppConfig(BaseSettings):
# boring, shareable, commit the defaults proudly
app_name: str = "myapp"
debug: bool = False
database_host: str = "localhost"
class Secrets(BaseSettings):
# reads openai_api_key from /run/secrets/openai_api_key
model_config = SettingsConfigDict(secrets_dir="/run/secrets")
database_password: SecretStr
openai_api_key: SecretStr

Now print(secrets) gives you openai_api_key=SecretStr('**********'), and the only way to get the real value is an explicit secrets.openai_api_key.get_secret_value() at the exact call site that needs it. AppConfig is shareable configuration, Secrets has its own door, and it is the same amount of code.

One more practical tip: if you are letting an AI write your code, put this rule in its context. A line in your CLAUDE.md or AGENTS.md like “never put secrets in config classes or defaults; use SecretStr and a separate Secrets settings class” fixes the pattern at the source, instead of you catching it in review every time.

So we kicked secrets out of the config. They still need a home, and the annoying part is that the right home is different in every environment:

  • Your laptop: use your OS keyring (macOS Keychain, GNOME Keyring / KWallet). It is encrypted, it is already there, and it beats a plaintext .env sitting in your home directory. If you use 1Password, op run -- ./myapp will inject secrets straight from your vault into the process and nothing ever hits disk.
  • CI: use the platform’s secret store (GitHub Actions secrets, GitLab CI variables). They are masked in logs and scoped to the repo.
  • Production: this is where actual vaults shine. Vault / OpenBao, AWS Secrets Manager, GCP Secret Manager. You get access control, audit logs of who read what and when, and rotation without redeploying anything.

The problem is that now you have three environments with three different lookup mechanisms, and you end up writing glue for each one. This used to be the point where you either build a mini secrets platform or give up and go back to the .env file.

This is exactly the gap SecretSpec fills. You commit a secretspec.toml that declares what your app needs, and never the values:

[project]
name = "myapp"
[profiles.production]
DATABASE_URL = { description = "Postgres connection string" }
OPENAI_API_KEY = { description = "The key from our story earlier" }

Then every environment resolves the same declaration from whatever provider it wants: your laptop uses the keyring, CI uses env variables, production uses Vault or 1Password. Running it is one command:

Terminal window
secretspec run -- ./myapp

There are also SDKs (Python, Rust, Go, Node, and more) if you want the app to resolve secrets directly instead of receiving them as env vars. Your config declares what it needs, each environment decides where the value lives, and you never write the glue yourself.

Because statistically, some of you reading this already have:

  1. Rotate the key. Now. Not after reading this article. Deleting the commit or force-pushing does nothing, remember the two minutes thing? The bots already have it, and the only way to actually kill a leaked key is to revoke it.
  2. Turn on push protection. GitHub’s secret scanning push protection will straight up refuse the push if it spots a known key format. It is free and it has saved me from myself.
  3. Add a scanner to pre-commit. gitleaks or trufflehog will catch a secret before it ever enters your history, which matters because git history is forever.
  4. Set spending limits. If your OpenAI key does leak, a 50 dollar hard cap is a much better morning than our friend from the intro had.

The .gitignore advice everyone gives you isn’t wrong, it is just the last line of defense. The real fix is structural. If a quarter of NixOS modules have to write custom glue because applications refuse to separate them, the least we can do in our own apps is not add to the pile.

And seriously, go rotate that key.