← All posts
·7 min read·GuardLayer

"You can only request this after 56 seconds"

SupabaseAuthRate LimitsEnumerationSecurity

For security purposes, you can only request this after 56 seconds. is GoTrue's per-address email cooldown, and the timestamp it checks lives on the user's row in auth.users. No row means no timestamp means no cooldown — so the error can only ever fire for an address that has an account. Never let that response reach the browser unchanged.

The number moves — 56, 47, 23 — and the error fires for some addresses and not others. That inconsistency is the interesting part, and it is not random.

If you just want the numbers for every Supabase auth limit, they are in the full rate-limit table. This post answers a narrower question: why the limit applies selectively, and what that selectivity gives away.

Why do I get "you can only request this after 56 seconds" for some emails but not others?

Because the cooldown is per-identity, and an identity that does not exist has nothing to rate limit.

Every email GoTrue sends is gated by the same helper (internal/api/mail.go):

func validateSentWithinFrequencyLimit(sentAt *time.Time, frequency time.Duration) error {
	if sentAt != nil && sentAt.Add(frequency).After(time.Now()) {
		return apierrors.NewTooManyRequestsError(
			apierrors.ErrorCodeOverEmailSendRateLimit, "%s",
			generateFrequencyLimitErrorMessage(sentAt, frequency))
	}
	return nil
}

Look at what gets passed in. For a password reset it is called as validateSentWithinFrequencyLimit(u.RecoverySentAt, config.SMTP.MaxFrequency) — and u.RecoverySentAt is a column on the user row. The check starts with sentAt != nil. For an address with no account there is no u, the /recover handler returns its uniform success long before this code is reached, and the cooldown never applies.

The number in the message is just the remaining time, computed on the spot:

left := timeStamp.Add(maxFrequency).Sub(now) / time.Second
return fmt.Sprintf("For security purposes, you can only request this after %d seconds.", left)

MaxFrequency defaults to one minute when it is not configured. So "56 seconds" is a 60-second window with four seconds already elapsed — which is why the number is different every time and why nobody finds "56" in any documentation. Supabase's error-code reference describes the underlying code, over_email_send_rate_limit, as "Too many emails have been sent to this email address" — per address, stated plainly.

That gives you a clean decision table:

RequestAccount existsSecond call, immediately
resetPasswordForEmail("real@x.com")yes429 over_email_send_rate_limit
resetPasswordForEmail("nobody@x.com")no200, no error, instantly

How do I fix it?

If you are hitting it while testing, you are not doing anything wrong — wait out the window, or raise it. The frequency is configurable under Authentication → Emails → SMTP Settings as the minimum interval between emails, and it is a genuinely useful control once you are on custom SMTP. On the built-in provider, leave it alone; it is protecting a shared sending reputation that is not yours.

In your UI, handle it as a state rather than an error. Start a client-side countdown the moment the user submits, and disable the button for the full window:

const { error } = await supabase.auth.resetPasswordForEmail(email, {
  redirectTo: "https://app.example.com/auth/callback",
});

// Do not branch the message on the error. Same copy either way.
setSubmitted(true);
setCooldownUntil(Date.now() + 60_000);

That removes the error for real users entirely, because they cannot fire the second request.

What you must not do is what feels most helpful: render error.message so the user can see the countdown. Read the table above again and it is obvious why.

The part that matters for security

Two unauthenticated requests, one address, no credentials beyond the publishable key that is already in your JavaScript bundle, and you know whether that person has an account.

This is filed as supabase/auth#2398, open and effectively unanswered since December 2024 under the title "Auth. Security vulnerability: find out if an email address is registered in the DB." The reproduction in the issue is four steps, and the reporter also noticed the second channel:

"I used the method supabase.auth.resetPasswordForEmail and I have noticed that if I enter an email address that is not present in the DB, the method succeeds as it's supposed to although the email is not sent. […] It succeeds fast (because an email is not really sent)."

So even without the second request, the duration of the first one separates the two cases: a real account does SMTP work, a nonexistent one returns immediately. Supabase Auth has a measured timing gap on the sign-in path too, which is worth reading alongside this one — the pattern is not isolated to password reset.

