The Reality of Security for Solo Developers
When building a solo SaaS, security always lands on the “later” list. Shipping one more feature feels more urgent, and with 10 users, who cares about security?
The problem is that when a security incident hits, a solo developer has no capacity to recover.
| Situation | With a Team | Alone |
|---|---|---|
| Database leak | Security team responds + PR team notifies | Root cause analysis + apology + recovery — all you |
| API key exposed | Immediate rotation + impact analysis | First you have to figure out where it’s even used |
| DDoS attack | Infrastructure team adjusts WAF | Survive on Cloudflare free tier |
| Legal issue (GDPR, etc.) | Legal team handles it | Research + respond yourself |
Security isn’t something you “don’t need to do.” It’s something you need to do more because you’re alone. When an incident occurs, there’s nobody else to handle it.
graph TD
A["Solo SaaS developer"] --> B{"Security\nincident occurs"}
B -->|"Has team"| C["Divide roles\nFast response"]
B -->|"Alone"| D["Root cause +\ncustomer response +\nrecovery = all on you"]
D --> E["Service downtime\nincreases"]
D --> F["Trust lost"]
D --> G["Legal risk"]
style D fill:#ffebee,stroke:#f44336
style C fill:#e8f5e9,stroke:#4caf50
Five OWASP Top 10 Vulnerabilities That Hit Solo SaaS
OWASP Top 10 is the most widely recognized list of web application security vulnerabilities. From the ten, we selected five that actually occur frequently in solo SaaS projects.
graph LR
subgraph RELEVANT["Relevant to Solo SaaS"]
A01["A01\nBroken Access Control"]
A02["A02\nCryptographic Failures"]
A03["A03\nInjection"]
A05["A05\nSecurity Misconfiguration"]
A07["A07\nIdentification &\nAuthentication Failures"]
end
subgraph LESS["Less Relevant"]
A04["A04\nInsecure Design"]
A06["A06\nVulnerable Components"]
A08["A08\nData Integrity Failures"]
A09["A09\nLogging Failures"]
A10["A10\nSSRF"]
end
style RELEVANT fill:#fff3e0,stroke:#ff9800
style LESS fill:#f5f5f5,stroke:#bdbdbd
A01. Broken Access Control
What it is: A vulnerability where one user can access another user’s data.
| Example | Cause | Consequence |
|---|---|---|
Changing /api/users/123/data to 456 exposes another user’s data | Authorization checks rely only on URL parameters | Full user data exposure |
| Admin page accessible to anyone who’s logged in | No role verification | Regular users can use admin features |
| DELETE API has no authentication | Auth missing on specific endpoints | Anyone can delete data |
Minimum defense:
// BAD -- data retrieval based solely on URL parameter
app.get('/api/users/:id/reports', async (req, res) => {
const reports = await db.getReports(req.params.id);
return res.json(reports);
});
// GOOD -- compare session user ID with requested ID
app.get('/api/users/:id/reports', auth, async (req, res) => {
if (req.user.id !== req.params.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
const reports = await db.getReports(req.params.id);
return res.json(reports);
});
In WICHI’s early days, the report API was accessible to anyone who knew the UUID. We assumed UUIDs were unguessable, but if the URL was ever shared, it could be accessed without authentication.
A02. Cryptographic Failures
What it is: Sensitive data stored or transmitted without encryption.
| Item | Do This | Don’t Do This |
|---|---|---|
| Passwords | bcrypt/argon2 hashing | Plaintext storage, MD5/SHA1 |
| API communication | Enforce HTTPS | Allow HTTP |
| DB connection | SSL/TLS | Plaintext connection |
| Token storage | httpOnly + secure cookies | localStorage |
A03. Injection
What it is: User input executed as part of a query or command. SQL Injection, XSS (Cross-Site Scripting), and Command Injection are the most common forms.
// BAD -- vulnerable to SQL Injection
const query = `SELECT * FROM users WHERE email = '${email}'`;
// GOOD -- parameterized query
const query = 'SELECT * FROM users WHERE email = $1';
const result = await db.query(query, [email]);
| Injection Type | Defense | Tools |
|---|---|---|
| SQL Injection | Parameterized queries / ORM | Prisma, Drizzle |
| XSS | Output escaping, CSP | DOMPurify, helmet |
| Command Injection | Never pass user input to shell | — |
A05. Security Misconfiguration
What it is: Deploying with default settings or leaving unnecessary features enabled.
graph TD
A["Deployed with\ndefault settings"] --> B["Debug mode ON"]
A --> C["Detailed error messages\nexposed"]
A --> D["Default passwords\nunchanged"]
A --> E["Unnecessary ports open"]
B --> F["Internal structure exposed"]
C --> F
D --> G["Unauthorized access"]
E --> G
style F fill:#ffebee,stroke:#f44336
style G fill:#ffebee,stroke:#f44336
Things solo developers frequently miss:
| Item | Risk | Fix |
|---|---|---|
DEBUG=true in production | Stack traces exposed | Separate configs per environment |
CORS * (all origins allowed) | Other sites can call your API | Origin whitelist |
| Default error pages | Framework/version revealed | Custom error handler |
| Unnecessary HTTP methods | PUT/DELETE exposed | Allow only required methods |
A07. Identification & Authentication Failures
What it is: Weak login, session management, or password policies.
| Item | Minimum Standard |
|---|---|
| Session expiry | Max 24 hours; 30 minutes on inactivity |
| Password policy | Minimum 8 characters with complexity |
| Login attempt limits | Lock for 15 minutes after 5 failures |
| Password reset | Token-based, 1-hour expiry |
| Social login | Validate the state parameter |
Environment Variable Management
Environment variables are the most common cause of security incidents in solo SaaS. API keys ending up on GitHub happens every day.
Environment Variable Security Checklist
| # | Item | Check |
|---|---|---|
| 1 | Is .env included in .gitignore? | |
| 2 | Does .env.example use placeholders instead of real values? | |
| 3 | Are production env vars stored in hosting service settings (not files)? | |
| 4 | Is the principle of least privilege applied to API keys? | |
| 5 | Is there a key rotation schedule (minimum 90 days)? | |
| 6 | Are client-exposed and server-only env vars separated? |
graph LR
A["Environment\nvariables"] --> B{"Where stored?"}
B -->|".env file"| C["Local dev only\n.gitignore required"]
B -->|"Hosting settings"| D["Vercel/Railway\nenv var panel"]
B -->|"Secret manager"| E["AWS SSM,\nVault, etc."]
C --> F["Never commit"]
D --> G["Safe"]
E --> G
style F fill:#ffebee,stroke:#f44336
style G fill:#e8f5e9,stroke:#4caf50
Client vs Server Environment Variables
| Framework | Client-exposed | Server-only |
|---|---|---|
| Next.js | NEXT_PUBLIC_* | Everything else |
| Vite | VITE_* | Everything else |
| Astro | PUBLIC_* | Everything else |
Accidentally putting API secrets in client-exposed env vars is disturbingly common. A name like
NEXT_PUBLIC_STRIPE_SECRET_KEYshould never exist. Client environment variables are directly visible in browser source code.
Dependency Auditing: Dependabot and Snyk
Your own code might be secure, but the packages you use might not be. Dependency vulnerabilities in the npm ecosystem are constant.
Tool Comparison
| Tool | Price | Auto PR | CI Integration | Notable |
|---|---|---|---|---|
| GitHub Dependabot | Free | Yes | Yes | Built into GitHub |
| Snyk | Free tier available | Yes | Yes | Broader DB, fix suggestions |
npm audit | Free | No | Manual | CLI-based, instant check |
| Socket.dev | Free tier available | Yes | Yes | Supply chain attack detection |
Minimum Setup (GitHub Dependabot)
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
labels:
- "dependencies"
Adding this single file gives you:
- Automatic weekly vulnerability scans
- Auto-generated PRs for vulnerable packages
- Merge to fix — done
A 5-minute setup. Skip it, and three months later you’ll find 30 vulnerabilities piled up in
npm audit.
Rate Limiting
Without rate limiting on your API, the following will happen:
| Attack Type | Consequence | Rate Limiting Effect |
|---|---|---|
| Brute-force login | Password compromise | Attempt limits |
| API abuse | Server cost explosion | Request throttling |
| Scraping | Unauthorized data collection | Speed limits |
| Lightweight DDoS | Service downtime | Load distribution |
Implementation Example
// express-rate-limit
import rateLimit from 'express-rate-limit';
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 per IP
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, try again later' },
});
// Stricter for login
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // 5 per IP
skipSuccessfulRequests: true,
});
app.use('/api/', apiLimiter);
app.use('/api/auth/login', loginLimiter);
Recommended Limits by Endpoint
| Endpoint Type | Limit | Reason |
|---|---|---|
| Login/Signup | 5 per 15 min | Brute-force prevention |
| Password reset | 3 per hour | Abuse prevention |
| General API | 100 per 15 min | Normal usage allowance |
| File upload | 10 per hour | Storage abuse prevention |
| Webhook receive | 1000 per min | Normal traffic allowance |
CORS Configuration
CORS (Cross-Origin Resource Sharing) is a browser security policy that applies when a page on one domain calls an API on a different domain.
Common Mistakes
// BAD -- all origins allowed
app.use(cors({ origin: '*' }));
// BAD -- credentials with * (doesn't actually work)
app.use(cors({ origin: '*', credentials: true }));
// GOOD -- whitelist
app.use(cors({
origin: ['https://myapp.com', 'https://www.myapp.com'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
}));
graph LR
A["Browser\n(myapp.com)"] -->|"API request"| B["Server\n(api.myapp.com)"]
B -->|"CORS header\nAccess-Control-Allow-Origin"| A
C["Attacker\n(evil.com)"] -->|"API request"| B
B -->|"Origin mismatch\n-> Rejected"| C
style C fill:#ffebee,stroke:#f44336
style A fill:#e8f5e9,stroke:#4caf50
| Setting | Development | Production |
|---|---|---|
| origin | localhost:3000 | Actual domains only |
| credentials | true (when using cookies) | true (when using cookies) |
| methods | Allow all | Only what’s needed |
| headers | Allow all | Only what’s needed |
CSP (Content Security Policy)
CSP is an HTTP header that tells the browser which resource origins are allowed on a page. It’s highly effective at reducing XSS attack impact.
Basic CSP Configuration
// CSP via helmet
import helmet from 'helmet';
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // When using CSS-in-JS
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.myapp.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
}));
| Directive | Description | Recommended |
|---|---|---|
default-src | Default policy | 'self' |
script-src | JavaScript sources | 'self' (no inline scripts) |
style-src | CSS sources | 'self' + 'unsafe-inline' if needed |
img-src | Image sources | 'self' + CDN |
connect-src | API/WebSocket sources | 'self' + API domain |
object-src | Flash/Java plugins | 'none' |
Enabling CSP for the first time can break your site. Start with the
Content-Security-Policy-Report-Onlyheader to test, then switch to the enforcingContent-Security-Policyonce issues are resolved.
What WICHI Missed and Fixed After Launch
Of MMU’s 534 checklist items, 45 are security-related. These items came directly from real oversights discovered while building WICHI.
Items Missed and When They Were Found
| Item | Duration Missing | How Discovered | Impact |
|---|---|---|---|
| API rate limiting | 2 weeks post-launch | Abnormal traffic detected | 2x API costs |
| CORS origin restriction | 1 week post-launch | Discovered while writing security checklist | Potential vulnerability |
| Client/server env var separation | Mid-development | Code review | Key exposure risk |
| CSP headers | 3 weeks post-launch | Discovered while organizing MMU checklist items | No XSS defense |
| Webhook signature verification | 1 week post-launch | Re-reading Stripe/LemonSqueezy docs | Forged payment event risk |
graph TD
A["WICHI launches"] --> B["Features work"]
B --> C["Security review begins"]
C --> D["Missing items discovered"]
D --> E["Fixed one by one"]
E --> F["Pattern found:\nSame items missed\nevery time"]
F --> G["Turned into checklist\n= MMU"]
style F fill:#fff3e0,stroke:#ff9800
style G fill:#e8f5e9,stroke:#4caf50
The reason security items were missed wasn’t ignorance. It was urgency. When you’re focused on feature development, security naturally gets pushed back. That’s exactly why a checklist is needed.
Minimum Security Checklist for Solo SaaS
The 20 items below are the minimum to verify before launch.
| # | Category | Item | Difficulty |
|---|---|---|---|
| 1 | Auth | Password hashing (bcrypt/argon2) | Low |
| 2 | Auth | Session expiry configured | Low |
| 3 | Auth | Login attempt rate limiting | Low |
| 4 | Access Control | Per-endpoint authentication check | Medium |
| 5 | Access Control | User data isolation (no cross-user access) | Medium |
| 6 | Env Vars | .env in .gitignore | Low |
| 7 | Env Vars | Client/server env var separation | Low |
| 8 | Env Vars | No production keys hardcoded in source | Low |
| 9 | Communication | HTTPS enforced (HTTP to HTTPS redirect) | Low |
| 10 | Communication | CORS origin whitelist | Low |
| 11 | Communication | API rate limiting | Low |
| 12 | Headers | CSP headers configured | Medium |
| 13 | Headers | X-Frame-Options (clickjacking prevention) | Low |
| 14 | Headers | X-Content-Type-Options: nosniff | Low |
| 15 | Dependencies | Dependabot or Snyk configured | Low |
| 16 | Dependencies | npm audit run regularly (weekly) | Low |
| 17 | Data | SQL Injection prevention (parameterized queries) | Low |
| 18 | Data | XSS prevention (output escaping) | Low |
| 19 | Payments | Webhook signature verification | Medium |
| 20 | Monitoring | Abnormal traffic alerts | Medium |
14 of 20 items are “low” difficulty. Most are one-line configs or a single package install. The problem isn’t complexity — it’s forgetting.
Security vs Usability Trade-offs
Tightening security can degrade usability. Here’s where to draw the line for a solo SaaS:
graph LR
subgraph MUST["Non-negotiable (Regardless of UX)"]
M1["HTTPS enforced"]
M2["Password hashing"]
M3["SQL Injection prevention"]
M4["Env var protection"]
end
subgraph BALANCE["Requires Balance"]
B1["Session expiry duration"]
B2["Password complexity"]
B3["Rate limiting thresholds"]
B4["CSP strictness"]
end
subgraph DEFER["Can Defer"]
D1["2FA"]
D2["IP whitelisting"]
D3["Audit logs"]
D4["Penetration testing"]
end
style MUST fill:#ffebee,stroke:#f44336
style BALANCE fill:#fff3e0,stroke:#ff9800
style DEFER fill:#e8f5e9,stroke:#4caf50
| Decision Factor | Question |
|---|---|
| User count | 10 users: 2FA is overkill. 10,000 users: 2FA is essential |
| Data sensitivity | Payment data: high security. Blog: baseline security |
| Regulations | GDPR-subject: audit logs required |
| Cost | WAF at $20/month vs potential incident cost |
Summary
| Key Point | Details |
|---|---|
| Security matters more solo | When an incident hits, you’re the only responder |
| 5 from OWASP | Access control, cryptography, injection, misconfiguration, authentication |
| Environment variables | .gitignore + client/server separation |
| Automation | 5-minute Dependabot setup for dependency auditing |
| Rate limiting | Differentiated limits per endpoint |
| CORS + CSP | Allowed origins + resource source restrictions |
| 20-item minimum checklist | 14 of 20 are “low difficulty” — just don’t forget |
Related Posts
After Hackathon Rejection — Pivoting to Independent SaaS
Recording the 24-hour pivot of WICHI to an independent SaaS after a hackathon rejection, covering i18n implementation, SEO setup, and monetization roadmap restructuring.
Lovable to Vercel — Frontend Migration Record
Migration from Lovable ($25/mo) to Vercel ($0) including build pipeline reconfiguration, documenting the frontend hosting transition process.
Build, Document, Share
Personal execution notes from a non-tech builder who started with AI FOMO and is now navigating the messy reality of production beyond the initial 'one-click' hype.