API Security Checklist for SaaS Builders

Most SaaS breaches aren't zero-days, they're missing auth checks, leaked keys, and endpoints nobody remembered to lock down. Here's the checklist that actually prevents them.
API Security Checklist for SaaS Builders
Most API breaches aren't sophisticated. They're a missing req.user.id check, an API key committed to a public repo, or an endpoint that returns another tenant's data because nobody tested for it. If you're building a multi-tenant Saas, a CRM, an ERP, a booking platform, anything with paying customers and shared infrastructure, this is the checklist to run before you ship, not after an incident.
Tip
TL;DR, Authenticate every request, authorize every object access (not just the endpoint), never trust client input, rotate and scope your secrets, rate-limit everything public, and log enough to reconstruct what happened. The order below is roughly the order attackers try things.
1. Authentication: prove who's calling
- Every non-public route checks a valid, unexpired token, no exceptions for "internal" routes reachable from the internet.
- Use short-lived access tokens (15–60 min) with refresh tokens, not long-lived JWTs you can't revoke.
- Store refresh tokens hashed, same as passwords.
// Express middleware, fail closed, not open
function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Missing token' });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
return res.status(401).json({ error: 'Invalid token' });
}
}2. Authorization: this is where multi-tenant SaaS actually breaks
Authentication tells you who. Authorization tells you what they're allowed to touch. The most common SaaS vulnerability, Broken Object Level Authorization (BOLA), is when your endpoint checks the token but not whether the requested resourceId belongs to that tenant.
// Wrong: any authenticated user can read any invoice by guessing IDs
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await Invoice.findById(req.params.id);
res.json(invoice);
});
// Right: scope every query to the caller's tenant
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await Invoice.findOne({
_id: req.params.id,
tenantId: req.user.tenantId,
});
if (!invoice) return res.status(404).json({ error: 'Not found' });
res.json(invoice);
});BOLA has topped the OWASP API Security Top 10 for years running, it's the single most exploited category in production APIs, precisely because it's invisible in a demo and only shows up when someone tries a different id.
3. Input validation: never trust the client
- Validate shape, type, and range on the server, even if the frontend already validates. Frontend validation is UX, not security.
- Use a schema validator (Zod, Joi, Pydantic) at the route boundary, not scattered
ifchecks. - Reject unknown fields, don't silently strip them, since silent stripping hides bugs and mass-assignment attempts.
# FastAPI + Pydantic, extra fields rejected outright
class CreateLeadRequest(BaseModel):
name: str
email: EmailStr
phone: str | None = None
class Config:
extra = "forbid"4. Secrets and keys
- No secrets in git, ever, not in a
.env.example, not in a commit you "deleted later" (git history keeps it). Use a secrets manager or at minimum CI-injected env vars. - Scope API keys to what they need. A key for a public read-only widget should not have write access to your database.
- Rotate on any suspected leak, and rotate on a schedule regardless. I've handled a leaked SMTP credential via a public GitHub repo before; containment was fast because rotation was already a known procedure, not something improvised under pressure.
5. Rate limiting and abuse prevention
- Every public endpoint gets a rate limit, especially auth, password reset, and anything that sends email/SMS (these get farmed for abuse first).
- Rate limit by IP and by account/API key, IP-only limits are trivial to route around.
- Return
429with aRetry-Afterheader, not a silent drop.
import rateLimit from 'express-rate-limit';
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
message: { error: 'Too many attempts, try again later' },
});
app.use('/api/auth/login', authLimiter);6. Transport and headers
- HTTPS only, HSTS enabled, no mixed content.
- Set
Content-Security-Policy,X-Content-Type-Options: nosniff, and disableX-Powered-By, don't advertise your stack. - CORS: allowlist specific origins, never
*on any route that reads authenticated data.
7. Logging and monitoring
- Log auth failures, authorization denials, and rate-limit hits with enough context (user id, tenant id, IP, endpoint) to reconstruct an incident, but never log tokens, passwords, or full request bodies containing PII.
- Alert on anomalies: a spike in 401s/403s from one IP, or one account hitting endpoints across many tenant IDs in sequence, is almost always a BOLA probe.
What this looks like on a real multi-tenant backend
This is close to how I run it on the multitenant CRM backend I maintain for SaaS clients: every query is scoped by tenantId at the data-access layer (not left to individual route handlers to remember), JWT-based auth with short-lived tokens, and rate limiting on every public-facing route by default rather than added reactively. It's also the same instinct that mattered when responding to a malware incident in a client's Docker container last year, the fix was fast because containment and credential rotation were already a rehearsed process, not something figured out mid-incident.
The pattern holds regardless of stack: assume every ID in a URL will be guessed, assume every secret will eventually leak, and build the response to both before you need it.
Wrapping up
API security for SaaS isn't one big project, it's a checklist you run on every new endpoint, every time. Authentication gets the headlines, but authorization (scoping data access to the right tenant) is where most real breaches actually happen. If you're building or auditing a multi-tenant backend and want a second set of eyes on it, book a free 20-minute call.
Related Posts
Related Articles

Scaling WebSocket Connections: What Breaks Past a Few Thousand Concurrent Users
WebSockets don't scale like REST APIs, they're stateful, long-lived connections, and that single difference breaks chat and notifications past a few thousand concurrent users. A breakdown of what actually needs to change: shared pub/sub state, connection-aware load balancing.

From 0 to 100K Users: Handling Users Smoothly from the Mobile App Client to the Backend
What actually breaks as a Flutter app scales isn't the UI — it's how sessions, token refresh, and concurrent writes are handled between the client and backend. A technical breakdown of the user-lifecycle failures that show up between 1K and 100K users.

Next.js SEO: What Actually Moves the Needle
Rebuilt your site in React and watched traffic drop? Here's what Google actually sees, and the three fixes that matter more than any plugin.
