DEV Community

Cover image for I Accidentally Exposed My API Keys to 50,000 Users (And How You Can Avoid My $3,000 Mistake)
Werliton Silva
Werliton Silva

Posted on

I Accidentally Exposed My API Keys to 50,000 Users (And How You Can Avoid My $3,000 Mistake)

It was 2 AM on a Tuesday. My phone wouldn't stop buzzing.

"Dude, your AWS bill is insane this month." - My CTO

I rubbed my eyes, opened the AWS console, and my heart dropped. $2,847.63 in unexpected charges. Someone had discovered my API keys buried in my React bundle and was mining cryptocurrency using my S3 buckets.

The worst part? I thought I was being "secure" by using environment variables.

Spoiler alert: I wasn't.

kyes


The Brutal Truth Nobody Tells Junior Devs

Here's what they don't teach you in bootcamp: Everything you ship to the frontend is PUBLIC.

I mean everything.

That .env file? Public.

That "hidden" API key? Public.

That token you "obfuscated"? Still public.

Let me show you exactly what I did wrong (and what you might be doing right now).


My Original "Secure" Code (That Wasn't Secure At All)

// ❌ What I thought was secure
// .env.production
REACT_APP_STRIPE_SECRET=sk_live_51H...
REACT_APP_AWS_KEY=AKIAIOSFODNN7EXAMPLE

// src/checkout.js
const stripe = Stripe(process.env.REACT_APP_STRIPE_SECRET)
const payment = await stripe.charges.create({
  amount: 5000,
  currency: 'usd',
  source: token
})
Enter fullscreen mode Exit fullscreen mode

The Problem:

When Webpack builds your app, it literally replaces process.env.REACT_APP_STRIPE_SECRET with the actual string. Anyone can open DevTools, search the bundle, and find it in ~30 seconds.

Don't believe me? Try it yourself:

  1. Open any website (including some you'd consider "professional")
  2. Hit F12 → Sources tab
  3. Ctrl+F and search for "API" or "secret" or "key"
  4. Watch the magic happen

Real Attack Scenarios I've Seen

Scenario 1: The Crypto Mining Incident (My Story)

What Happened:

Hacker found my AWS credentials → Spun up 50 EC2 instances → Mined Monero for 3 days → $3K bill

The Exposed Code:

// Found in main.bundle.js
const AWS_ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE"
const AWS_SECRET_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
Enter fullscreen mode Exit fullscreen mode

Scenario 2: The Startup That Lost Customer Data

A Y Combinator startup exposed their Firebase admin SDK key in their frontend. Attacker downloaded their entire user database (200K emails, names, addresses). Company shut down within 6 months due to GDPR fines.

Their Mistake:

// ❌ NEVER do this
const admin = require('firebase-admin')
admin.initializeApp({
  credential: admin.credential.cert({
    projectId: process.env.REACT_APP_PROJECT_ID,
    privateKey: process.env.REACT_APP_PRIVATE_KEY, // 😱
    clientEmail: process.env.REACT_APP_CLIENT_EMAIL
  })
})
Enter fullscreen mode Exit fullscreen mode

Scenario 3: The API Bill From Hell

Developer exposed their OpenAI API key. Someone automated 500K requests. Bill: $12,000 in one weekend.


The Right Way: Backend as Your Security Bodyguard

Think of your backend as a bouncer at a club. The frontend is the drunk dude trying to get in. Never trust the drunk dude.

Architecture That Actually Works

┌──────────────┐
│   Frontend   │  "Hey, can I get some data?"
│   (Public)   │  + User session token only
└──────┬───────┘
       │ HTTPS
       ▼
┌──────────────┐
│   Backend    │  "Let me check if you're legit..."
│  (Private)   │  → Validates user session
└──────┬───────┘  → Uses API keys internally
       │          → Returns sanitized data
       ▼
┌──────────────┐
│  External    │
│  Services    │
└──────────────┘
Enter fullscreen mode Exit fullscreen mode

Code Example: The Correct Pattern

// ✅ Frontend (public code)
// src/api/checkout.js
export async function createPayment(amount) {
  const userToken = localStorage.getItem('authToken')

  const response = await fetch('/api/create-payment', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${userToken}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ amount })
  })

  return response.json()
}

// ✅ Backend (private code)
// server/routes/payments.js
app.post('/api/create-payment', authenticateUser, async (req, res) => {
  // Validate user session
  if (!req.user) {
    return res.status(401).json({ error: 'Unauthorized' })
  }

  // API key stays on server - NEVER exposed
  const stripe = Stripe(process.env.STRIPE_SECRET_KEY)

  try {
    const paymentIntent = await stripe.paymentIntents.create({
      amount: req.body.amount,
      currency: 'usd',
      customer: req.user.stripeCustomerId
    })

    res.json({ clientSecret: paymentIntent.client_secret })
  } catch (error) {
    res.status(500).json({ error: 'Payment failed' })
  }
})
Enter fullscreen mode Exit fullscreen mode

Why This Works:

  • Frontend only knows about the user's session token
  • Stripe secret key lives only on the server
  • If token is stolen, rate limiting + expiration limits damage
  • Server validates every request

