DEV Community

Cover image for Rate-limiting anonymous users with no login, no Redis — just a cookie and an IP
dmitryvz
dmitryvz

Posted on

Rate-limiting anonymous users with no login, no Redis — just a cookie and an IP

I let people use my app before they sign up — upload a photo, get outfit feedback, no account needed. Great for conversion, right up until you remember every one of those anonymous calls hits a paid vision API. So I needed a free tier with a hard ceiling: 3 analyses per day per person, where "person" has no user ID, no session, and no reason to be honest about who they are.

The usual answer for the counting part is "spin up Redis and do a sliding window." But counting was never the hard problem here — identifying the user was, and a counter store does nothing for that. For the counting I already had a MongoDB, a cookie, and the one header every proxy sets. Turns out that's enough to get surprisingly far. The failure modes are worth knowing before you reach for heavier infrastructure, though.

The problem: who is "this user" when there is no user?

For a logged-in user, rate-limiting is easy: you have a stable userId, count their rows for today, compare against a limit. Done.

For an anonymous visitor you have neither an identity nor anything you can trust. So you assemble one out of the two weak signals you do have:

  1. A cookie you set — stable across requests, but trivially cleared.
  2. The IP address — harder to change casually, but shared by everyone behind the same router or NAT.

Neither is reliable alone. A cookie resets when someone opens an incognito window. An IP is shared by an entire office. The trick is to use both, accept that each covers the other's blind spot, and be honest about what still leaks through (more on that at the end).

Step 1: give every guest a stable ID in a cookie

A quick note on the stack before the code: this all runs inside a Next.js Server Action — server-side only. That's why a single file reaches for both next/headers (to read the cookie and IP off the incoming request) and mongoose (to count rows): in the App Router, request context and database access live in the same server function, not split across a client and an API route.

The first time an anonymous visitor does something rate-limited, I mint an ID and drop it in an httpOnly cookie. On every later request, I read it back.

import { cookies, headers } from 'next/headers';
import { Types } from 'mongoose';

const cookieStore = await cookies();
let guestId = cookieStore.get('guest_id')?.value || '';

if (!guestId) {
  guestId = new Types.ObjectId().toString();
  cookieStore.set('guest_id', guestId, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    maxAge: 60 * 60 * 24 * 30, // 30 days
  });
}
Enter fullscreen mode Exit fullscreen mode

Three deliberate choices in that cookie:

  • httpOnly — JavaScript can't read or tamper with it. It's an opaque server-side handle, not something the client gets to negotiate.
  • secure in production — never travels over plain HTTP in prod, but stays loose in local dev so you're not fighting https on localhost.
  • A Mongoose ObjectId as the value — I already had mongoose imported, and new Types.ObjectId() is a perfectly good source of a unique, unguessable token. No extra dependency for UUIDs.

This is the cooperative-user identity. It's stable for anyone who isn't actively trying to dodge the limit — which is most people.

Step 2: grab the IP for the people who aren't cooperative

Clearing a cookie is one click. Cookie-only, and the limit is decorative — hit the wall, open an incognito window, you're back to three. The IP is the backstop for that.

On any platform behind a proxy (Vercel, most of them), you don't read the socket address — you read x-forwarded-for, which holds the chain of IPs the request passed through. The original client is the first entry:

const headersList = await headers();
const xForwardedFor = headersList.get('x-forwarded-for') || '';
const guestIp = xForwardedFor.split(',')[0].trim() || 'unknown';
Enter fullscreen mode Exit fullscreen mode

x-forwarded-for looks like client, proxy1, proxy2. Splitting on the comma and taking [0] gives you the client the proxy saw. The || 'unknown' matters: I'd rather bucket all unidentifiable traffic into one shared 'unknown' IP than crash or, worse, hand every header-less request a fresh unlimited quota.

