Detecting Credential Stuffing in Nuxt in 30 Lines

A Nuxt server middleware + a server route handler that catches per-IP credential stuffing. Works on self-hosted Nuxt 3 and adapts cleanly to Vercel.

May 9, 2026·4 min read

Detecting Credential Stuffing in Nuxt in 30 Lines

Two server files, shared state via Redis or memory.

Block-side middleware

// server/middleware/credential-stuffing-block.ts
const blocked = new Map<string, number>();

export default defineEventHandler(async (event) => {
  const url = getRequestURL(event);
  if (!url.pathname.startsWith('/api/auth')) return;

  const ip = getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
    || getRequestIP(event)
    || '';

  const blockedUntil = blocked.get(ip);
  if (blockedUntil && blockedUntil > Date.now()) {
    setResponseStatus(event, 429);
    return { error: 'Too many failed attempts' };
  }
  if (blockedUntil) blocked.delete(ip);
});

export { blocked };

Record-side handler

// server/api/auth/login.post.ts
import { blocked } from '../../middleware/credential-stuffing-block';

const FAILURES = new Map<string, number[]>();
const WINDOW_MS = 5 * 60 * 1000;
const THRESHOLD = 10;
const BLOCK_DURATION_MS = 60 * 60 * 1000;

function recordFailure(ip: string) {
  const now = Date.now();
  const recent = (FAILURES.get(ip) || []).filter(t => now - t < WINDOW_MS);
  recent.push(now);
  FAILURES.set(ip, recent);
  if (recent.length >= THRESHOLD) blocked.set(ip, now + BLOCK_DURATION_MS);
}

export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  const ip = getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
    || getRequestIP(event)
    || '';

  const user = await authenticate(body.email, body.password);
  if (!user) {
    recordFailure(ip);
    throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' });
  }

  return { token: createToken(user) };
});

For Vercel: Upstash Redis

In-memory state breaks on serverless. Replace the Maps with Upstash:

import { Redis } from '@upstash/redis';
const redis = Redis.fromEnv();

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

Cross-service correlation with SecureNow

If you're running multiple Nuxt apps or microservices, the per-app detector misses coordinated attacks across them. The SecureNow preload (-r securenow/register) feeds all your services into one detection layer with cross-service correlation:

node -r securenow/register .output/server/index.mjs

Frequently Asked Questions

Should I use server middleware or a route handler?

Both. Middleware blocks already-locked IPs fast; the auth route handler records new failures. Two pieces, one shared state.

Where does the state live?

Single-instance Nuxt: in-memory Maps in a Nitro plugin. Multi-instance or Vercel: Redis (Upstash works on serverless).

Is `getRequestIP()` reliable?

Use `getRequestHeader('x-forwarded-for')?.split(',')[0]?.trim()` first; fall back to `getRequestIP()`. Behind any proxy or CDN, the header is more accurate.

Recommended reading

How to Investigate an Account Takeover by Asking Questions

A step-by-step account takeover investigation in plain English: the ten questions to ask, where each answer lives in your logs, traces and security events, and what to check before you call it closed.

Oct 28
investigate account takeover
Why Per-IP Rate Limits Fail Against Rotating IPs (and What to Count Instead)

One login, 100 rotating IPs: a 10-per-minute-per-IP limit let 275 of 275 requests through. How to spot rotation in your traffic and what to key your limits on.

Oct 27
Rate Limit per API Key, per Session or per IP in Node.js, Without Code Changes

Add rate limits by route and method, counted per IP, per API key, per session cookie or globally, written in plain English and shared across servers with Redis.

Oct 27