A single AI key shared by every user of a small app is a bad deal. The free tier runs out, one noisy user consumes the quota, and the owner pays for everyone. Letting each reader bring their own key fixes that, but it creates a different problem: now the app stores secrets that belong to other people.
ARN.IO is a reading app with a book assistant. Readers can ask about a summary, a chapter or a theme. I added a bring-your-own-key setting this week. The rule is simple: a key can go in, but it never comes back out.
What the user sees
The account page has an AI tab with four fields:
- Provider
- Model
- API base URL
- API key
The provider list covers Groq, OpenAI, OpenRouter, DeepSeek, Google Gemini, Mistral and a custom OpenAI-compatible endpoint. Choosing a preset fills in the base URL and a default model. The key field is a password input and is never prefilled, even when a key is already saved.
After saving, the page shows which provider is active and offers a Remove Key button. The settings endpoint returns hasKey: true and nothing that can be used to recover the secret.
The key is write-only
The database table is deliberately small:
CREATE TABLE IF NOT EXISTS user_ai_settings ( user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, provider TEXT NOT NULL, base_url TEXT NOT NULL, model TEXT NOT NULL, api_key_encrypted TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); The key is encrypted with AES-256-GCM before it reaches the row. The encryption key lives in an environment variable, not in PostgreSQL. The stored value is the initialization vector, the authentication tag and the ciphertext, joined with dots:
export function encryptSecret(value) { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv("aes-256-gcm", getKey(), iv); const encrypted = Buffer.concat([ cipher.update(String(value), "utf8"), cipher.final(), ]); const tag = cipher.getAuthTag(); return [ iv.toString("base64"), tag.toString("base64"), encrypted.toString("base64"), ].join("."); } GCM was chosen because it gives confidentiality and integrity in one step. A modified ciphertext fails to decrypt instead of returning garbage. The key is 32 bytes and is validated at startup. If the environment value is missing or the wrong length, saving a key fails with a configuration error instead of writing something unreadable.
There is one operational consequence. If the encryption key is rotated, every saved user key becomes unreadable. The app treats that as a failed key, falls back to the built-in assistant, and the user has to enter the key again. In a larger system I would store a key version alongside the ciphertext so old keys can be decrypted during a rotation window.
The API is intentionally boring
The settings endpoint returns only the configuration a browser needs to render the form:
{ "settings": { "provider": "groq", "baseUrl": "https://api.groq.com/openai/v1", "model": "llama-3.3-70b-versatile", "hasKey": true } } There is no endpoint that returns the key. The only time the server decrypts it is immediately before a provider request, and the decrypted value stays in memory for that request.
Saving uses PUT /api/auth/ai-settings. If the user leaves the key blank for an existing setup, the server keeps the stored ciphertext. That makes it possible to change the model without asking for the secret again.
Changing the provider with a blank key is rejected. A Groq key is not an OpenAI key, and silently pairing the old secret with a new service would only produce confusing failures.
Provider presets, not provider lock-in
The server stores three values per user: the provider id, the base URL and the model. The provider id is mostly a label and a source of defaults. The request path is the same for all of them because every preset uses an OpenAI-compatible chat completions endpoint.
| Provider | Base URL |
|---|---|
| Groq | https://api.groq.com/openai/v1 |
| OpenAI | https://api.openai.com/v1 |
| OpenRouter | https://openrouter.ai/api/v1 |
| DeepSeek | https://api.deepseek.com/v1 |
| Google Gemini | https://generativelanguage.googleapis.com/v1beta/openai |
| Mistral | https://api.mistral.ai/v1 |
The custom option accepts any public HTTPS endpoint with the same shape. That keeps the door open for providers I have not listed without turning the server into a provider-specific integration for each one.
A custom base URL is an SSRF risk
This was the part that needed the most care. A user-supplied base URL means the server will make an outbound request to an address chosen by that user. Without checks, someone could point it at http://127.0.0.1:5432 or a cloud metadata service.
The validator does five things before a custom URL is saved:
- It requires HTTPS.
- It rejects URLs with embedded credentials, query strings or fragments.
- It resolves the hostname and checks every returned address.
- It blocks private IPv4 ranges, private IPv6 ranges, localhost,
.localnames and metadata hosts. - It rejects the request if the hostname cannot be resolved.
The provider request also uses redirect: "error" and a timeout. A redirect to an internal address is not followed.
This is not a complete SSRF defense. A DNS rebinding attack can resolve to a public address during validation and a private address during the request. A stricter design would resolve once, pin the address, and connect to that exact address while keeping the host header. For a small reading app, the validation plus no redirects plus a short timeout is a reasonable barrier. I would not treat it as sufficient for a high-risk multi-tenant platform without the address pinning step.
How a request chooses a key
The chat path has four possible outcomes:
- The user's saved key is decrypted and used.
- If the user has no key, the server's own key is used.
- If the user's key fails and a server key exists, the server key is tried.
- If neither works, the built-in assistant answers.
The built-in fallback is not a language model. It handles summaries from the book description, chapter lists, author questions and a short excerpt from the current chapter. That keeps the assistant usable when a provider is down or a key has expired.
Key errors are logged on the server, not shown in the chat. A rejected key should not turn the reading experience into a configuration lesson. The user sees a normal answer and can fix the key in the account page when they choose to.
What I would change next
The current version is a good first pass, but there are four things I would add before calling it finished:
- A test button. Save the key, then send one small request and report whether the provider accepted it. Right now the first real chat is the test.
- Per-user rate limits. A user key protects the owner's quota, but it does not protect the app from a user who sends hundreds of requests in a minute.
- Key versioning. Store an encryption key version so a rotation does not invalidate every saved key at once.
- A managed secret store. Application-level AES-GCM is fine for a small deployment, but a dedicated secret manager reduces the number of places the master key can appear.
The principle I keep coming back to is simple. A user's API key is a loan, not a copy. The app should use it for the request in front of it, store only what it must, and make removal easy.
ARN.IO is live at https://arnio.mukuvi.tech. The AI tab is in the profile page.