That last part is the trap. The limit works by counting rows whose guestIp matches. If a missing header produced an empty or unique value instead, every header-less request would look brand-new, never match a prior row, and count as zero used — so the limit would never trip and anyone who could strip the header gets unlimited analyses. Collapsing them all to one shared 'unknown' bucket means they share a single 3/day allowance instead. It's a deliberate fail-closed choice: when the request carries nothing you can identify it by, default to the most restrictive bucket, not the most permissive one.

Step 3: count today's usage across either signal

Here's the part that does the actual work. To count how many analyses this guest has run today, I match a row where either the cookie ID or the IP matches — and only for rows that aren't tied to a logged-in account:

export function countGuestPromptsToday(guestId: string, guestIp: string): Promise<number> {
  const startOfDay = new Date();
  startOfDay.setHours(0, 0, 0, 0);

  return Prompt.countDocuments({
    $or: [{ guestId }, { guestIp }],
    userId: { $exists: false },
    createdAt: { $gte: startOfDay },
  });
}
Enter fullscreen mode Exit fullscreen mode

The $or is the whole design in one line:

  • Clear your cookie? Your IP still matches your earlier rows, so the count doesn't reset.
  • On a fresh IP (phone vs. laptop, coffee-shop wifi)? Your cookie still matches.

You need to defeat both signals in the same session to get a clean reset — meaningfully harder than clicking "clear cookies."

Two things here will bite you if you skip them:

  • userId: { $exists: false } keeps guest counting and logged-in counting strictly separate. Without it, a guest sharing an office IP with a logged-in user would have that user's analyses counted against the guest's free tier. Different identity systems, different buckets.
  • startOfDay via setHours(0,0,0,0) resets the quota at midnight in the server's timezone. On most serverless hosts that's UTC, so in practice the window resets at 00:00 UTC. If you need user-local midnight, this is a known gotcha — you'd have to pass the client's timezone in and compute the boundary from that. I deliberately didn't; a single global reset is simpler and good enough for a free tier.

Step 4: make the query cheap

countDocuments runs on every single guest request, so it cannot be a collection scan. Indexing an $or correctly is less obvious than it looks, though, and the natural first guess is wrong.

The tempting move is one compound index covering both fields:

// Looks right. Doesn't work for the $or.
promptSchema.index({ guestId: 1, guestIp: 1, createdAt: -1 });
Enter fullscreen mode Exit fullscreen mode

Two MongoDB rules sink this:

  • A compound index can only be entered from its leading field. A query on guestIp alone can't use { guestId, guestIp, createdAt }, because guestIp sits in the middle — you can't start a compound index from a non-prefix field.
  • For an $or, every branch must be independently indexable, or the whole query collection-scans. MongoDB evaluates each $or clause separately and only uses indexes if all of them are supported.

So with the single index above, the { guestId } branch is covered but the { guestIp } branch isn't — and that one unindexed branch forces a full collection scan for the entire query. The index that looks purpose-built does nothing for the query it was built for.

The fix is one index per branch, each leading with the field that branch filters on:

promptSchema.index({ guestId: 1, createdAt: -1 });
promptSchema.index({ guestIp: 1, createdAt: -1 });
Enter fullscreen mode Exit fullscreen mode

Now each $or clause has an index it can enter from its leading field, and createdAt is a true range bound on each (not a post-scan filter). MongoDB runs two index scans and unions the results; the userId: { $exists: false } rides along as a cheap residual filter on the already-narrowed set. Confirm it on your own data with .explain("executionStats") — the single-index version shows a COLLSCAN, the two-index version an OR over two IXSCANs.

The general rule worth remembering: an $or is only as fast as its slowest branch, and each branch needs its own index. A compound index spanning the branches doesn't help — it can only serve whichever branch its leading field belongs to.

The enforcement loop

With identity resolved and the count query in hand, enforcement is just: count, block if over, otherwise do the work and write a row.

const promptsToday = await countGuestPromptsToday(guestId, guestIp);
if (promptsToday >= MAX_DAILY_PROMPTS) {
  return { error: 'You have reached your daily limit.' };
}

