DEV Community

Sir Max
Sir Max

Posted on

The API Key Security Mistake I See in Every Codebase (And How to Fix It)

The API Key Security Mistake I See in Every Codebase (And How to Fix It)

I've reviewed a lot of codebases over the years — open source projects, startup repos, enterprise code, you name it. And there's one mistake I see everywhere. It's not a logic bug. It's not a performance issue. It's an API key sitting in plain text in a config file, committed to git.

Last month I audited a friend's SaaS repo. Their Stripe secret key was in config.py, pushed to a public GitHub repo, and had been there for 11 months. Nobody noticed. The key still worked. They'd been running production charges through that key the whole time.

Here's the pattern I see over and over — and the fix I use in every project now.


The Mistake Everyone Makes

It usually starts innocently. You're prototyping, hardcoding an API key to test something:

# config.py
OPENAI_API_KEY = "sk-proj-abc123..."
STRIPE_SECRET_KEY = "sk_live_xyz789..."
Enter fullscreen mode Exit fullscreen mode

Then you forget to remove it. Or you think "I'll clean this up later." Then you commit, push, and it's part of your git history forever.

The worst part? Removing it from the current commit isn't enough. Git history is permanent. Anyone who cloned your repo in those 11 months has the key. Rotating the key is the only real fix.

And it's not just config files. I've found secrets in:

  • Test fixtures{"api_key": "sk-live-..."} in a JSON test file, committed to make CI pass
  • Documentation — "Set your API key like this:" followed by a real key someone copy-pasted
  • Error messagesraise AuthError(f"Invalid key: {api_key}") that dumps the key in stack traces
  • DockerfilesENV STRIPE_KEY=sk_live_... baked into an image pushed to Docker Hub

The Fix: Three Layers of Defense

I use three layers now. Each one catches what the previous might miss.

Layer 1 — Environment Variables (The Obvious One)

Never read secrets from files that get committed. Use environment variables:

import os

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
if not OPENAI_API_KEY:
    raise RuntimeError("OPENAI_API_KEY not set")
Enter fullscreen mode Exit fullscreen mode

But this alone isn't enough. I've seen people commit .env files. I've seen people hardcode fallback values. I've done both myself.

A better pattern is to validate at startup — loudly — so misconfiguration fails fast:

# config.py
import os
from dataclasses import dataclass

@dataclass
class AppConfig:
    openai_api_key: str
    database_url: str
    redis_url: str = "redis://localhost:6379"  # Has default — OK to log

    @classmethod
    def from_env(cls):
        required = {
            "OPENAI_API_KEY": "OpenAI API key for LLM calls",
            "DATABASE_URL": "PostgreSQL connection string",
        }
        missing = []
        for var, desc in required.items():
            if not os.getenv(var):
                missing.append(f"  {var}{desc}")
        if missing:
            raise RuntimeError(
                "Missing required environment variables:\n" + "\n".join(missing)
            )
        return cls(
            openai_api_key=os.getenv("OPENAI_API_KEY"),
            database_url=os.getenv("DATABASE_URL"),
            redis_url=os.getenv("REDIS_URL", "redis://localhost:6379"),
        )
Enter fullscreen mode Exit fullscreen mode

Now you get a clear error on startup instead of a confusing NoneType has no attribute crash 30 minutes later.

Layer 2 — Pre-commit Hook (The Enforcer)

Install a pre-commit hook that scans for secrets before they hit your repo:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks
Enter fullscreen mode Exit fullscreen mode

Gitleaks catches AWS keys, GitHub tokens, JWT secrets, private keys — over 150 patterns out of the box. It runs in milliseconds. There's no reason not to use it.

I also add a simple custom check for patterns Gitleaks might miss:

#!/bin/bash
# .git/hooks/pre-commit (supplementary)
PATTERNS=(
    'sk-[a-zA-Z0-9]{20,}'
    'AKIA[A-Z0-9]{16}'
    'ghp_[a-zA-Z0-9]{36}'
    'xox[bprsa]-[a-zA-Z0-9-]+'
)
for pattern in "${PATTERNS[@]}"; do
    if git diff --cached | grep -qE "$pattern"; then
        echo "🚫 Secret pattern detected: $pattern"
        echo "   Check your staged files before committing."
        exit 1
    fi
done
Enter fullscreen mode Exit fullscreen mode

