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
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 9An honest, side-by-side comparison of the ten most-deployed application security monitoring tools — from enterprise platforms to free open-source options.
May 9A 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