Dev.to Security 🔐 Cybersecurity 👁 0 📖 7 min read

Send the link again has five limits on it, and only two of them are in the browser

Every app with email confirmation ends up with this screen: Check your inbox We sent a confirmation link to [email protected]. Open it to continue. And then someone notices the mail has not arrived, and you need a way

Every app with email confirmation ends up with this screen:

Check your inbox

We sent a confirmation link to [email protected]. Open it to continue.

And then someone notices the mail has not arrived, and you need a way to send it again. Nakodo's version of that is at the bottom of nakodo.app/login after you sign up, and it is one of the smallest components in the app with the most limits attached to it.

The reason is worth saying out loud before the code. A resend button is not a button that mails you. It is a button that mails whatever address is in the form. The form is on a public page, there is no session yet, and nothing stops a person typing somebody else's address into it. So the thing being protected is not our sending reputation. It is a stranger's inbox.

The two limits in the browser

const RESEND_AFTER_SECONDS = 10;
const MAX_RESENDS = 3;

// Sends the confirmation link again, at least 10 seconds after the last one
// and at most 3 times. The server limits confirmation emails per address too.
function ResendLink({ email, plan, justSent = false }: { email: string; plan: string; justSent?: boolean }) {

The countdown is a setTimeout that reschedules itself one second at a time, which is less code than an interval and cannot drift into a double tick:

useEffect(() => {
  if (wait <= 0) return;
  const timer = setTimeout(() => setWait((s) => s - 1), 1000);
  return () => clearTimeout(timer);
}, [wait]);

justSent is the interesting prop. The component is rendered in two places: under the sign-in form, where nothing has just been sent, and on the "check your inbox" screen, where an email went out a second ago. Same component, different starting state:

const [wait, setWait] = useState(justSent ? RESEND_AFTER_SECONDS : 0);

So the fresh sign-up screen opens with the link already counting down, because the first send is the one you are waiting on. The sign-in version is clickable immediately, because by then your last link may be an hour old.

The send counter is kept by wrapping the Server Action inside the useActionState reducer, rather than in an effect watching the result:

const [state, action, pending] = useActionState<AuthState, FormData>(async (prev, form) => {
  const result = await resendConfirmation(prev, form);
  if (result.message) setSends((n) => n + 1);
  setWait(RESEND_AFTER_SECONDS);
  return result;
}, {});

Two details in four lines. The counter only moves on result.message, which is the success field, so a rejected attempt does not spend one of your three. The countdown restarts on every attempt including the rejected ones, because a rejection means the limiter is already unhappy and tapping again faster is not going to help.

The button itself says which of the two limits is currently in force, and the exhausted state does not scold anybody:

{sends >= MAX_RESENDS ? (
  "Still nothing? Check your spam folder, or try again later."
) : (
  <>
    No email?{" "}
    <Button type="submit" variant="link" disabled={pending || wait > 0}>
      {pending ? "Sending..." : wait > 0 ? `Send the link again in ${wait}s` : "Send the link again"}
    </Button>
  </>
)}

"Send the link again in 7s" is a label that explains a disabled control, which is the only honest way to disable something.

The two limits in our rate limiter

The action behind the button is seven lines long and the first thing it does after parsing is ask two questions:

// Confirmation and reset emails: per caller, and per address so nobody can
// flood someone else's inbox.
async function authEmailLimit(email: string): Promise<string | null> {
  return (await rateLimitMessage("authEmailIp", await ipKey())) ?? (await rateLimitMessage("authEmail", emailKey(email)));
}
authEmail: { tokens: 5, window: "1 h" },   // confirmation and reset emails, per address
authEmailIp: { tokens: 20, window: "1 h" },

Twenty an hour from one caller, five an hour to one address, and the ?? means the caller's own limit is reported first when both are over. Per-IP alone would be useless here: the attack is not volume, it is one request every ten minutes aimed at one person for a day. Per-address alone would let a script walk a list of addresses and send each of them five. You need both, and the address rule is the one that exists for people who are not our users.

The address is lowercased and hashed before it becomes a Redis key, so the limiter's key space is not a list of email addresses:

const hash = (key: string) => createHash("sha256").update(key).digest("base64url").slice(0, 32);

The fifth limit is not ours

Supabase sends these mails through our own SMTP credentials, and the provider applies its own hourly cap across every user of the project. That one cannot be argued with from application code, which is exactly why the four above exist: by the time the provider's limit is the thing stopping an email, someone else's confirmation link is also not arriving.

When the provider does say no, the message is translated, because its wording is addressed to developers:

function authError(error: { code?: string; status?: number; message: string }): string {
  if (error.status === 429 || error.code === "over_email_send_rate_limit" || error.code === "over_request_rate_limit") {
    return "Too many attempts. Wait a minute and try again.";
  }
  if (error.code === "weak_password") return "Choose a stronger password: longer, and not a common one.";
  if (error.code === "email_address_invalid") return "That email address can't receive mail. Check it and try again.";
  if (error.code === "signup_disabled") return "New sign-ups are paused for now. Try again later.";
  console.error("Supabase auth error", error.code, error.message);
  return "Something went wrong. Try again in a moment.";
}

The default case logs the real code and shows a sentence with no code in it. Anything specific enough to be actionable gets its own line, and weak_password gets advice rather than the rule.

The flag that keeps the screen where it is

The state these actions return has a field that looks redundant until you try to remove it:

export type AuthState = { error?: string; message?: string; email?: string; sent?: boolean; terms?: boolean; marketing?: boolean };

sent means "we are on the check-your-inbox side of this flow", and it is set even when the attempt failed:

const limited = await authEmailLimit(parsed.data.email);
if (limited) return { error: limited, email: parsed.data.email, sent: true };

Without that, hitting the limit would drop the person back to an empty form, which reads as "the sign-up did not work" and gets them to sign up again. With it, they stay on the screen that says a link was sent, with a sentence explaining the wait.

The same field does a second job in sign-in:

if (error.code === "email_not_confirmed") {
  return { error: "Confirm your email first. Check your inbox for the link.", email, sent: true };
}
{mode === "signin" && state.sent && state.email && <ResendLink email={state.email} plan={plan} />}

Someone who signed up a week ago, never confirmed, and is now trying to sign in gets the resend control attached to the sign-in form, with no extra component and no new route.

The one limit that is not a count

Sign-up with an address that already has an account must not say so, or the form becomes an account checker. Supabase returns a user object either way, and the difference is one array:

// An already registered email gets a placeholder user with no identities.
if (data.user?.identities?.length) {
  await db.insert(profiles).values({ id: data.user.id, termsVersion: TERMS_VERSION, termsAcceptedAt: acceptedAt }).onConflictDoNothing();
  await audit({ action: "user.signed_up", userId: data.user.id, entityType: "user", entityId: data.user.id, metadata: accepted });

No identities means no profile row, no audit entry, and no analytics identify call. What the browser gets back is the message every sign-up gets:

return { message: `We sent a confirmation link to ${parsed.data.email}. Open it to continue.`, email: parsed.data.email, sent: true };

Password reset takes the same line, in a comment that is the whole specification of the function:

// Always reports success, so the form can't be used to find out who has an account.

What a reader can check from outside

The client-side half of this is shipped to the browser and the server-side half is not, which is observable. Fetch the sign-in page and every script it loads:

14 scripts, 1,052,108 bytes
"Send the link again" appears in exactly 1 chunk

The countdown, the three-try cap and the spam-folder sentence are in that one chunk. The two rate limiter rules, the hashing, the identities check and the translated provider errors are in none of them.

The honest part

The two browser limits are courtesy, not enforcement. sends is component state, so a reload sets it back to zero, and wait goes with it. Anyone who wants to send themselves six links can press F5 between them.

That is fine, and it is the right split. The browser's job is to stop the ordinary double-click and to explain the wait in words. The limit that has to hold against someone deliberately pointing the form at a stranger is the five-an-hour rule on the address, which lives on the server, is keyed on a hash of the address and knows nothing about which tab asked.

If you only have budget for one of the five, build that one.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.