HomeGuidesStop brute force and credential stuffing on your login

Guide

Stop brute force and credential stuffing on your login

Rate limits, CAPTCHA, lockout, and MFA for Supabase, Clerk, Firebase, and Auth.js logins, plus generic errors and a safe way to test your own app.

18 min read

Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.

Your login form is the one page every attacker can reach. AI coding tools wire up sign-in in minutes, and it works for real users. What it rarely does is notice one IP trying thousands of passwords, or one weak password tried against every email in a leaked list. That is the server's job, and depending on your auth provider, some of it is already done for you.

This guide covers what Supabase, Clerk, Firebase, and Auth.js give you out of the box, what you have to switch on, and the code to add when you run your own login route. If your problem is who can see what after sign-in, read client-side auth is not security instead. This page is about getting in.

Checked against the OWASP, MITRE, NIST, Supabase, Clerk, Firebase, Google Cloud, Auth.js, and Upstash docs linked below on October 19, 2026. Defaults change, so confirm the numbers in your own dashboard. VibeLocked is independent and not affiliated with any of these products.

The short version

  1. Three attacks, one form. Brute force, password spraying, and credential stuffing. You need limits per IP and per account.
  2. Know what your provider does. Supabase limits by IP. Clerk locks accounts and verifies new devices. Firebase has abuse protection and email enumeration protection. Auth.js Credentials has nothing built in.
  3. Turn on the extras: CAPTCHA, leaked password checks, and MFA, at least for admins.
  4. Say the same thing for every failure on login, signup, and password reset.
  5. Limit the side doors too: password reset, OTP codes, and magic links.
  6. Test it on your own staging app and confirm you actually see a 429 or a lockout.

Brute force, password spraying, and credential stuffing

OWASP's Authentication Cheat Sheet and Credential Stuffing Prevention Cheat Sheet define the three attacks this way:

  • Brute force: "Testing multiple passwords from dictionary or other source against a single account."
  • Credential stuffing: "Testing username/password pairs obtained from the breach of another site."
  • Password spraying: "Testing a single weak password against a large number of different accounts."

MITRE ATT&CK files all three under T1110 Brute Force, with password spraying and credential stuffing as sub-techniques. Spraying exists to dodge per-account lockouts, and stuffing works because people reuse passwords. The bug on your side has a name too: CWE-307, Improper Restriction of Excessive Authentication Attempts.

Why this matters for defenses:

  • IP limits get spread thin. OWASP warns that stuffing toolkits route requests through proxy networks, which "may defeat both IP block-lists and rate limiting, as per IP request volume may remain relatively low."
  • Per-account counters miss spraying, because each account sees only one or two attempts. OWASP still says the failed-login counter "should be associated with the account itself, rather than the source IP address," so you want both.
  • Lockouts can be abused. OWASP cautions that a lockout system can be used to lock other users out, and suggests letting the forgot password flow still work for locked accounts.
  • MFA is the strongest single fix. OWASP calls it "by far the best defense against the majority of password-related attacks."

NIST's SP 800-63B sets the outer bound: verifiers "SHALL limit consecutive failed authentication attempts" on one account to no more than 100, and may add bot challenges or increasing wait times so attackers cannot easily lock out real users. A hundred is a ceiling, not a target.

What each provider gives you

Built in You turn on or build
Supabase Auth Per-IP rate limits on sign-in, signup, reset, OTP, verify; 429 when exceeded CAPTCHA, leaked password protection (Pro plan and up), MFA, IP forwarding for server-side logins
Clerk Account lockout, Device Trust for new devices (newer apps), compromised password checks Review lockout settings, enable Device Trust on older apps, MFA (paid plan for production)
Firebase Auth Abuse protection, email enumeration protection on newer projects reCAPTCHA Enterprise and SMS defense (Identity Platform), App Check for Auth (preview), MFA (Identity Platform)
Auth.js Credentials Nothing for rate limiting Per-IP and per-account limits, generic errors, password reset, MFA

Supabase Auth

Supabase's rate limits page lists what Auth enforces. The token endpoint (/auth/v1/token, which covers password sign-in) is limited by IP address and "defaults to 150 requests per 5 minutes, with bursts up to 30 requests." Signup, password recovery, resend, magic link, and OTP endpoints share a per-IP limit that defaults to 30 requests per 5 minutes. Password reset and OTP or magic link requests also have a per-user window that defaults to 60 seconds before another request is allowed. Exceeding a limit returns 429 Too Many Requests. Most of these are customizable under Authentication, Rate Limits.

