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
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
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 27Add 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