Minbook
KO

Solo SaaS Security — The Minimum You Must Do

· 6 min read

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.

SituationWith a TeamAlone
Database leakSecurity team responds + PR team notifiesRoot cause analysis + apology + recovery — all you
API key exposedImmediate rotation + impact analysisFirst you have to figure out where it’s even used
DDoS attackInfrastructure team adjusts WAFSurvive on Cloudflare free tier
Legal issue (GDPR, etc.)Legal team handles itResearch + 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.

ExampleCauseConsequence
Changing /api/users/123/data to 456 exposes another user’s dataAuthorization checks rely only on URL parametersFull user data exposure
Admin page accessible to anyone who’s logged inNo role verificationRegular users can use admin features
DELETE API has no authenticationAuth missing on specific endpointsAnyone 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.

ItemDo ThisDon’t Do This
Passwordsbcrypt/argon2 hashingPlaintext storage, MD5/SHA1
API communicationEnforce HTTPSAllow HTTP
DB connectionSSL/TLSPlaintext connection
Token storagehttpOnly + secure cookieslocalStorage

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 TypeDefenseTools
SQL InjectionParameterized queries / ORMPrisma, Drizzle
XSSOutput escaping, CSPDOMPurify, helmet
Command InjectionNever 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:

ItemRiskFix
DEBUG=true in productionStack traces exposedSeparate configs per environment
CORS * (all origins allowed)Other sites can call your APIOrigin whitelist
Default error pagesFramework/version revealedCustom error handler
Unnecessary HTTP methodsPUT/DELETE exposedAllow only required methods

A07. Identification & Authentication Failures

What it is: Weak login, session management, or password policies.

ItemMinimum Standard
Session expiryMax 24 hours; 30 minutes on inactivity
Password policyMinimum 8 characters with complexity
Login attempt limitsLock for 15 minutes after 5 failures
Password resetToken-based, 1-hour expiry
Social loginValidate 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

#ItemCheck
1Is .env included in .gitignore?
2Does .env.example use placeholders instead of real values?
3Are production env vars stored in hosting service settings (not files)?
4Is the principle of least privilege applied to API keys?
5Is there a key rotation schedule (minimum 90 days)?
6Are 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

FrameworkClient-exposedServer-only
Next.jsNEXT_PUBLIC_*Everything else
ViteVITE_*Everything else
AstroPUBLIC_*Everything else

Accidentally putting API secrets in client-exposed env vars is disturbingly common. A name like NEXT_PUBLIC_STRIPE_SECRET_KEY should 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

ToolPriceAuto PRCI IntegrationNotable
GitHub DependabotFreeYesYesBuilt into GitHub
SnykFree tier availableYesYesBroader DB, fix suggestions
npm auditFreeNoManualCLI-based, instant check
Socket.devFree tier availableYesYesSupply 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:

  1. Automatic weekly vulnerability scans
  2. Auto-generated PRs for vulnerable packages
  3. 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 TypeConsequenceRate Limiting Effect
Brute-force loginPassword compromiseAttempt limits
API abuseServer cost explosionRequest throttling
ScrapingUnauthorized data collectionSpeed limits
Lightweight DDoSService downtimeLoad 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);
Endpoint TypeLimitReason
Login/Signup5 per 15 minBrute-force prevention
Password reset3 per hourAbuse prevention
General API100 per 15 minNormal usage allowance
File upload10 per hourStorage abuse prevention
Webhook receive1000 per minNormal 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
SettingDevelopmentProduction
originlocalhost:3000Actual domains only
credentialstrue (when using cookies)true (when using cookies)
methodsAllow allOnly what’s needed
headersAllow allOnly 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: [],
  },
}));
DirectiveDescriptionRecommended
default-srcDefault policy'self'
script-srcJavaScript sources'self' (no inline scripts)
style-srcCSS sources'self' + 'unsafe-inline' if needed
img-srcImage sources'self' + CDN
connect-srcAPI/WebSocket sources'self' + API domain
object-srcFlash/Java plugins'none'

Enabling CSP for the first time can break your site. Start with the Content-Security-Policy-Report-Only header to test, then switch to the enforcing Content-Security-Policy once 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

ItemDuration MissingHow DiscoveredImpact
API rate limiting2 weeks post-launchAbnormal traffic detected2x API costs
CORS origin restriction1 week post-launchDiscovered while writing security checklistPotential vulnerability
Client/server env var separationMid-developmentCode reviewKey exposure risk
CSP headers3 weeks post-launchDiscovered while organizing MMU checklist itemsNo XSS defense
Webhook signature verification1 week post-launchRe-reading Stripe/LemonSqueezy docsForged 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.

#CategoryItemDifficulty
1AuthPassword hashing (bcrypt/argon2)Low
2AuthSession expiry configuredLow
3AuthLogin attempt rate limitingLow
4Access ControlPer-endpoint authentication checkMedium
5Access ControlUser data isolation (no cross-user access)Medium
6Env Vars.env in .gitignoreLow
7Env VarsClient/server env var separationLow
8Env VarsNo production keys hardcoded in sourceLow
9CommunicationHTTPS enforced (HTTP to HTTPS redirect)Low
10CommunicationCORS origin whitelistLow
11CommunicationAPI rate limitingLow
12HeadersCSP headers configuredMedium
13HeadersX-Frame-Options (clickjacking prevention)Low
14HeadersX-Content-Type-Options: nosniffLow
15DependenciesDependabot or Snyk configuredLow
16Dependenciesnpm audit run regularly (weekly)Low
17DataSQL Injection prevention (parameterized queries)Low
18DataXSS prevention (output escaping)Low
19PaymentsWebhook signature verificationMedium
20MonitoringAbnormal traffic alertsMedium

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 FactorQuestion
User count10 users: 2FA is overkill. 10,000 users: 2FA is essential
Data sensitivityPayment data: high security. Blog: baseline security
RegulationsGDPR-subject: audit logs required
CostWAF at $20/month vs potential incident cost

Summary

Key PointDetails
Security matters more soloWhen an incident hits, you’re the only responder
5 from OWASPAccess control, cryptography, injection, misconfiguration, authentication
Environment variables.gitignore + client/server separation
Automation5-minute Dependabot setup for dependency auditing
Rate limitingDifferentiated limits per endpoint
CORS + CSPAllowed origins + resource source restrictions
20-item minimum checklist14 of 20 are “low difficulty” — just don’t forget
Share

Related Posts

Comments