The honest summary: /auth/v1/recover is designed to be indistinguishable, and the rate limiter layered on top of it is what makes it distinguishable. A security control defeating another security control.

What you can actually do about it

Nothing in your repository closes this. /auth/v1/recover is a public endpoint on your project's hosted Auth server, reachable with the key in your page source, whatever you build in front of it. Anyone telling you a proxy route fixes it is wrong.

What is left is real but narrower:

  1. Never return the raw AuthApiError to the client. One uniform response, one uniform message, regardless of outcome.
  2. Put your own limiter in front of your own auth routes, keyed on IP, so at least the amplified path through your app is not free.
  3. Do not add a pre-flight existence check. It converts a two-request timing-dependent tell into a one-request API, and that one is yours to keep forever.
  4. Add a captcha to the reset form if enumeration is genuinely part of your threat model. Supabase supports hCaptcha and Turnstile at the Auth layer, which is the only place a control can sit in front of the real endpoint rather than beside it.

Here is the version of point 1 that quietly gets it wrong:

import { createClient } from "@supabase/supabase-js";

// Proxy the reset call so we can show the user exactly how long to wait.
export async function POST(request: Request) {
  const { email } = await request.json();

  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );

  const { error } = await supabase.auth.resetPasswordForEmail(email);

  return Response.json({
    ok: !error,
    message: error?.message ?? "Reset email sent",
  });
}
guardlayer scan · app/api/forgot-password/route.tsLive engine output
Passed with warnings
92/100 · A
  • Warningapp/api/forgot-password/route.ts:5

    API route without input validation

    Validate the parsed body against a schema (e.g. zod safeParse) before use, and return 400 on failure. Consider rate limiting for unauthenticated routes.

That route takes an arbitrary body from anyone on the internet, does no validation, has no limiter of its own, and forwards Supabase's answer verbatim — so it does not merely fail to fix the oracle, it hosts a copy of it on your domain. Be clear about what a scanner sees here, though: GuardLayer flags the unvalidated public route, which is a real finding, and it is the closest static analysis gets. It cannot see the 429, the timing, or the oracle. Those are runtime properties of a service that is not in your repository.

A 60-second self-check

# 1. Public routes that read a body with no schema validation
grep -rln "request.json()" app/api/ --include=route.ts

# 2. Anywhere an auth error object is forwarded to the client
grep -rnE "error\?\.message|error\.message" app/ --include=route.ts

# 3. Any pre-flight existence check that shouldn't exist
grep -rln "listUsers\|auth\.users" app/ --include=route.ts

FAQ

What does "For security purposes, you can only request this after 56 seconds" mean? You requested a second auth email for the same address inside the minimum interval. GoTrue returns HTTP 429 with the code over_email_send_rate_limit. The number is the time remaining in the window, so it changes every time.

Why 56 and not 60? Because the message reports the remainder. The default interval is one minute, and roughly four seconds had already passed when the second request arrived.

Why does it only happen for some email addresses? The cooldown reads a timestamp stored on the user's row in auth.users. An address with no account has no row and no timestamp, so the check is never reached and the request always succeeds instantly.

Can I turn the cooldown off? You can raise or lower the minimum interval between emails in the SMTP settings once you are on custom SMTP. Turning it off entirely is a bad trade — it is what stops one person's retry loop from burning your sending reputation.

Is this a vulnerability in my app? It is an account-existence oracle in hosted Supabase Auth, tracked upstream and unresolved. It becomes your vulnerability the moment you forward the raw error to the browser or build an email-lookup endpoint to work around it.

Can GuardLayer detect it? No. The behaviour is runtime and hosted; no static scan of your repository can observe a 429 or a response time. GuardLayer does flag the adjacent code that makes it worse — unvalidated public routes, unguarded admin lookups, tables without RLS, and the service role key reaching the client.

Catch this before it ships — free

GuardLayer scans every push for this and 33 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading

A Solvion project — see also Reglog — EU AI Act changelog, Proceedly, Solenna and Solvion Solutions.