The table does not describe a per-account lockout for password sign-in, and a slow stuffing run spread over many IPs can stay under per-IP limits. So add the other layers:

  • CAPTCHA. Supabase supports hCaptcha and Cloudflare Turnstile for sign-in, sign-up, and password reset forms (Supabase CAPTCHA). Turn it on under Authentication, Bot and Abuse Protection as that guide describes. Only the widget's site key belongs in your frontend; the CAPTCHA secret key lives in Supabase's settings, never in your code. Then pass the token from the widget:
const { error } = await supabase.auth.signInWithPassword({
  email,
  password,
  options: { captchaToken }, // from the hCaptcha or Turnstile widget
});

Once it is on, sign-in, sign-up, and reset calls need a token, so update all three forms before enabling it in production.

  • Leaked password protection. Supabase can reject passwords found in HaveIBeenPwned's Pwned Passwords, which is aimed squarely at credential stuffing. Its password security page lists it on the Pro Plan and above. Minimum length and required characters are set in the same place.
  • MFA. Supabase supports TOTP and phone factors and marks the session aal2 after a second factor. The MFA docs stress that adding MFA to the UI "does not in-and-of-itself offer a higher level of security"; you have to enforce aal2 in RLS, APIs, and server rendering. MFA challenge and verify are limited by IP to 15 requests per minute, and that limit is not customizable.

The server-side login gotcha. If your login runs in a Server Action or API route, Supabase sees your server's IP, not the user's. The rate limits page says it "may be necessary to forward the end-user IP address" so all your users are not limited as one client. Forwarding requires turning on IP address forwarding in the Rate Limits settings and sending Sb-Forwarded-For with a secret key, not the publishable key. If you do this, use a dedicated server-only client just for the auth call, read the IP from the header your host documents as trusted, and never use that secret-key client for data queries, since it bypasses RLS (anon key vs service role key). The simpler alternative is to call signInWithPassword from the browser so Supabase sees the real IP.

More Supabase settings worth a pass before launch are in Supabase Auth security settings.

Clerk

Clerk has the most built in. Its account lockout feature tracks failed password attempts, password resets, backup codes, email and phone codes, and TOTP attempts. New instances lock an account after 10 failed attempts for one hour. Clerk says that default changed from 100 to 10 on July 6, 2026, and existing instances kept their old limit, so check yours: Protect, Protections, Lockout policy. The minimum attempt limit is 5, and you can set the duration or lock until an admin unlocks.

For credential stuffing, Device Trust (formerly Client Trust) requires an email code, SMS code, or email link when a user enters a valid password from a new device and has not enabled MFA. It is on by default for applications created after November 14, 2025; older ones enable it on the same Protections page. If you built a custom sign-in flow, it must handle the needs_client_trust status, or users on new devices get stuck.

Two more settings:

  • Reject compromised passwords compares passwords against HaveIBeenPwned and prompts a reset when a correct password turns up in breach data (Clerk password rules). It lives under User & authentication, Password.
  • Challenge suspicious sign-ups runs a Turnstile check on sign-ups that look automated or come through VPNs, Tor, or proxies. Clerk's docs are clear that it "never challenges a sign-in," so it is not your login defense. They also warn that the Native API pathway is public and must not be your only boundary against automated traffic.

Clerk MFA (SMS, authenticator app, backup codes) "requires a paid plan for production use," per its sign-up and sign-in options. While you are in the dashboard, make sure only the pk_ key is public (Clerk publishable vs secret key).

Firebase Authentication

Firebase publishes a limits page with quotas like new accounts per IP per hour and password reset emails per day, but it does not publish a threshold for failed password attempts. It does say "abuse protections may be enabled without warning on accounts that demonstrate suspicious traffic patterns," and the Auth REST API lists TOO_MANY_ATTEMPTS_TRY_LATER: "We have blocked all requests from this device due to unusual activity." Treat that as a backstop you cannot tune.

What you control:

  • Email enumeration protection. Google's Identity Platform docs say it is on by default for projects created on or after September 15, 2023, and recommend it for all projects. With it on, a bad sign-in returns INVALID_LOGIN_CREDENTIALS instead of EMAIL_NOT_FOUND, and password reset gives no error for unknown emails. Older projects must turn it on under Authentication, Settings, User actions. Sign-up still returns EMAIL_EXISTS, so Google suggests App Check or reCAPTCHA on sign-up.
  • reCAPTCHA Enterprise. The Identity Platform integration scores email and password sign-in, sign-up, password reset, and phone sign-in. Start in audit mode, which only measures, then switch to enforce, which rejects requests without a token. Google lists reCAPTCHA Enterprise and Identity Platform as billable, so check pricing first.
  • App Check. The App Check overview lists Firebase Authentication as supported in Preview. It proves requests come from your real app, which raises the cost of scripted attacks, but it is not authentication (Firebase API key and App Check).
  • MFA. Firebase's web MFA guide requires upgrading to Firebase Authentication with Identity Platform.

