Skip to content

07 — API key security and going further

Duration: 10 min Prerequisites: chapter 02 (you have a key in .env).

A leaked OpenAI API key gives an attacker:

  • Full access to your account (all models, no per-key restriction unless you set one).
  • The ability to burn your billing balance — bots scan public repos within seconds for new sk-... strings.
  • The ability to read your usage history.

It does not give them access to:

  • Your bank CSV (it lives on your machine).
  • Your SQLite database (also local).
  • Past chat history (OpenAI doesn’t expose this via the API).

So the primary asset to protect is the key itself — and your bill.

The repo ships with:

csv-llm-openai/.gitignore
.env
.cache/
__pycache__/

And csv-llm-openai/.env.example (committed) is the template:

OPENAI_API_KEY=

You copy it to .env (not committed) and fill in the real key.

Verification:

Terminal window
cd csv-llm-openai
git status
# .env must NOT appear in the output

Don’t reuse one key everywhere. Create:

  • csv-llm-openai-dev (your laptop, low monthly limit).
  • csv-llm-openai-prod (if you deploy somewhere, higher limit).
  • csv-llm-openai-ci (for GitHub Actions, separate budget).

If one leaks, you only revoke that one. The rest keep working.

In platform.openai.com → Settings → Limits, set:

  • Hard limit: $10/month (refuses requests past this).
  • Soft limit: $5/month (email you a warning).

For a course like this, $10 is way more than you’ll ever need. But it caps the blast radius if a key leaks.

Layer 4 — restrict the key’s permissions

Section titled “Layer 4 — restrict the key’s permissions”

OpenAI lets you create restricted keys that can only call specific endpoints. For this app you only need:

  • model.read (list models).
  • model.write (call chat completions).

Other scopes (file uploads, fine-tuning, batch jobs) can be disabled on the key. If a bot grabs your key and tries to fine-tune a model, the call fails.

Every 90 days, regenerate your key:

  1. Create a new key.
  2. Update your .env (and any deployment).
  3. Verify everything still works.
  4. Delete the old key in the dashboard.

Even if you can’t see a leak, rotation closes the window of any breach you haven’t noticed yet.

Don’t print() your messages in production

Section titled “Don’t print() your messages in production”

The messages list contains your transaction descriptions and amounts. If your app logs print(messages) and that ends up in a public log aggregator (CloudWatch, Datadog, GCP Logs), your data is now there for the duration of the log retention.

Use a structured logger with PII filters, or redact merchant names before logging.

streamlit run app.py --server.port 8505 binds to 0.0.0.0 by default in some setups. Always pass --server.address 127.0.0.1 (or use the start.ps1 helper which already does this) unless you’ve fronted Streamlit with auth.

If you containerize the app and accidentally include .cache/csv-llm-openai.sqlite in the image, you’ve leaked all 734 transactions to anyone who pulls the image. .dockerignore should have .cache/. The repo’s .gitignore already does.

Right now the app loads one CSV at a time. Extend:

  • A profiles table in SQLite: (profile_id, name, owner, created_at).
  • A sidebar dropdown to pick a profile.
  • Filter all dashboards and chat by the selected profile.

Use case: a household tracking 2 cards + 1 joint account.

Add a second tool to the agent:

FORECAST_TOOL = {
"type": "function",
"function": {
"name": "forecast_next_month",
"description": "Predict next-month spending in a given category using a moving average of the last 3 months.",
"parameters": {
"type": "object",
"properties": {"category": {"type": "string"}},
"required": ["category"],
},
},
}

The LLM now has 2 tools. “What will I likely spend on groceries next month?” triggers forecast_next_month("Groceries"), not query_sql. The LLM picks.

Replace client.chat.completions.create(...) with the streaming variant:

stream = client.chat.completions.create(model=..., messages=..., tools=[...],
stream=True)
for chunk in stream:
if chunk.choices[0].delta.content:
st.write(chunk.choices[0].delta.content)

The answer appears word-by-word, much nicer UX. Caveat: handling tool_calls in a stream is trickier (you have to buffer the function name and arguments across chunks).

Use Ollama for cheap, short, common queries; OpenAI for complex multi-join ones. A trivial heuristic (“does the question contain ‘compare’ or ‘forecast’? → OpenAI; otherwise → Ollama”) gets you 80% of the cost-savings.

  • .env + .gitignore + separate keys + monthly limit + rotation = 5 layers, almost no work.
  • Don’t log raw messages, don’t bind to 0.0.0.0, don’t ship the SQLite cache.
  • Build next: multi-profile, forecasting tool, streaming UI, hybrid router.
  • 100% of the agent loop logic transfers to those extensions — you’ve already learned the hard part.

Back to the Project 2 overview