
To implement OWASP Top 10 mitigations in a startup, pick one Node/TypeScript fix per category and ship them in this order: access control middleware, parameterized queries, secrets out of git, dependency pinning, then everything else. You do not need a security team. You need ten small, well-placed patterns that turn the most common bug classes into compile-time or middleware-time errors.
The OWASP Top 10 has not changed dramatically since the 2021 list, but the attack surface has. Most startups now ship serverless functions, third-party AI APIs, vector stores, and queue workers within the first 90 days. Each of those is a new place for a misconfigured IAM role or an unvalidated input to bite you.
The good news: AI-assisted code review catches most of these before merge. Cursor and Claude Code will both flag a raw eval or a missing rate limiter if you ask. The bad news: AI also generates plausible-looking insecure code, especially around auth and SQL. You still need to know what right looks like so you can correct the suggestion.
Most teams treat security as a quarterly checklist. They run Snyk once, fix the criticals, and move on. This catches dependency CVEs but misses the categories that actually breach startups: broken access control, identification failures, and SSRF. Those are application-logic bugs that no scanner will find.
The better approach is to bake one mitigation per category into the codebase as a pattern. Once requireRole exists, every new route uses it. Once you have a typed Zod validator at every API boundary, injection becomes a parsing error. Patterns scale; checklists do not.
| Category | Typical startup bug | One-line mitigation |
|---|---|---|
| A01 Broken Access Control | Anyone with the URL can read another tenant's order | Always check resource.ownerId === session.userId server-side |
| A02 Cryptographic Failures | Passwords hashed with MD5 or stored in plaintext | Use argon2 or bcrypt with a per-app pepper |
| A03 Injection | Raw SQL with template literals, unvalidated req.body | Parameterized queries via Drizzle / Prisma, Zod at the edge |
| A04 Insecure Design | Coupon endpoint with no rate limit, abused for credential stuffing | Threat model the abuse path, add rate limit + idempotency keys |
| A05 Security Misconfiguration | Default admin credentials, verbose stack traces in prod | helmet, env-driven config, error sanitizer middleware |
| A06 Vulnerable Components | Old next, express, or axios with known CVEs | npm audit --omit=dev in CI, Renovate or Dependabot |
| A07 Identification & Auth Failures | Session token in localStorage, no MFA, no lockout | HttpOnly secure cookies via NextAuth/Lucia, MFA on admin |
| A08 Software & Data Integrity | Pulling install scripts via curl | bash in CI | Pinned package hashes, signed artifacts, locked CI runners |
| A09 Logging & Monitoring Failures | Logs full of PII, no alert on auth failure spikes | Structured JSON logs via pino, alert on auth.fail rate |
| A10 SSRF | Server fetches a user-supplied URL with no allowlist | URL allowlist + DNS resolution check before fetch |
The rest of this post is the actual code for each row.
This is the number one breach class for startups because it looks correct in code review. The pattern that fixes it is a route-level guard that checks ownership, not just authentication.
// lib/auth/require-ownership.ts
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/auth/session';
import { db } from '@/lib/db';
export async function requireOrderOwner(req: NextRequest, orderId: string) {
const session = await getSession();
if (!session) return { error: NextResponse.json({}, { status: 401 }) };
const order = await db.query.orders.findFirst({
where: (o, { eq }) => eq(o.id, orderId),
});
if (!order || order.userId !== session.userId) {
return { error: NextResponse.json({}, { status: 404 }) };
}
return { order, session };
}
Return 404 (not 403) on ownership failure. Returning 403 leaks the fact that the resource exists.
The fix is short: never write your own crypto, and never reach for crypto.createHash('sha256') to store a password. Use argon2id with sensible parameters.
import argon2 from 'argon2';
const PEPPER = process.env.PASSWORD_PEPPER!; // 32+ random bytes, not in git
export async function hashPassword(plain: string) {
return argon2.hash(plain + PEPPER, {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB, OWASP 2025 baseline
timeCost: 2,
parallelism: 1,
});
}
export async function verifyPassword(hash: string, plain: string) {
return argon2.verify(hash, plain + PEPPER);
}
The pepper lives in your secrets manager, not in the database. If your DB leaks, the hashes are still useless without the pepper.
In a TypeScript stack, injection is mostly solved by two habits: a query builder that parameterizes by default, and Zod validation at every API boundary. Skipping either invites trouble. If you want a deeper look at making API boundaries airtight in CI, our guide on running integration tests in CI covers the test harness that catches injection regressions before deploy.
// app/api/search/route.ts
import { z } from 'zod';
import { db } from '@/lib/db';
import { posts } from '@/lib/db/schema';
import { ilike } from 'drizzle-orm';
const QuerySchema = z.object({
q: z.string().min(1).max(100).regex(/^[\w\s-]+$/),
limit: z.coerce.number().int().min(1).max(50).default(20),
});
export async function GET(req: Request) {
const params = QuerySchema.safeParse(
Object.fromEntries(new URL(req.url).searchParams),
);
if (!params.success) return Response.json({ error: 'bad input' }, { status: 400 });
const rows = await db
.select()
.from(posts)
.where(ilike(posts.title, `%${params.data.q}%`))
.limit(params.data.limit);
return Response.json({ rows });
}
Note what is not here: no string concatenation into SQL, no raw template literal queries, no trust in the URL.
Insecure design is the category that catches "we forgot the threat model" bugs. The classic example: a /coupons/apply endpoint with no rate limit, abused to brute-force valid codes. The fix is a per-route rate limit plus idempotency on anything that mutates money.
// lib/security/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
export const couponLimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '1 m'),
prefix: 'rl:coupon',
});
// app/api/coupons/apply/route.ts
export async function POST(req: Request) {
const ip = req.headers.get('x-forwarded-for') ?? 'anon';
const { success } = await couponLimit.limit(ip);
if (!success) return Response.json({}, { status: 429 });
// ...verify coupon
}
For payment-adjacent endpoints, also accept an Idempotency-Key header and store it for 24 hours. Stripe popularized this pattern; copy it.
The fixes are boring and effective: helmet for headers, an env loader that fails on missing values, and an error handler that never leaks stack traces in production.
// middleware.ts (Next.js)
import { NextResponse } from 'next/server';
export function middleware() {
const res = NextResponse.next();
res.headers.set('X-Frame-Options', 'DENY');
res.headers.set('X-Content-Type-Options', 'nosniff');
res.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
res.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;",
);
res.headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
return res;
}
If you are deploying on Render, those headers travel with your Next.js app. Our Render deploy guide has the full build-time checklist.
The trap here is treating npm audit as a one-time event. The fix is a daily Renovate run plus a hard CI block on high and critical advisories.
# .github/workflows/audit.yml
name: dep-audit
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22', cache: 'npm' }
- run: npm ci
- run: npm audit --omit=dev --audit-level=high
This adds about 15 seconds to your pipeline and catches the next Log4Shell six hours after it lands. Pair it with Renovate to get auto-PRs for patches, and your weekly merge load stays small. If your dependencies are getting away from you, our piece on managing technical debt in a startup walks through how to keep this kind of upgrade work scheduled rather than crisis-driven.
Roll your own auth and you will lose. Use Lucia, NextAuth, Clerk, or WorkOS. Whatever you pick, three rules: tokens in HttpOnly secure cookies, MFA on admin, lockout after failed attempts.
// lib/auth/login.ts
import { failureLimit } from '@/lib/security/rate-limit';
export async function login(email: string, password: string, ip: string) {
const { success } = await failureLimit.limit(`login:${email}`);
if (!success) throw new Error('locked');
const user = await db.query.users.findFirst({ where: (u, { eq }) => eq(u.email, email) });
// Constant-time verify even when user is missing, to prevent enumeration
const ok = user
? await verifyPassword(user.passwordHash, password)
: await verifyPassword(DUMMY_HASH, password);
if (!user || !ok) {
await logAuthFailure({ email, ip });
throw new Error('invalid');
}
return createSession(user.id);
}
The constant-time verify on missing users prevents account enumeration through timing. That is the kind of detail that takes a senior engineer to spot in code review, not a scanner.
The startup version of this is "we install random tools in CI via curl | bash." Stop doing that. Use lockfile-pinned installs and verify checksums for any binary you download.
# .github/workflows/build.yml
- name: install pinned tools
run: |
EXPECTED_SHA="abc123..."
curl -fsSL https://example.com/tool-v1.2.3 -o /tmp/tool
echo "$EXPECTED_SHA /tmp/tool" | sha256sum --check
chmod +x /tmp/tool && sudo mv /tmp/tool /usr/local/bin/
For your own packages, set "engines" and use npm ci (not npm install) everywhere. Lockfile drift is how supply-chain attacks land in your bundle.
You cannot respond to what you cannot see. Use pino for structured logs, ship them to a real backend (Axiom, Datadog, Better Stack), and set one alert: spike in auth.fail events per minute.
import pino from 'pino';
export const log = pino({
redact: ['req.headers.authorization', 'req.headers.cookie', 'password', '*.password'],
formatters: { level: (label) => ({ level: label }) },
});
// usage
log.warn({ event: 'auth.fail', userId, ip, reason: 'invalid_password' });
The redact config strips PII before it ever leaves the process. That keeps your logs out of regulatory scope and stops credentials from ending up in third-party tools.
Anywhere your server fetches a user-supplied URL (webhook tester, image resizer, OG image scraper, AI-tool plugin), you need an allowlist. The naive fetch(req.body.url) is how attackers reach your cloud metadata endpoint.
import dns from 'node:dns/promises';
import net from 'node:net';
const ALLOWED_HOSTS = new Set(['api.partner.com', 'hooks.example.com']);
export async function safeFetch(rawUrl: string) {
const url = new URL(rawUrl);
if (url.protocol !== 'https:') throw new Error('https only');
if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error('host not allowed');
const { address } = await dns.lookup(url.hostname);
if (net.isIP(address) === 0 || isPrivate(address)) throw new Error('private IP');
return fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(5000) });
}
function isPrivate(ip: string) {
return /^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(ip);
}
redirect: 'manual' prevents the canonical SSRF bypass where the server redirects you to 169.254.169.254. The DNS check stops DNS-rebinding tricks.
any or unknown are theater. Run integration tests that send malformed payloads on purpose. Our E2E testing for a SaaS guide has a chapter on negative-path tests.Access-Control-Allow-Origin plus credentialed cookies is a CSRF buffet.If you are two founders at pre-revenue with no user data, you do not need MFA on day one. You do need A01, A03, and A07: ownership checks, parameterized queries, and a real auth library. Everything else compounds value over the first 90 days. The Top 10 is a priority order, not a tick list.
The cheapest way to ship the right baseline early is to pair with an engineer who has done it before. On Cadence, a senior engineer at $1,500/week will set up the full security baseline (helmet, Zod, argon2, rate limits, structured logging, SSRF guards, CI audit gate) in about three days. Every engineer on Cadence is AI-native by default, vetted on Cursor, Claude Code, and Copilot fluency before they unlock bookings, so the pairing actually accelerates the codebase rather than just adding hours.
If you would rather not book anyone and just want a read on what you have, audit your stack with Ship or Skip and you will get an honest grade on where the OWASP gaps actually sit.
requireOwnership middleware. Wrap every route that returns or mutates tenant data.argon2id with a pepper from your secrets manager. Migrate on next login.helmet-equivalent headers via Next.js middleware. Add a CSP and HSTS.npm audit --audit-level=high to CI. Enable Renovate or Dependabot for daily PRs.pino with redact. Alert on auth-failure rate.safeFetch with an allowlist and private-IP check.Once these are in, do a quarterly review: re-read the OWASP Top 10 page, look at your last 90 days of dependency PRs, and run one tabletop exercise on the worst-case breach. That is the loop, not a one-off project.
If you want a real engineer to ship this entire baseline for you, book a senior on Cadence and you get a 48-hour free trial before any money changes hands. Two days is usually enough to see whether the pairing works.
For a startup at 5,000 to 20,000 lines of code, a senior engineer ships the full baseline in 3 to 5 working days. The longest items are usually auth migration (if you are moving off a homegrown system) and the access-control audit on existing routes.
No. The OWASP Top 10 is mostly application-layer hygiene, not specialized security work. A senior product engineer who has done this before can do it. Once the patterns are in the codebase, every new feature inherits them. You only need a dedicated security hire around series A, when compliance (SOC 2, HIPAA) becomes a sales gate.
Expect roughly $0 to $200/month at startup scale. Upstash Redis for rate limiting is free under 10k requests/day. Axiom or Better Stack run $0 to $50/month for structured logs. Snyk and Socket have free tiers for OSS dependency scanning. Renovate and Dependabot are free. The real cost is engineering time, not tools.
Three layers. First, unit tests on the security helpers themselves (requireOwnership, safeFetch, hashPassword). Second, integration tests that send hostile payloads (SQL injection strings, traversal paths, private-IP URLs) and assert 400/403/404. Third, run a third-party pen test once you have paying customers; expect $5k to $15k for a focused web-app test.
The categories are identical, but two implementations change. Rate limiting must use a shared store (Upstash, DynamoDB, Redis) because in-memory state does not survive between invocations. Logging must ship to an external sink, not local files. Everything else (Zod, argon2, ownership checks, SSRF guards) works the same on Vercel Edge, Cloudflare Workers, AWS Lambda, and long-running Node.