DEV Community

Cover image for I Audited My Own Side Projects for Security Issues — Here's What I Found
JustJinoIT
JustJinoIT

Posted on

I Audited My Own Side Projects for Security Issues — Here's What I Found

I recently did a full security audit across all my side projects — FastAPI backends, Telegram bots, a PWA, and a Streamlit app. I wasn't expecting much. I thought I'd been careful.

I was wrong.

Here's every issue I found, why I made each mistake, and how to fix them. This isn't a theoretical checklist — these are bugs I actually shipped to production.


1. Authentication Bypass via Empty Secret (Critical)

What I wrote

_API_SECRET = os.environ.get('API_SECRET_KEY', '')

def verify_api_key(x_api_key: str = Header(default='')):
    if _API_SECRET and x_api_key != _API_SECRET:  # ← the bug
        raise HTTPException(status_code=401)
Enter fullscreen mode Exit fullscreen mode

Notice the condition: if _API_SECRET and .... If API_SECRET_KEY isn't set in the environment, _API_SECRET is an empty string — falsy — so the entire check is skipped. Every request passes as authenticated.

Why I wrote it this way

I wanted graceful fallback during local development so I wouldn't need to set env vars every time. The problem: that same "graceful fallback" made it to production, and when I forgot to set API_SECRET_KEY on the server, the entire API was open.

The fix

_API_SECRET = os.environ.get('API_SECRET_KEY', '')

def verify_api_key(x_api_key: str = Header(default='')):
    if not _API_SECRET:
        raise HTTPException(status_code=500, detail='API_SECRET_KEY not configured')
    if not secrets.compare_digest(x_api_key, _API_SECRET):
        raise HTTPException(status_code=401, detail='Unauthorized')
Enter fullscreen mode Exit fullscreen mode

Missing secret = 500 error, not open access. Also switched to secrets.compare_digest() to prevent timing attacks.

Lesson: Never make authentication conditional on whether the secret is configured. Missing config should be a hard failure.


2. Secrets Committed to Git History (Critical)

I found real API keys in git history — not in the current code, but in commits from months ago when I was "just testing."

# How to check
git log --all -p | grep -E "sk-ant-api03-[A-Za-z0-9_-]{20,}"
git log --all -p | grep -E "AIzaSy[A-Za-z0-9]{20,}"
Enter fullscreen mode Exit fullscreen mode

Why this happens

Early in a project, you hardcode a key to test quickly, commit it, then move it to .env in a later commit thinking the problem is solved. But git keeps every commit forever. Anyone with repo access (or if you ever make the repo public) can find these old keys.

The fix

# Remove a file from entire history
pip install git-filter-repo
git-filter-repo --path .env --invert-paths --force
git push --force-with-lease origin main
Enter fullscreen mode Exit fullscreen mode

Also: immediately revoke and reissue any exposed key. Cleaning git history doesn't undo any exposure that already happened.

Lesson: Treat any key committed to git as compromised, even if you "fixed it" in a later commit.


3. Debug Endpoints in Production (High)

I had endpoints like this deployed to my production server:

@app.get('/debug/config')
async def debug_config():
    return {
        'supabase_url': settings.supabase_url,
        'environment': settings.env,
        'connected_services': [...]
    }

@app.get('/debug/db-test')
async def debug_db():
    # runs a live query and returns raw results
    ...
Enter fullscreen mode Exit fullscreen mode

Why this happens

Debug endpoints are lifesavers during development. You add them when you're stuck, solve the problem, and forget to remove them. They don't cause errors, so nothing reminds you they exist.

The fix

Delete them. If you need runtime introspection, put it behind authentication or use a proper admin interface.

# Check for common debug patterns before every deploy
grep -rn '@app.get.*debug\|@app.post.*debug\|@app.get.*admin/exec' app/
Enter fullscreen mode Exit fullscreen mode

Lesson: Add debug endpoint removal to your deployment checklist. Or better — never create them in the first place.


4. Error Messages Leaking Internal Details (High)

# What I had
except Exception as e:
    return JSONResponse({"error": str(e)}, status_code=500)
Enter fullscreen mode Exit fullscreen mode

This returns things like:

  • FATAL: password authentication failed for user "postgres"
  • [Errno 2] No such file or directory: '/home/ubuntu/app/config.json'
  • Module 'xyz' version 1.2.3 has no attribute 'connect'

An attacker can use this to understand your infrastructure, figure out which libraries you're using, and target known CVEs.

Why I wrote it this way

Same reason as #1 — debug convenience. str(e) gives you instant visibility when testing. The mistake was not adding a layer between internal errors and HTTP responses.

The fix

import logging
logger = logging.getLogger(__name__)

except Exception as e:
    logger.error(f"Scout error: {e}", exc_info=True)  # full details in logs
    return JSONResponse({"error": "internal server error"}, status_code=500)
Enter fullscreen mode Exit fullscreen mode

Log everything internally, return nothing externally.

Lesson: Your logs are for you. HTTP error responses are for the client. Keep these completely separate.


5. XSS via Unescaped innerHTML (High)

In my frontend, I was doing:

// Rendering data from an API
articles.forEach(article => {
    container.innerHTML += `
        <h3>${article.title}</h3>
        <p>${article.summary}</p>
        <a href="${article.url}">Read more</a>
    `;
});
Enter fullscreen mode Exit fullscreen mode

