Storing User API Keys When Your AI App Lets People Bring Their Own
The moment your AI app has more than one user, someone will ask to use their own OpenAI or Anthropic key, and you have a storage problem you didn't have yesterday. Environment variables are the right place for your application's own secrets and the wrong place for your users' — they're app-scoped configuration, they're stored and displayed as plain text in the panel, and changing one recreates your container. Per-user keys need real storage: encrypted with a key your app holds, written to a volume, never logged. But before any of that, the useful question is whether you should be holding them at all, because two of the three designs available don't require it.
Decide whether to store anything
Three models, in increasing order of how much responsibility you take on:
You use your own key and charge for it. Users never hand you a credential. You carry the API cost and price it in, which is simpler to build, simpler to explain, and gives you real control over spend. It is also the only model where a database breach doesn't involve anyone else's account.
The key lives for one session. The user pastes it, you keep it in memory for that request or that browser session, and it's gone. Nothing at rest, nothing to leak. This works well for a tool people use occasionally and badly for anything that runs in the background — your bot can't use a key it doesn't have at 3am.
You store it. Necessary if the app does work on the user's behalf while they're away: scheduled summaries, a bot answering in a channel, anything long-running. This is the one that needs the rest of this post.
Pick deliberately. Most small AI apps default to storing because it's the obvious implementation, when the first option would have been less work and less risk.
Environment variables are not a per-user secret store
Worth being precise about what they are, because people reach for them by reflex.
An environment variable is a name and a value attached to your application. That's the whole model — there's no per-value encryption, no secret flag, and no masking in the panel, so anyone who can open your app's settings can read the value. Since an app has exactly one owner, that's you, which is fine for your API key.
It doesn't extend to users. There's no way to scope a variable to one person, you'd need one variable per user, and changing any of them recreates the container — so adding a user would restart the app for everyone. Environment variables are how your process learns its own configuration at startup. Anything that varies per user belongs in storage.
What they're genuinely good at is holding the one key that protects everything else.
Encrypt with a key that comes from outside your code
The shape is small:
- A master key in an environment variable — random, at least 32 bytes, generated once by you.
- Authenticated encryption with it — AES-GCM or a library's high-level "secret box". Not a hand-rolled XOR, not base64, which is encoding rather than encryption.
- Store the ciphertext and its nonce in your database on a volume. Never the plaintext.
- Decrypt only when you're about to make the call, and don't hold the result longer than that.
# The master key comes from the environment, never from the code or the database.
MASTER_KEY = base64.b64decode(os.environ["KEY_ENCRYPTION_KEY"])
The property this buys you is separation. Your volume holds ciphertext, your environment holds the key, and someone who obtains one without the other has nothing useful. That's the entire point, so don't undo it by writing the master key into the same database.
The trap: a key generated at startup
This is the specific way it goes wrong here, and it's the same failure that logs everyone out on every deploy.
If your code does the convenient thing — generate a random key at startup when none is configured — it works perfectly in testing. Then you deploy. The container is recreated on every deploy and every configuration change, a new key is generated, and every stored key becomes permanently undecryptable. Not "needs re-entering" — cryptographically gone.
Worse, it's silent. Nothing errors at deploy time; it errors later, per user, when they next use the app.
Generate the key once, put it in an environment variable, and make missing configuration a hard startup failure rather than a fallback. An app that refuses to boot is a much better outcome than one that quietly destroys data.
Don't log them, and check what you're logging
Keys leak through logs far more often than through databases.
- Never log request bodies on the routes that accept a key. Not at debug level, not temporarily.
- Watch your error handler. Exception reporters that attach local variables will happily capture a decrypted key from the frame that was using it.
- Redact before writing, not after.
Container logs are plain text and readable in the panel. They're also capped and rotated, which sounds like a mitigation and isn't — the key sits there until enough traffic pushes it out, and you don't control how long that takes. Treat anything written to a log as permanently disclosed.
Validate on entry, and expect revocation
Two behaviours that turn a fragile feature into a solid one:
Validate when the key is saved. Make one cheap call to the provider and confirm it works. A key that fails at save time is a form error the user can fix immediately; the same key stored unvalidated becomes a mysterious failure at 3am in a background job.
Handle revocation as normal, not exceptional. Users rotate and delete keys without telling you. When a call comes back unauthorised, mark the stored key invalid, stop retrying it, and tell the user — otherwise you'll hammer a dead credential forever and they'll experience it as your app being broken.
Store a little metadata alongside the ciphertext — when it was added, when it last worked — so both of you can see the state without anyone reading the secret.
Deletion should be real
Give users a delete button that removes the row, and make sure it actually does.
The absence of platform backups helps you here, unusually. Volumes are not snapshotted, so a deleted row is genuinely gone rather than living on in a backup you'd have to reason about. That's the flip side of the risk described in backing up your volumes — and if you do add your own backups, remember they now contain encrypted keys and inherit every obligation that comes with them.
Say plainly what you store and what happens on delete. For a small app, three sentences in a settings page is enough, and it's the difference between a feature people trust and one they work around.
FAQ
Where should I store my users' API keys?
Encrypted in your own database on a volume, using a master key supplied through an environment variable. Not in environment variables themselves.
Can I use environment variables for per-user secrets?
No. They're app-scoped, stored and shown as plain text in the panel, and changing one recreates the container — so adding a user would restart your app.
What's the safest option overall?
Not storing keys at all. Use your own key and charge for usage, or hold the user's key only for the duration of a session.
Why did all my stored keys stop decrypting after a deploy?
Your encryption key was probably generated at startup. The container is recreated on every deploy, so the new key can't decrypt old data. Supply it from an environment variable and fail startup if it's missing.
Do I need a secrets manager?
Not for a small app. An environment variable holding one master key, plus encrypted rows on a volume, is a sound design.
What if a user revokes their key?
Detect the unauthorised response, mark it invalid, stop retrying, and notify them. Assume revocation happens without warning.
Wrapping up
Accepting other people's credentials changes what your side project is: a bug that used to cost you an afternoon now costs someone else their account. That's not a reason to avoid the feature, but it is a reason to build it in the order above — decide whether you need to store anything, keep the master key in the environment and the ciphertext on the volume, fail loudly when the key is missing, and be ruthless about logs. It's perhaps an hour of work more than the naive version, and it's the difference between a leak being an incident and a leak being nothing at all.