"If the browser can see it, it's not a secret."
AI-assisted development moves fast. Security often gets overlooked. This guide covers the most common vulnerabilities in vibe-coded apps and how to fix them.
| Issue | Risk Level | How Common |
|---|---|---|
| Exposed API Keys | Critical | Very common |
| Missing Authorization | Critical | Extremely common |
| Unvalidated Input | High | Common |
| No Rate Limiting | High | Very common |
| Verbose Error Messages | Medium | Common |
| CORS Misconfiguration | Medium | Common |
| Missing Security Headers | Medium | Very common |
| Dependency Vulnerabilities | High | Common |
| No CSRF Protection | Medium | Common |
| Hardcoded Admin Accounts | Critical | Occasional |
AI-generated code often hard-codes secrets directly into files:
// ❌ EXPOSED - Anyone can see this
const apiKey = "sk-1234567890abcdef";
const dbPassword = "super_secret_123";If it's in your frontend code, it's public. Browser dev tools, page source, and network requests expose everything.
Step 1: AI Audit
Review my code and identify any API keys, secrets, tokens,
or credentials that may be exposed. Flag anything that
should be moved to environment variables.
Step 2: Browser Check
- Right-click → View Page Source → Search for "key", "secret", "password"
- Dev Tools → Network tab → Check request headers/bodies
Step 3: Repo History
Even if you deleted it, Git remembers. Search your commits:
git log -p | grep -i "api_key\|secret\|password"// ✅ SAFE - Use environment variables
const apiKey = process.env.API_KEY;
const dbPassword = process.env.DB_PASSWORD;If already exposed:
- Generate NEW keys immediately
- Move to environment variables
- Revoke the old keys
- Redeploy
Simply deleting the code doesn't fix it — the old key is still compromised.
"Logging in is like showing ID at a front desk. Authorization is what locks the doors inside."
Many vibe-coded apps let users log in but don't actually protect data:
// ❌ INSECURE - Only checks if logged in, not WHO
app.get('/api/user/:id', (req, res) => {
if (req.session.loggedIn) {
return db.getUser(req.params.id); // Any user can get ANY user's data!
}
});Ask yourself: What stops User A from seeing User B's data?
If your answer is:
- "The UI doesn't show it" → ❌ Not protected
- "There's no button for it" → ❌ Not protected
- "The backend checks user ID" → ✅ Protected
// ✅ SECURE - Verify ownership
app.get('/api/user/:id', (req, res) => {
if (req.session.userId !== req.params.id) {
return res.status(403).json({ error: 'Forbidden' });
}
return db.getUser(req.params.id);
});For Supabase users: Enable Row Level Security (RLS)
-- ✅ Users can only see their own data
CREATE POLICY "Users see own data" ON users
FOR SELECT USING (auth.uid() = id);Audit my backend and database rules. Explain what actually
enforces access control. Identify any endpoints where a
logged-in user could access another user's data.
Vibe-coded apps trust user input too much:
// ❌ DANGEROUS - No validation
app.post('/upload', (req, res) => {
saveFile(req.file); // Could be malware!
});
app.post('/api/query', (req, res) => {
db.query(req.body.sql); // SQL injection!
});// ✅ SAFE - Validate everything
import { z } from 'zod';
const uploadSchema = z.object({
file: z.instanceof(File),
size: z.number().max(5_000_000), // 5MB limit
type: z.enum(['image/png', 'image/jpeg', 'application/pdf'])
});
app.post('/upload', (req, res) => {
const validated = uploadSchema.parse(req.body);
saveFile(validated.file);
});Rules:
- Whitelist allowed file types
- Set size limits
- Sanitize text input
- Never pass raw input to database queries
- Use parameterized queries
Vibe-coded APIs accept unlimited requests:
// ❌ NO LIMIT - Attackers can hammer this
app.post('/api/login', (req, res) => {
return authenticate(req.body);
});This enables:
- Brute force password attacks
- API abuse running up your costs
- Denial of service
// ✅ RATE LIMITED
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: 'Too many requests, try again later'
});
app.use('/api/', limiter);
// Stricter for auth endpoints
const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5, // 5 attempts
});
app.post('/api/login', authLimiter, (req, res) => {
return authenticate(req.body);
});Detailed errors help attackers:
// ❌ LEAKY - Reveals system info
app.use((err, req, res, next) => {
res.status(500).json({
error: err.message,
stack: err.stack,
query: req.query,
dbConnection: process.env.DATABASE_URL
});
});// ✅ SAFE - Generic public errors, detailed logs
app.use((err, req, res, next) => {
// Log full error internally
console.error('Internal error:', err);
// Return generic message to user
res.status(500).json({
error: 'Something went wrong',
requestId: req.id // For support lookup
});
});Rules:
- Never expose stack traces in production
- Never reveal database structure
- Never show internal paths
- Log details server-side only
Wildcard CORS allows any site to call your API:
// ❌ WIDE OPEN - Any website can access
app.use(cors({ origin: '*' }));// ✅ RESTRICTED - Only your domains
app.use(cors({
origin: [
'https://yourapp.com',
'https://www.yourapp.com',
process.env.NODE_ENV === 'development' && 'http://localhost:3000'
].filter(Boolean),
credentials: true
}));Default headers leave browsers vulnerable. Without security headers, your app is susceptible to:
- Clickjacking (no X-Frame-Options)
- MIME sniffing attacks (no X-Content-Type-Options)
- XSS attacks (no X-XSS-Protection)
// ✅ Add security headers
import helmet from 'helmet';
app.use(helmet());
// Or manually:
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Strict-Transport-Security', 'max-age=31536000');
next();
});AI often suggests outdated packages with known vulnerabilities:
// ❌ Old version with CVEs
"dependencies": {
"lodash": "4.17.15"
}# npm
npm audit
# yarn
yarn audit
# pnpm
pnpm audit# Auto-fix what's possible
npm audit fix
# Update specific package
npm update lodash
# Check for major updates
npx npm-check-updatesMake this routine:
- Run
npm auditbefore every deploy - Set up GitHub Dependabot
- Review AI-suggested packages
Without CSRF tokens, attackers can trick users into actions:
<!-- Malicious site -->
<form action="https://yourapp.com/api/transfer" method="POST">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="1000" />
</form>
<script>document.forms[0].submit()</script>// ✅ Require CSRF token
import csrf from 'csurf';
app.use(csrf({ cookie: true }));
app.get('/form', (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="{{csrfToken}}" />
<!-- form fields -->
</form>For SPAs: Use SameSite cookies + custom headers
// ❌ CRITICAL - Obvious backdoor
if (username === 'admin' && password === 'admin123') {
grantAdminAccess();
}
// ❌ Also bad - in config files
const ADMIN_PASSWORD = 'supersecret';- Never hardcode credentials
- Use proper user management
- Admin accounts via secure setup process
- Rotate credentials regularly
// ✅ Proper admin check
const isAdmin = await db.users.findOne({
id: req.session.userId,
role: 'admin'
});Before deploying, verify:
- No secrets in frontend code
- All secrets in environment variables
-
.envfiles in.gitignore - No secrets in Git history
- Backend verifies user owns requested data
- Database has row-level security (if applicable)
- API endpoints check permissions, not just login status
- Tested: Can User A access User B's data? (Should fail)
- All user input validated server-side
- File uploads restricted by type and size
- No raw SQL queries with user input
- Error messages don't expose system details
- Auth endpoints rate limited (5-10 attempts/hour)
- API endpoints rate limited (100-1000/minute based on use case)
- File upload size limits enforced
- No stack traces in production responses
- No database/system info in errors
- Errors logged server-side with request ID
- Security headers set (use helmet or manual)
- CORS restricted to your domains only
- SameSite cookie attribute set
-
npm auditshows 0 critical/high vulnerabilities - No hardcoded admin accounts
- No test/debug credentials in codebase
- CSRF protection enabled for forms
Perform a security audit on my codebase. Check for:
1. Exposed API keys, secrets, or credentials
2. Missing authorization checks (can users access others' data?)
3. Unvalidated user input
4. SQL injection vulnerabilities
5. Sensitive data in logs or error messages
For each issue found, explain the risk and provide a fix.
I'm about to deploy. Quickly check for critical security issues:
- Any hard-coded secrets?
- Any endpoints missing auth checks?
- Any unvalidated input going to database?
| Principle | Meaning |
|---|---|
| Defense in depth | Multiple layers of security, not just one |
| Least privilege | Users only access what they need |
| Trust nothing | Validate all input, verify all requests |
| Fail secure | When in doubt, deny access |
| Mistake | Why It Happens | Fix |
|---|---|---|
| API key in frontend | AI puts it where it's needed | Move to backend, use env vars |
| No RLS policies | Didn't know it was needed | Enable RLS, add policies |
| Auth check in UI only | Assumed backend was protected | Add backend authorization |
| Trusting user input | AI doesn't validate by default | Add Zod/Yup validation |
| Secrets in Git | Committed before .gitignore | Rotate secrets, clean history |
| No rate limiting | AI doesn't add it by default | Add express-rate-limit |
| Detailed error messages | Debugging left enabled | Use generic errors in production |
CORS set to * |
Quick fix during development | Restrict to your domains |
| Missing security headers | Not visible, easy to forget | Use helmet.js |
| Outdated dependencies | AI suggests what it knows | Run npm audit regularly |
AI audit prompts are useful, but asking an LLM "is this secure?" is the weakest
form of verification — same code, different prompt, different answer. Move the
detectable issues above (injection, hardcoded secrets, dangerous APIs) onto a
deterministic tool: add Semgrep SAST, a /security-scan command, and a CI gate.
See Semgrep SAST + /security-scan for the setup — including the failure modes (scanning a path that doesn't exist, treating "findings found" as "tool missing") that make a scan worse than no scan.
- OWASP Top 10 — Most critical web security risks
- Supabase RLS Guide — Database-level security
- Zod — TypeScript-first input validation
- GitGuardian — Scan repos for exposed secrets
Remember: Moving fast is great. Moving fast securely is better.