Auth.js / NextAuth Credentials, and any login route you wrote

This is the one to worry about. The Auth.js Credentials docs say plainly that you "have to provide the necessary logic, eg. to encrypt passwords, add rate-limiting, add password reset functionality." They also recommend OAuth, magic links, or passkeys over passwords if you can. The same is true of any /api/login route, Express handler, or Server Action an AI tool wrote for you: if you did not add a limit, there is none.

You need a store shared by every server instance. An in-memory Map does not work on serverless hosts, as the AI route rate limiting guide explains. This example uses Upstash Ratelimit with its two env vars set as server-only variables. A counter table in your own database works too.

// lib/login-limit.ts
import 'server-only';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const redis = Redis.fromEnv();

// Every login attempt from one IP. Example numbers: tune them for your users.
export const loginIpLimit = new Ratelimit({
  redis,
  prefix: 'rl:login:ip',
  limiter: Ratelimit.slidingWindow(20, '10 m'),
});

// Failed attempts against one email, from any IP.
export const loginFailLimit = new Ratelimit({
  redis,
  prefix: 'rl:login:fail',
  limiter: Ratelimit.fixedWindow(5, '15 m'),
});

Then use both inside authorize(). Auth.js passes the original request as the second argument (Credentials reference), and a subclass of CredentialsSignin sets the error code the login page sees.

// auth.ts
import NextAuth, { CredentialsSignin } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { ipAddress } from '@vercel/functions';
import { z } from 'zod';
import { loginIpLimit, loginFailLimit } from '@/lib/login-limit';
import { findUserByEmail, verifyPassword, DUMMY_HASH } from '@/lib/users'; // your code

// One error for every failure: wrong password, unknown email, or rate limited.
class LoginFailed extends CredentialsSignin {
  code = 'login_failed';
}

const schema = z.object({
  email: z.string().email().max(254),
  password: z.string().min(1).max(256),
});

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Credentials({
      credentials: { email: {}, password: {} },
      async authorize(credentials, request) {
        const parsed = schema.safeParse(credentials);
        if (!parsed.success) throw new LoginFailed();
        const email = parsed.data.email.trim().toLowerCase();

        // 1. Per-IP limit, before any database work
        const ip = ipAddress(request) ?? 'unknown';
        const byIp = await loginIpLimit.limit(ip);
        if (!byIp.success) {
          console.warn('login_limited', { by: 'ip' }); // never log the password
          throw new LoginFailed();
        }

        // 2. Per-account limit on failures, counted for unknown emails too
        const { remaining } = await loginFailLimit.getRemaining(email);
        if (remaining <= 0) {
          console.warn('login_limited', { by: 'account' });
          throw new LoginFailed();
        }

        // 3. Check the password. Hash against a dummy when the user does not exist
        //    so unknown emails take about as long as wrong passwords.
        const user = await findUserByEmail(email);
        const ok = await verifyPassword(user?.passwordHash ?? DUMMY_HASH, parsed.data.password);
        if (!user || !ok) {
          await loginFailLimit.limit(email);
          throw new LoginFailed();
        }

        await loginFailLimit.resetUsedTokens(email);
        return { id: user.id, email: user.email };
      },
    }),
  ],
});

Notes on the code:

  • Both keys, on purpose. The IP limit slows one machine. The email limit slows a botnet aimed at one account. Neither stops a slow, wide spray alone, which is why CAPTCHA, breached password checks, and MFA still matter.
  • Unknown emails count too. Counting failures by the email typed, not by user id, means a real account and a made-up one behave the same way.
  • Only failures spend the account budget. getRemaining checks it, limit spends one, and resetUsedTokens clears it after a good login, which NIST also suggests.
  • Fail closed if you prefer. Upstash allows a request by default if Redis does not answer within its timeout. For a login route, consider a short timeout and treating reason === 'timeout' as a failure.
  • The client IP. ipAddress() from @vercel/functions reads the header Vercel sets. On other hosts, use the header your platform documents as trusted, because a raw x-forwarded-for can contain client-added values (more in the AI route guide).
  • Lockout abuse. An attacker who knows a user's email can burn their 5 attempts. Keep the window short, let password reset still work, and watch the login_limited logs.

