I am a...
Learn more
How it worksPricingFAQ
Account
May 17, 2026 · 11 min read · Cadence Editorial

How to implement OWASP Top 10 mitigations

owasp top 10 implementation — How to implement OWASP Top 10 mitigations
Photo by [REINER SCT](https://www.pexels.com/@reiner-sct-140938854) on [Pexels](https://www.pexels.com/photo/man-holding-a-calculator-in-front-of-laptop-10330110/)

How to implement OWASP Top 10 mitigations

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.

Why this matters in 2026

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.

The default approach (and why it's flawed)

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.

The Top 10 at a glance

CategoryTypical startup bugOne-line mitigation
A01 Broken Access ControlAnyone with the URL can read another tenant's orderAlways check resource.ownerId === session.userId server-side
A02 Cryptographic FailuresPasswords hashed with MD5 or stored in plaintextUse argon2 or bcrypt with a per-app pepper
A03 InjectionRaw SQL with template literals, unvalidated req.bodyParameterized queries via Drizzle / Prisma, Zod at the edge
A04 Insecure DesignCoupon endpoint with no rate limit, abused for credential stuffingThreat model the abuse path, add rate limit + idempotency keys
A05 Security MisconfigurationDefault admin credentials, verbose stack traces in prodhelmet, env-driven config, error sanitizer middleware
A06 Vulnerable ComponentsOld next, express, or axios with known CVEsnpm audit --omit=dev in CI, Renovate or Dependabot
A07 Identification & Auth FailuresSession token in localStorage, no MFA, no lockoutHttpOnly secure cookies via NextAuth/Lucia, MFA on admin
A08 Software & Data IntegrityPulling install scripts via curl | bash in CIPinned package hashes, signed artifacts, locked CI runners
A09 Logging & Monitoring FailuresLogs full of PII, no alert on auth failure spikesStructured JSON logs via pino, alert on auth.fail rate
A10 SSRFServer fetches a user-supplied URL with no allowlistURL allowlist + DNS resolution check before fetch

The rest of this post is the actual code for each row.

A01: Broken access control

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.

A02: Cryptographic failures

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.

A03: Injection

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.

A04: Insecure design

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.

A05: Security misconfiguration

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.

A06: Vulnerable and outdated components

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.

A07: Identification and authentication failures

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.

A08: Software and data integrity failures

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.

A09: Security logging and monitoring failures

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.

A10: Server-side request forgery

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.

Common pitfalls

  • Validating only the happy path. Zod schemas that allow 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.
  • Trusting the client to set a tenant id. The tenant id always comes from the session, never the request body.
  • Forgetting CORS on internal APIs. A wildcard Access-Control-Allow-Origin plus credentialed cookies is a CSRF buffet.
  • Logging the raw request body. Tokens, card numbers, and PII end up in your log retention. Redact at the logger level.
  • Skipping security in staging. Staging is where attackers look first. Same headers, same auth, same rate limits.

When you can skip parts of this

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.

Steps

  1. Add requireOwnership middleware. Wrap every route that returns or mutates tenant data.
  2. Replace any raw SQL with Drizzle or Prisma. Add a Zod schema at every API boundary.
  3. Switch password storage to argon2id with a pepper from your secrets manager. Migrate on next login.
  4. Install helmet-equivalent headers via Next.js middleware. Add a CSP and HSTS.
  5. Add npm audit --audit-level=high to CI. Enable Renovate or Dependabot for daily PRs.
  6. Pick an auth library (Lucia, NextAuth, Clerk, WorkOS). Move tokens to HttpOnly cookies, add MFA on admin, add a per-email rate limit.
  7. Ship structured logs via pino with redact. Alert on auth-failure rate.
  8. Wrap every user-supplied URL fetch in 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.

FAQ

How long does it take to implement all ten mitigations?

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.

Do I need a security team for this?

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.

What tooling cost should I budget?

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.

How do I test that the mitigations actually work?

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.

Is it different for serverless or edge runtimes?

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.

All posts