If a malicious article gets into the database with a title like <script>stealCookies()</script>, it executes in every user's browser.

Why this happens

Template literals feel like string formatting, not HTML rendering. When you write ${article.title}, it doesn't feel dangerous — it's just a variable. But the browser sees HTML and executes whatever it finds.

The fix

const esc = s => (s || '')
  .replace(/&/g, '&amp;')
  .replace(/</g, '&lt;')
  .replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;');

const safeUrl = u => /^https?:\/\//.test(u || '') ? u : '#';

// Now safe
container.innerHTML += `
    <h3>${esc(article.title)}</h3>
    <p>${esc(article.summary)}</p>
    <a href="${safeUrl(article.url)}" rel="noopener noreferrer">Read more</a>
`;
Enter fullscreen mode Exit fullscreen mode

Or better — use textContent and createElement instead of innerHTML where possible.

Lesson: Every time you write innerHTML, mentally replace it with "I am executing arbitrary code." Then decide if you've sanitized properly.


6. Missing Rate Limits on AI-Powered Endpoints (High)

I had endpoints that call Claude/Gemini directly:

@app.post('/analyze')
async def analyze(item: Item, _: None = Depends(verify_api_key)):
    result = await claude.messages.create(...)  # $0.015 per call
    return result
Enter fullscreen mode Exit fullscreen mode

No rate limiting. An attacker with a stolen API key (or even someone who finds the endpoint through enumeration) could make thousands of calls and rack up a serious bill.

The fix

from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter

@app.post('/analyze')
@limiter.limit('10/minute')
async def analyze(request: Request, item: Item, _: None = Depends(verify_api_key)):
    ...
Enter fullscreen mode Exit fullscreen mode

Lesson: Authentication prevents unauthorized access. Rate limiting prevents authorized-but-abusive access. You need both.


7. CORS Wildcard in Production (Medium)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # ← allows any website
    ...
)
Enter fullscreen mode Exit fullscreen mode

Why this is risky even with authentication

If your API key is stored in frontend JavaScript (which it often is for SPAs), a malicious website could use XSS on any site to steal the key from the user's browser and call your API. CORS is your browser-level firewall.

The fix

import os

ALLOWED_ORIGINS = os.environ.get('ALLOWED_ORIGINS', '*').split(',')

app.add_middleware(
    CORSMiddleware,
    allow_origins=ALLOWED_ORIGINS,
    allow_methods=['GET', 'POST'],
    allow_headers=['X-API-Key', 'Content-Type'],
)
Enter fullscreen mode Exit fullscreen mode
# .env (production)
ALLOWED_ORIGINS=https://yourapp.vercel.app
Enter fullscreen mode Exit fullscreen mode

Lesson: allow_origins=["*"] is fine for local dev. Never ship it.


8. Temporary Files Not Cleaned Up (Medium)

# Original code
with tempfile.NamedTemporaryFile(suffix='.xlsx', delete=False) as tmp:
    tmp.write(uploaded_file.read())
    tmp_path = tmp.name

process_file(tmp_path)  # if this throws, tmp file leaks forever
Enter fullscreen mode Exit fullscreen mode

If process_file() raises an exception, the temp file is never deleted. On a long-running server, these accumulate. Worse — if the files contain sensitive user data, they sit on disk indefinitely.

The fix

tmp_path = None
with tempfile.NamedTemporaryFile(suffix='.xlsx', delete=False) as tmp:
    tmp.write(uploaded_file.read())
    tmp_path = tmp.name
try:
    process_file(tmp_path)
finally:
    if tmp_path and os.path.exists(tmp_path):
        os.unlink(tmp_path)
Enter fullscreen mode Exit fullscreen mode

Lesson: Any code path that creates a file must also be responsible for deleting it. finally guarantees cleanup even on exception.


The Checklist I Now Follow

After this audit, I created a mandatory checklist that applies to every project from day one:

Before writing any code:

  • [ ] .gitignore created with .env, *.key, sessions/, credentials.json
  • [ ] .env.example created (no real values)

For every endpoint:

  • [ ] Authentication added (never conditional on secret being present)
  • [ ] Error responses return generic messages, not str(e)
  • [ ] Rate limits on AI-powered or expensive operations

For every frontend:

  • [ ] All innerHTML usage uses escaped values
  • [ ] href values go through URL validation
  • [ ] External links have rel="noopener noreferrer"

Before every commit:

git diff --cached | grep -E "sk-ant-api03|AIzaSy|password\s*=\s*[^\$\{]"
git diff --cached --name-only | grep -E "^\.env"
Enter fullscreen mode Exit fullscreen mode

Before every deploy:

pip-audit -r requirements.txt
npm audit  # if applicable
Enter fullscreen mode Exit fullscreen mode

Why Did I Miss All This?

Honestly: I treated security as a separate phase rather than a built-in constraint.

The pattern was always the same:

  1. Get the feature working quickly
  2. Add a TODO comment: "clean this up later"
  3. Later never comes
  4. Deploy with the "temporary" solution

Security issues aren't added intentionally — they're the residue of "I'll fix it when I have time." The only way I've found to actually prevent them is to make security checks happen at natural checkpoints: before commit, before deploy, and at project start.

The bugs are boring. The cost of fixing them after the fact is not.


Running FastAPI or similar backends? I'd recommend checking your own projects with these patterns. The auth bypass one (if _SECRET and key != _SECRET) is surprisingly common.

Top comments (0)