For a custom Route Handler instead of Auth.js, run the same three steps at the top of POST, return 429 with a Retry-After header when the IP limit trips, and return one identical 401 body for every other failure. Server Actions are public POST endpoints too, so the same rules apply (Next.js Server Actions security). Keep AUTH_SECRET server-only while you are in this file (Auth.js AUTH_SECRET).

Account enumeration: one message for every failure

Attackers like to know which emails have accounts before they start. OWASP says login, password reset, and recovery "must respond with a generic error message" whether the password was wrong, the account does not exist, or it is locked. It also warns about two quieter leaks: different HTTP status codes for each case, and response timing.

AI tools love helpful errors. Find login code that says "No account found" or "Wrong password" and replace it with one line:

if (error) setMessage('Email or password is incorrect.');

Then check the other forms:

  • Password reset. Always reply "If an account exists for that email, we sent a link," and keep the timing similar. OWASP's Forgot Password Cheat Sheet says to return "a consistent message for both existent and non-existent accounts."
  • Signup. Supabase's signUp reference says that when both Confirm email and Confirm phone are enabled, signing up with an existing confirmed email returns "an obfuscated/fake user object"; when either is off, it returns "User already registered." Keep confirmations on and send the same "check your email" screen either way. Firebase still returns EMAIL_EXISTS on sign-up, so rely on App Check or reCAPTCHA there.
  • Locked accounts. Clerk's components tell locked users how long the lockout lasts, a fair trade for real users. In your own code, never show a lock message only for emails that exist.

These endpoints send email or SMS, and some accept short codes, so they need limits as much as the password form does. OWASP says reset forms need "rate-limiting on a per-account basis, requiring a CAPTCHA, or other controls," otherwise an attacker can flood a user's inbox with reset emails.

  • Supabase applies per-IP limits and a per-user 60 second window on reset and OTP requests by default, plus a per-IP limit on /auth/v1/verify where codes are checked. With the built-in email provider, Supabase sends only 2 emails per hour for the whole project, so a reset form spammed by a bot can block real users' emails. Set up custom SMTP and put CAPTCHA on the reset form.
  • Firebase has daily quotas on password reset and sign-in link emails (limits), so automated reset requests can use up quota your real users need. reCAPTCHA Enterprise covers password reset and email link requests.
  • Clerk counts password resets and email and phone codes toward the account lockout.
  • Your own codes. If you generate reset tokens or OTPs yourself, make them random, single use, and short lived, as the Forgot Password cheat sheet says, and limit guesses per account and per IP. A six-digit code is only safe if nobody gets thousands of guesses at it.

Why a client-side attempt counter does nothing

A common AI-generated "fix" is a failedAttempts counter in React state or localStorage that disables the button after five tries. It stops nobody. The attacker never uses your button. They call the login API directly: your /api/auth/callback/credentials route, your Server Action, or Supabase's /auth/v1/token endpoint with the publishable key that is already in your bundle. A refresh resets the counter anyway. Counts and lockouts have to live on the server, in your auth provider or a shared store. This is the same mistake as hiding an admin page in the browser, covered in client-side auth is not security.

A friendly "too many attempts" message is fine as UX, as long as the server enforces the real limit.

Test your own login safely

Only test an app you own, on staging if you have it, with a test account you created. Do not run this against someone else's site or a real user's account.

  1. Know your limit. Read it from your dashboard (Clerk lockout policy, Supabase Rate Limits) or your code. Supabase's per-IP sign-in limit is high for hand testing, so lower it temporarily in a staging project and set it back after.
  2. Try a few more wrong passwords than the limit, by hand in your own login form. For a custom /api/login route, a short loop is fine:
# Your own staging app and your own test account only
for i in $(seq 1 7); do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST https://staging.yourapp.example/api/login \
    -H 'Content-Type: application/json' \
    -d '{"email":"test-account@yourdomain.example","password":"wrong-password"}'
done
  1. Check the result. You should see a 429, a lockout message, or your generic error, and a login_limited line in your logs.
  2. Try the right password now. During the lockout window it should fail too. That proves the limit is tied to the account, not just the IP.
  3. Compare messages. A wrong password, an unknown email, and a locked account should look the same on login and on password reset.
  4. Clean up. Unlock the test user (Clerk has an Unlock action on the user) or wait out the window, and restore any limits you lowered.

