Shipping fast as a solo founder means you will introduce security bugs. That's not a question of skill — it's a question of bandwidth. The question is whether you catch them before someone else does.
I'm building Pronto — an open-source self-hosted POS, CRM, and booking system for service businesses. During a security review of v1.0, I found three issues that made me genuinely uncomfortable. None were exploited. All are now fixed.
Here's what they were, why they happened, and how I fixed each one.
Bug 1: Bot tokens leaking to client-side HTML
What happened
Pronto supports Telegram, WhatsApp, and Viber notifications. Each tenant configures their own bot credentials in Settings. These credentials are stored in the database and used server-side to dispatch notifications.
In an early version, I had a notification preview endpoint that fetched tenant settings — including bot tokens — and returned them in a JSON response that was consumed directly by a client-side component.
The token never appeared visibly in the UI. But it was sitting in a fetch response, readable from the browser's DevTools network tab.
// ❌ WRONG — this response reached the client
const settings = await fetch('/api/settings/notifications')
// response included: { telegram_token: "7412...", viber_token: "1234..." }
Any user logged into that tenant's account could extract their own bot token from the browser. For multi-tenant SaaS, this is a real problem — especially if a tenant's account gets compromised.
Why it happened
I built the notifications settings UI quickly and pulled the same endpoint used for server-side rendering. The path of least resistance was "fetch everything, display what you need." The token fields weren't rendered in the UI, so it felt safe. It wasn't.
The fix
All notification dispatch moved to server-only API routes protected by an internal secret header. Client-facing endpoints now return only non-sensitive configuration (enabled/disabled state, phone number prefix for display) — never credentials.
// ✅ Server-side only — internal routes
// middleware checks: req.headers['x-internal-secret'] === process.env.INTERNAL_API_SECRET
// ✅ Client receives only display data
const settings = await fetch('/api/settings/notifications/display')
// response: { telegram_enabled: true, whatsapp_number_preview: "+1 234 ***" }
Rule learned: If it looks like a credential, it never touches the client. Not even in a field that isn't rendered.
Bug 2: Unauthenticated API endpoint
What happened
The public booking page — where clients book appointments without creating an account — needs to fetch available time slots for a given business. This endpoint is intentionally public. No auth required.
However, the same route handler also accepted a staff_id parameter and, when provided, returned full staff profile data including internal notes and contact details that were never meant to be public.
GET /api/public/slots?business=salon-maya&staff_id=123
// returned available slots ✅
// also returned: { name, phone, internal_notes, salary_type } ❌
Why it happened
The staff lookup was added to pre-fill the booking form when a client clicks a specific staff member's profile. I reused an existing staff fetch function without thinking about what data it exposed. The function was designed for internal admin use.
The fix
Public endpoints now use dedicated lean serializers that explicitly whitelist fields:
// ✅ Public staff serializer — only what the booking page needs
function serializeStaffPublic(staff) {
return {
id: staff.id,
name: staff.display_name,
avatar_url: staff.avatar_url,
services: staff.services,
}
// internal_notes, phone, salary_type — never included
}
Rule learned: Never reuse internal data fetchers for public endpoints. Write a separate serializer that explicitly lists what's allowed out.
Bug 3: In-memory rate limiting that restarts on deploy
What happened
The public booking form is rate-limited to prevent spam. I implemented this with a simple in-memory store:
// ❌ In-memory rate limiter
const attempts = new Map() // { ip: { count, resetAt } }
export function checkRateLimit(ip) {
const record = attempts.get(ip)
if (record && record.count >= 10 && Date.now() < record.resetAt) {
return false // rate limited
}
// update record...
return true
}
This works fine in development. In production, every container restart wipes the Map. Every deploy resets all rate limit counters. A bad actor who knows your deploy schedule (or just retries after a restart) bypasses the limit entirely.
For a SaaS with multiple instances, it's even worse — each instance has its own Map, so the effective limit is N instances × 10 requests.
Why it happened
In-memory is the fastest path to "rate limiting is implemented." It works in local testing. The failure mode only appears in production at scale or with deliberate probing.
The fix
The proper solution is Redis (Upstash works well for serverless). But I documented it as a known limitation with a clear upgrade path rather than blocking the release:
// ✅ Production path — Redis via Upstash
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1 h'),
})
const { success } = await ratelimit.limit(ip)
For self-hosted single-instance deployments, the in-memory version is documented as acceptable with the caveat clearly stated in README. For SaaS — Redis is required.
Rule learned: In-memory state that needs to survive restarts is a production bug, not a dev shortcut. Either implement it properly or document the limitation explicitly so users aren't surprised.
The meta-lesson
All three bugs share a root cause: I made the "fast path" decision without thinking about the security surface.
- Fastest notification settings: return everything to the client
- Fastest staff lookup: reuse the existing function
- Fastest rate limiting: in-memory Map
Speed of implementation and security surface are often in tension. When you're building solo, you need a personal forcing function to catch this tension before it ships.
Mine is now: anything that touches credentials, user data, or auth gets a 5-minute threat model before it goes to production. Not a formal audit. Just five minutes of "who can call this, what can they get, what happens if this is wrong."
It has caught two more issues since I formalized it.
Pronto is open-source under MIT. If you find something I missed — issues are open.
GitHub: github.com/SGrappelli/pronto
SaaS: trypronto.app













