Back to all articles
SecurityGuide

Security Mistakes vibestacks Can't Save You From

vibestacks handles the common attacks. But here's what's still on you - and how to not mess it up.

Security Mistakes vibestacks Can't Save You From

vibestacks ships with solid security defaults. CSRF protection, session management, password hashing, rate limiting - all handled.

But no boilerplate can protect you from your own code.

Here are the security mistakes that are still your responsibility. I've seen senior devs make every single one of these.

IDOR (Insecure Direct Object Reference)

The mistake:

Your API endpoint fetches an invoice:

// GET /api/invoices/123
const invoice = await db.query.invoices.findFirst({
  where: eq(invoices.id, params.id)
})
return invoice

User is authenticated. They own invoice 123. Works perfectly.

But what if they change the URL to /api/invoices/456? That's someone else's invoice. And your code just returned it.

Why this happens:

You added authentication (user must be logged in). You forgot authorization (user must own this resource).

Two different things. Easy to conflate.

The fix:

Every query for user-owned data must check ownership:

const invoice = await db.query.invoices.findFirst({
  where: and(
    eq(invoices.id, params.id),
    eq(invoices.userId, session.userId) // ← this line
  )
})

if (!invoice) return notFound()

No framework can do this for you. It depends on your data model. Every resource, every endpoint, every query.

Quick audit: Search your codebase for queries that use params.id or req.params. Are you checking ownership?

Race Conditions

The mistake:

User has $100 balance. They click "Withdraw $100" twice really fast. Two requests hit your server simultaneously:

// Both requests run at the same time:
const balance = await getBalance(userId) // Both see $100
if (balance >= 100) {
  await deductBalance(userId, 100) // Both deduct
  await sendMoney(100) // Both send
}
// User withdrew $200 from $100 balance

Why this happens:

Works perfectly in testing. You click once, it works. The bug only appears under specific timing conditions in production - when two requests execute between each other's read and write.

The fix:

Database transactions with row locking:

await db.transaction(async (tx) => {
  const user = await tx.query.users.findFirst({
    where: eq(users.id, userId),
    for: 'update' // ← locks the row
  })
  
  if (user.balance >= 100) {
    await tx.update(users)
      .set({ balance: user.balance - 100 })
    await sendMoney(100)
  }
})

Second request waits until first completes. Sees the updated balance. Fails correctly.

Where to look: Any operation that reads a value, checks a condition, then writes. Especially involving money, inventory, limited slots, or quotas.

Logging Sensitive Data

The mistake:

console.log('Login attempt:', { email, password })
console.log('User:', user)
console.log('Request:', req.body)

Passwords are now in your logs. Logs go to Vercel. Logs go to your error tracking. Logs get accessed by support staff. Logs get included in breach dumps.

Why this happens:

Debugging in development. Forgot to remove. Or intentional logging without thinking about what's included.

The fix:

Never log:

  • Passwords (obviously)
  • Session tokens
  • API keys
  • Full credit card numbers
  • SSNs, government IDs
  • Anything you wouldn't want in a breach headline

Redact explicitly:

logger.info('Login attempt', { 
  email,
  password: '[REDACTED]'
})

Or better - use a logger that auto-redacts sensitive field names.

Quick audit: Search your codebase for console.log and check what's being logged.

Weak Validation

The mistake:

const schema = z.object({
  amount: z.number()
})

User sends { "amount": -500 }. Your code processes a negative withdrawal. Balance increases.

Or { "amount": 999999999 }. Integer overflow somewhere downstream.

Why this happens:

Zod validates types, not business logic. A number is a number. Zod doesn't know it should be positive, or have a maximum, or be a multiple of something.

The fix:

Validate business rules, not just types:

const schema = z.object({
  amount: z.number()
    .positive("Amount must be positive")
    .max(10000, "Maximum withdrawal is $10,000")
    .multipleOf(0.01, "Invalid decimal places")
})

Think adversarially. What values would break your logic?

Trusting Client Data

The mistake:

// Client sends:
{ "productId": "abc", "price": 0.01 }

// Server:
await createOrder({
  productId: body.productId,
  price: body.price // ← uh oh
})

User just bought your $500 product for one cent.

Why this happens:

The price was right in your React state. Easy to include in the POST body. Feels redundant to look it up again on the server.

The fix:

Client tells you WHAT they want. Server determines the terms.

// Client sends:
{ "productId": "abc", "quantity": 1 }

// Server:
const product = await db.query.products.findFirst({
  where: eq(products.id, body.productId)
})

await createOrder({
  productId: product.id,
  price: product.price, // ← from YOUR database
  quantity: body.quantity
})

Never trust prices, permissions, roles, or anything security-sensitive from the client.

Subdomain Takeover

The mistake:

You had staging at staging.yourapp.com pointing to Heroku. Deleted the Heroku app. Forgot to delete the DNS record.

Attacker creates a Heroku app, claims your dangling subdomain, now controls staging.yourapp.com.

If your cookies are set for .yourapp.com (all subdomains), attacker can steal sessions from your main app.

Why this happens:

It's infrastructure, not code. DNS records accumulate. Old projects get abandoned. Nobody audits.

The fix:

  • Audit DNS records quarterly
  • When you delete a deployment, delete the DNS record
  • Don't use wildcard cookies (.yourapp.com) unless you need cross-subdomain auth
  • Set cookies for exact domain when possible

Dependency Confusion

The mistake:

Your company has internal packages:

"@yourcompany/utils": "^1.0.0"

Private registry, not on npm. But your .npmrc also has public npm as a fallback.

Attacker publishes @yourcompany/utils version 99.0.0 to public npm. Your next install pulls from npm because higher version. Attacker's code runs in your app.

Why this happens:

Package installation just works. Nobody audits every install. Version resolution is automatic.

The fix:

  • Configure registries explicitly: private packages ONLY from private registry
  • Use lockfiles and verify checksums in CI
  • Pin exact versions in production, not ranges
  • Run npm audit / pnpm audit in CI pipeline
  • Consider tools like Socket.dev for supply chain monitoring

The Checklist

Before you ship:

  • Every user-owned resource checks ownership in queries
  • Financial operations use database transactions with locking
  • No passwords, tokens, or PII in logs
  • Validation includes business rules, not just types
  • Prices and permissions come from server, not client
  • No dangling DNS records pointing to dead services
  • Dependencies audited, lockfile committed

vibestacks gives you a secure foundation. What you build on top is on you.


Found a security issue in vibestacks itself? Email me at raman@vibestacks.dev.

Building something and not sure if it's vulnerable? Happy to take a look.