// ...run the analysis, then persist the row so the next count sees it
await Prompt.create({ guestId, guestIp, result, imageUrl });
Enter fullscreen mode Exit fullscreen mode

That last line is doing the real work: every analysis writes one row, and that row is what the next count reads back. There's no counter to increment and no TTL to expire — the stored history is the counter, which is why it can never drift out of agreement with reality.

Why this holds up

  • No new infrastructure. It reuses the database the app already had. Zero extra services to provision, pay for, or monitor.
  • No counter to maintain. The limit is derived from the rows you're already storing. There's no INCR/EXPIRE dance and no risk of the counter and the real data disagreeing.
  • It survives a cookie wipe. The $or against the IP is the part a naive cookie-only approach misses.
  • It's debuggable. Every decision is a row you can query after the fact. "Why was this person blocked?" is a find(), not a guess about evicted cache keys.

A dedicated counter store with atomic increments and TTLs is genuinely better at high volume — you avoid a count query per request and get cleaner concurrency. But that's a choice about the counting layer only; the cookie + IP identity work sits in front of it either way. For a free tier measured in single-digit daily requests per visitor, a counted query against an indexed collection is plenty, and you skip an entire piece of infrastructure.

Ways to make it stronger

The honest framing: this stops casual abuse — the person who clears their cookie to get three more. It does not stop a motivated attacker, because both signals are spoofable. x-forwarded-for is just a header; anyone hitting your origin directly (or through a proxy that lets them set it) can put whatever they want in it. If you need to actually hold a line, layer these on:

  • Trust the platform's real-client header, not raw x-forwarded-for. Vercel exposes x-vercel-forwarded-for / x-real-ip, Cloudflare sets cf-connecting-ip. These are populated by your edge and can't be overridden by the client, unlike the raw forwarding chain. Use them when you're behind a known proxy.
  • Add a CAPTCHA / invisible challenge at the limit boundary. Cloudflare Turnstile or hCaptcha (both have free tiers and are less intrusive than reCAPTCHA) on the first request of a session, or only once a guest is near the ceiling. This is the cheapest big jump in abuse resistance — it raises the cost of automated quota-farming from "a for-loop" to "solving a challenge per identity."
  • Proof-of-work as a lighter alternative. If a CAPTCHA feels too heavy for a free-to-try tool, a small client-side proof-of-work (e.g. mCaptcha) makes scripted mass-requests expensive without a human-visible challenge.
  • AND instead of OR for fewer false positives. My $or matches broadly — a row counts against you if either signal lines up, so it errs toward over-counting. That's aggressive by design: great for stopping resets, but an office full of people behind one NAT IP now shares a single limit, so legitimate users can get false-positive blocks. Switching to $and (require cookie and IP to match) flips the tradeoff — far fewer false positives, but resets get easy again, since clearing the cookie is enough to break the match.
  • Browser fingerprinting (canvas, fonts, headers via something like FingerprintJS) adds a third signal that's harder to reset than a cookie. It's a privacy tradeoff and an arms race, so weigh it against how much abuse actually costs you.
  • Graduate to a real rate-limit store when volume demands it. @upstash/ratelimit over serverless Redis gives you atomic sliding-window limits with per-request latency lower than a count query. Reach for it when you've outgrown "count the rows," not before.
  • Rate-limit the signup path too. Otherwise the obvious dodge is to script free-account creation and farm those quotas instead. The anonymous limit is only as strong as the cheapest identity behind it.

The pattern I'd actually recommend: start with cookie + IP exactly as above (it's nearly free and covers the 90% case), then add Turnstile at the boundary the day you see someone scripting it. Don't pay for the heavy machinery until the cheap version visibly fails.

See it running

This is the exact code behind the free tier on stylebias.app: guests get 3 analyses a day, cookie-cleared or not. If you've solved anonymous rate-limiting a different way — signed tokens, edge middleware, a fingerprinting service that actually held up — I'd like to hear what worked, and what got abused anyway, in the comments.

Top comments (0)