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.
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
Related
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
We deployed a real Next.js app to AWS, connected SecureNow traces, logs, body capture, multipart metadata, and firewall, then used an AI-assisted MCP workflow to block the attacker IP.
May 18A prioritized checklist of web development security best practices for Node.js teams, from secure coding to production monitoring and incident response.
May 12
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