Layer 3 — Runtime Log Masking (The Safety Net)

Pre-commit hooks only catch commits. What about runtime leaks? I once debugged a production incident where our error handler dumped the full HTTP request — headers, body, everything — into CloudWatch. API keys, session tokens, user emails, all sitting in plain text in our logs.

Here's the fix I add to every project now:

import re
import logging

class SecretMasker(logging.Filter):
    """Strip secrets from log output before they're written."""

    SECRET_PATTERNS = [
        (r'sk-[a-zA-Z0-9]{20,}', '[REDACTED_API_KEY]'),
        (r'(?i)bearer\s+[a-zA-Z0-9\-_\.]+', 'Bearer [REDACTED]'),
        (r'ghp_[a-zA-Z0-9]{36}', '[REDACTED_GITHUB_TOKEN]'),
        (r'(?i)authorization:\s*[^\s,]+', 'Authorization: [REDACTED]'),
    ]

    def filter(self, record):
        if isinstance(record.msg, str):
            for pattern, replacement in self.SECRET_PATTERNS:
                record.msg = re.sub(pattern, replacement, record.msg)
        return True

# Install at the root logger — catches everything
logging.getLogger().addFilter(SecretMasker())
Enter fullscreen mode Exit fullscreen mode

This has saved me twice: once when a misconfigured error handler dumped the full request context, and once when a teammate added print(api_key) for debugging and forgot to remove it.

What to Do If You Already Leaked a Key

It happens to everyone eventually. Here's the checklist:

  1. Rotate the key immediately. Go to the provider dashboard, revoke the old key, generate a new one. This is step one — everything else can wait.

  2. Remove from git history (optional but recommended):

   git filter-branch --force --index-filter \
     "git rm --cached --ignore-unmatch path/to/config.py" \
     --prune-empty --tag-name-filter cat -- --all
Enter fullscreen mode Exit fullscreen mode

But honestly? If the repo was public, assume the key was scraped by bots within minutes. Rotation is the only guarantee.

  1. Add all three layers above so it doesn't happen again.

  2. Check your logs. If you use a logging service (Datadog, CloudWatch, Papertrail), search for the leaked key. If it appears in logs, those logs need to be scrubbed or rotated.

  3. Check your Docker images. docker history will show every layer, including the one where you ran ENV SECRET=.... If the key was baked into an image, that image needs to be deleted from your registry and replaced.

The CI/CD Dimension

One thing people forget: secrets leak in CI pipelines too. A common pattern I see:

# .github/workflows/deploy.yml — DON'T DO THIS
- name: Deploy
  run: |
    curl -X POST https://api.example.com/deploy \
      -H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}"
Enter fullscreen mode Exit fullscreen mode

If this step fails, GitHub Actions prints the full command — including the expanded secret — in the logs. Anyone with read access to your repo sees it.

The fix: never pass secrets as CLI arguments. Use environment variables or stdin:

# Safer approach
- name: Deploy
  env:
    DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
  run: |
    curl -X POST https://api.example.com/deploy \
      -H "Authorization: Bearer $DEPLOY_TOKEN"
Enter fullscreen mode Exit fullscreen mode

GitHub Actions masks environment variable values in logs, but not command-line arguments.

The .env.example Pattern

When you use environment variables, teammates need to know which ones to set. Create a .env.example:

# .env.example — copy to .env and fill in values
# Never commit .env files to version control
OPENAI_API_KEY=
STRIPE_SECRET_KEY=
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
REDIS_URL=redis://localhost:6379
Enter fullscreen mode Exit fullscreen mode

Notice: no real values. Just the names with empty values. This file is safe to commit and tells everyone what they need.

Then add .env to .gitignore:

# .gitignore
.env
.env.local
.env.*.local
Enter fullscreen mode Exit fullscreen mode

The Bottom Line

Secrets management isn't hard — it's just easy to ignore until something goes wrong. The three layers I described (environment variables + pre-commit scanning + runtime log masking) take about 20 minutes to set up, and they'll save you from the kind of mistake that can cost real money — or worse, customer data exposure.

Start with environment variables. Add a pre-commit hook. Add runtime masking. Do it today.

Your future self (and your users) will thank you.


What's your approach to secrets management? I'm always looking for better patterns — drop a comment below.

Top comments (0)