Common Excuses I Hear (And Why They're Wrong)

"But I obfuscated the code!"

// ❌ This doesn't help
const k = btoa('my-secret-key')
const apiKey = atob(k) // Still visible in bundle
Enter fullscreen mode Exit fullscreen mode

Base64 isn't encryption. A 12-year-old can decode it.

"But it's just a read-only key!"

Great! Now hackers can:

  • Enumerate your entire dataset
  • Find security holes
  • DoS your API with unlimited requests
  • Clone your entire application

"But I restricted the API key to my domain!"

Referer headers can be spoofed. CORS helps, but it's not bulletproof:

// Attacker's code
fetch('https://your-api.com/data', {
  headers: { 'Origin': 'https://your-domain.com' }
})
Enter fullscreen mode Exit fullscreen mode

Some APIs check this properly (Google Maps, Firebase), many don't.


What You CAN Safely Expose

Not all environment variables are evil. Here's what's actually OK:

// ✅ These are fine in frontend
REACT_APP_API_URL=https://api.myapp.com
REACT_APP_ENVIRONMENT=production
REACT_APP_VERSION=2.1.4
REACT_APP_SENTRY_DSN=https://public@sentry.io/123

// Google Maps with domain restrictions
REACT_APP_GOOGLE_MAPS_KEY=AIzaSyC...

// Firebase client config (with Security Rules)
REACT_APP_FIREBASE_API_KEY=AIzaSyD...
REACT_APP_FIREBASE_AUTH_DOMAIN=app.firebaseapp.com
Enter fullscreen mode Exit fullscreen mode

The Rule:

If it can be abused without user authentication → Backend only

If it's restricted by domain/rules → Frontend OK (with caution)


Quick Wins: Security Checklist

Copy-paste this into your next PR:

## Security Checklist
- [ ] No API keys, secrets, or tokens in frontend code
- [ ] All sensitive operations go through authenticated backend routes
- [ ] Rate limiting implemented (express-rate-limit)
- [ ] CORS configured correctly
- [ ] Input validation on all endpoints
- [ ] User tokens have expiration (JWT: 1-24 hours max)
- [ ] HTTPS enforced in production
- [ ] Security headers set (helmet.js)
- [ ] .env files in .gitignore
- [ ] Different credentials per environment
Enter fullscreen mode Exit fullscreen mode

The $5 Solution That Saved My Ass

After my incident, I implemented this simple middleware. It's saved me countless times:

// middleware/rateLimiter.js
const rateLimit = require('express-rate-limit')
const RedisStore = require('rate-limit-redis')

const limiter = rateLimit({
  store: new RedisStore({ client: redisClient }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Max 100 requests per window
  message: 'Too many requests, chill out bro',
  standardHeaders: true,
  handler: (req, res) => {
    // Log suspicious activity
    logger.warn('Rate limit exceeded', {
      ip: req.ip,
      user: req.user?.id,
      endpoint: req.path
    })
    res.status(429).json({ error: 'Rate limit exceeded' })
  }
})

app.use('/api/', limiter)
Enter fullscreen mode Exit fullscreen mode

Cost: $5/month for Redis

Saved: $3K+ in potential abuse


The Ultimate Test

Before you deploy, do this:

  1. Build your production bundle: npm run build
  2. Open build/static/js/main.*.js
  3. Search for: "key", "secret", "token", "password", "api"
  4. If you find ANYTHING sensitive → DO NOT DEPLOY

Bonus points: Automate this check in your CI/CD:

# .github/workflows/security-check.yml
- name: Check for exposed secrets
  run: |
    npm run build
    if grep -r "sk_live\|api_key\|secret" build/; then
      echo "🚨 Found exposed secrets!"
      exit 1
    fi
Enter fullscreen mode Exit fullscreen mode

Real Talk: Lessons From My $3K Mistake

  1. Environment variables in frontend ≠ secure

    They're just placeholders. Webpack bakes them into your bundle.

  2. Security through obscurity doesn't work

    If you can see it in DevTools, so can everyone else.

  3. Your backend is your friend

    It's not "over-engineering." It's the difference between a secure app and a hacked app.

  4. Rate limiting is non-negotiable

    Even if your keys are secure, someone will try to DoS you.

  5. Assume breach

    Build your architecture assuming someone will find a way in. Limit the damage they can do.


TL;DR (For My Fellow ADHD Devs)

  • Frontend = Public. Period.
  • Secrets = Backend only. No exceptions.
  • User authentication ≠ API authentication. Don't mix them up.
  • Rate limit everything. Your wallet will thank you.
  • Test before deploy. grep your bundle for secrets.

Resources That Actually Helped Me


Got war stories about exposed API keys? Drop them in the comments. Let's learn from each other's expensive mistakes. 😅

And hey, if this saved you from a $3K AWS bill, maybe buy me a coffee? ☕

Follow me @werliton for more hard-learned lessons in web development.


P.S. If you're thinking "this won't happen to me" - that's exactly what I thought. Don't be 2 AM me, frantically Googling "how to revoke AWS keys." Be better. Secure your shit. 🔒

Top comments (0)