Checklist

  • Login, signup, reset, and OTP endpoints are rate limited on the server, not in the browser
  • Custom logins (Auth.js Credentials, /api/login, Server Actions) limit per IP and per account in a shared store
  • Supabase: Rate Limits reviewed; IP forwarding set up if logins run on your server; CAPTCHA on sign-in, sign-up, and reset
  • Supabase: leaked password protection on if your plan includes it; custom SMTP set
  • Clerk: lockout policy checked (older instances may still allow 100); Device Trust enabled; compromised passwords rejected
  • Clerk custom flows handle needs_client_trust and needs_second_factor
  • Firebase: email enumeration protection on; reCAPTCHA or App Check considered for sign-in and sign-up; SMS defense if you send SMS
  • One generic message for wrong password, unknown email, and locked account; same status code for each
  • Password reset always says "if an account exists" and is rate limited per account
  • MFA available for users and enforced for admins
  • Tested on staging: extra wrong passwords give a 429 or lockout, and the right password fails during the window

Then run the full ship checklist for a vibe-coded app before launch.

Ask your AI tool to audit your login

Paste this into Cursor, Claude Code, Windsurf, Lovable, or Bolt inside your project. It reads code only and asks the model not to print secret values.

Audit this project's login, signup, password reset, OTP, and magic link flows for
brute force, password spraying, and credential stuffing protection.
1. Identify the auth provider (Supabase, Clerk, Firebase, Auth.js/NextAuth, or custom).
   List every place a password, OTP, reset token, or magic link is checked or sent,
   with file and line, and say whether it runs in the browser or on the server.
2. For each server-side entry point (Route Handlers, Server Actions, Auth.js
   authorize(), Edge Functions, Express routes), show the rate limit that applies.
   Flag any with no limit, limits kept in memory or in the browser (useState,
   localStorage), and limits keyed only by IP or only by account.
3. Flag error messages or status codes that differ between "wrong password",
   "no such user", and "account locked", and password reset responses that reveal
   whether an email exists.
4. Supabase: check whether signInWithPassword, signUp, and resetPasswordForEmail pass
   a captchaToken, and whether sign-in runs on the server without forwarding the
   user's IP. Clerk: check custom sign-in flows handle needs_client_trust and
   needs_second_factor. Firebase: note if the code depends on fetchSignInMethodsForEmail
   or EMAIL_NOT_FOUND, which break with email enumeration protection.
5. List which settings I need to check by hand in my auth provider's dashboard
   (rate limits, CAPTCHA, lockout, leaked password protection, MFA).
6. Never print the value of any secret, key, or token. Report only file, line, and
   variable name.
Do not change any files yet. Give me a table of findings ordered by severity.

Read the list yourself afterward. Models often call a client-side counter "rate limiting" and miss dashboard settings they cannot see. More prompts are in the security review prompt guide.

Sources

Questions

Does Supabase rate limit login attempts?

Yes, per IP address. Supabase's rate limits page lists the token endpoint, which covers password sign-in, as limited by IP, with a default of 150 requests per 5 minutes and bursts up to 30, returning 429 when exceeded. The docs we read do not describe a per-account lockout for password sign-in, so add CAPTCHA, leaked password protection, and MFA, or your own per-account limiter if logins go through your server.

Does the Auth.js (NextAuth) Credentials provider have rate limiting?

No. The Auth.js Credentials docs say you have to provide the logic yourself, including rate limiting and password reset. Add a per-IP limit and a per-account failure limit inside authorize(), backed by a shared store such as Redis or your database, and return the same error for every failure.

Is a CAPTCHA enough to stop credential stuffing?

No. OWASP says CAPTCHAs can often be solved by tools or outsourced, so treat them as a defense-in-depth layer that makes attacks slower and more expensive. Combine CAPTCHA with server-side rate limits, leaked password checks, generic error messages, and MFA or new-device verification.

Should my login say 'no account with that email'?

No. OWASP recommends one generic message for login, password reset, and recovery whether the account exists, the password is wrong, or the account is locked. A different message lets attackers build a list of real accounts to target. Say 'Email or password is incorrect' and, for resets, 'If an account exists, we sent a link.'

How do I test my login rate limit without attacking anyone?

Only test an app you own, on staging, with a test account you created. Enter a wrong password a few more times than your limit allows, then confirm you get a 429, a lockout message, or the generic error even with the right password. Never point tests at someone else's site or real user accounts.