Detecting Credential Stuffing in Next.js in 30 Lines

A working credential-stuffing detector for Next.js using middleware + KV — works on Vercel Edge and Node runtime.

Lhoussine
May 9, 2026·6 min read

Detecting Credential Stuffing in Next.js in 30 Lines

Credential stuffing on Next.js apps usually targets /api/auth/* or /login route handlers. The detection logic is the same as in Express — count failures, block — but the implementation has to account for the Edge Runtime / serverless architecture where in-memory state doesn't persist across requests.

For broader context on credential stuffing detection, see the APM credential stuffing post.

The architecture

Two pieces:

  1. Middleware that checks if the IP is currently blocked — runs on every request, blocks fast.
  2. API route logic that increments failure count on bad auth — runs only on the auth endpoint.

Both sides use a shared store (Vercel KV or Upstash Redis) to communicate. In-memory state would break because Edge functions don't share memory across instances.

The middleware (block side)

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { kv } from '@vercel/kv';

export async function middleware(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown';

  const blocked = await kv.get(`blocked:${ip}`);
  if (blocked) {
    return new NextResponse(
      JSON.stringify({ error: 'Too many failed attempts. Try again later.' }),
      { status: 429, headers: { 'Content-Type': 'application/json' } }
    );
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/api/auth/:path*', '/login'],
};

The matcher scopes the middleware to auth routes only, so we're not paying for KV lookups on every static asset.

The recorder (API route side)

// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { kv } from '@vercel/kv';

const WINDOW_MS = 5 * 60 * 1000;
const THRESHOLD = 10;
const BLOCK_DURATION_S = 60 * 60;

async function recordFailure(ip: string) {
  const now = Date.now();
  const key = `failures:${ip}`;
  await kv.zadd(key, { score: now, member: `${now}` });
  await kv.zremrangebyscore(key, 0, now - WINDOW_MS);
  await kv.expire(key, WINDOW_MS / 1000 + 60);
  const count = await kv.zcard(key);
  if (count >= THRESHOLD) {
    await kv.set(`blocked:${ip}`, '1', { ex: BLOCK_DURATION_S });
  }
}

export async function POST(req: NextRequest) {
  const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown';
  const { email, password } = await req.json();

  const user = await authenticate(email, password);
  if (!user) {
    await recordFailure(ip);
    return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
  }

  return NextResponse.json({ token: createToken(user) });
}

That's the entire detection. 10 failures in 5 minutes from one IP triggers a 1-hour block. The block returns 429, which is the correct semantic — they're not unauthorized, they're rate-limited.

Why two places?

The split between middleware and API route is deliberate:

  • Middleware blocks fast. No auth logic runs for blocked IPs — saves compute on Vercel pricing and reduces effective attack surface.
  • API route records. Failures are recorded after the auth check completes, with the actual outcome.

If you put both in middleware, you'd have to either run auth logic inside middleware (overkill, edge runtime limitations) or trust the API route to also block (defeats the speed purpose).

Without Vercel KV

If you're not on Vercel or don't want KV:

  • Upstash Redis: drop-in replacement, works on Edge runtime via REST. Free tier is generous.
  • Self-hosted Redis: works only on Node runtime, not Edge. If your Next.js app runs on Node (Railway, Render, AWS), this is fine.
  • Hand-rolled with localStorage: doesn't work — no persistence across requests on serverless.

For Vercel deployments specifically, KV is the path of least resistance.

What this catches

  • Per-IP credential stuffing where the attacker hammers /api/auth/login from one source
  • Slow attacks if the attacker stays under 10 attempts per 5 minutes per IP, but the block lasts 1 hour so they can't easily reset

What it misses

  • Distributed attacks across many IPs (each IP under threshold)
  • Attacks that rotate IPs faster than the window resets

For those, layer additional rules:

  • ASN-level aggregation: block if total failures across an ASN exceed 5× the per-IP threshold.
  • Username-based detection: block if the same email is being tried from multiple IPs.

The vendor option

If you want production-grade credential-stuffing detection without writing it: install SecureNow and the detection rules ship pre-configured. The dashboard alerts you to attacks in progress, the AI investigation answers "is this part of a coordinated attempt across our user base?", and the firewall blocks at the IP layer based on reputation feeds.

For a Next.js app on Vercel specifically:

// instrumentation.ts (Node runtime portions only)
export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    await import('securenow/register');
  }
}

Plus the middleware approach above for Edge runtime portions. The two layer cleanly.

Related

Frequently Asked Questions

Does this work on Vercel Edge?

Yes, when paired with Vercel KV or Upstash Redis. Edge Runtime can't use in-memory state across requests because each request may run on a different instance.

What about App Router?

Identical — middleware.ts works the same in Pages Router and App Router. Route Handlers can record auth failures via the same fetch to the KV store.

Why use middleware instead of API route logic?

Middleware sees every request before routing, so it can block before any expensive auth logic runs. The middleware fast-paths blocked IPs to a 429 response without invoking the auth handler at all.

What's the latency impact?

Edge middleware adds 1–5ms; the KV lookup adds another 5–20ms depending on region. For unauthenticated traffic, you can skip the KV lookup entirely with smart matchers.

Recommended reading

What 1.2B Requests Look Like: Anomaly Patterns from the SecureNow Firewall Fleet

Aggregated, anonymized data from 1.2B requests across the SecureNow customer fleet. Top anomaly types, peak hours, and the day-of-week patterns nobody publishes.

May 9
10 Best Application Security Monitoring Tools in 2026

An honest, side-by-side comparison of the ten most-deployed application security monitoring tools — from enterprise platforms to free open-source options.

May 9
The 2026 npm Supply-Chain Attack Survey, Q2

A quarterly tally of malicious npm packages, the major incidents, and detection patterns. April 2026 set a new record at 847 confirmed malicious packages — here's what they did and how to detect them.

May 9