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.
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
})
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:
- Open any website (including some you'd consider "professional")
- Hit
F12→ Sources tab -
Ctrl+Fand search for "API" or "secret" or "key" - 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"
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
})
})
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 │
└──────────────┘
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' })
}
})
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
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' }
})
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
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
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)
Cost: $5/month for Redis
Saved: $3K+ in potential abuse
The Ultimate Test
Before you deploy, do this:
- Build your production bundle:
npm run build - Open
build/static/js/main.*.js - Search for: "key", "secret", "token", "password", "api"
- 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
Real Talk: Lessons From My $3K Mistake
Environment variables in frontend ≠ secure
They're just placeholders. Webpack bakes them into your bundle.Security through obscurity doesn't work
If you can see it in DevTools, so can everyone else.Your backend is your friend
It's not "over-engineering." It's the difference between a secure app and a hacked app.Rate limiting is non-negotiable
Even if your keys are secure, someone will try to DoS you.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.
grepyour bundle for secrets.
Resources That Actually Helped Me
- OWASP Top 10 - Security fundamentals
- express-rate-limit - Easy rate limiting
- helmet.js - Security headers made easy
- git-secrets - Prevent committing secrets
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)