<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: SignupBouncer</title>
    <description>The latest articles on DEV Community by SignupBouncer (signupbouncer).</description>
    <link>https://dev.to/signupbouncer</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F15489%2F9b7a992b-b2ff-432c-a659-7418b9fa36a0.png</url>
      <title>DEV Community: SignupBouncer</title>
      <link>https://dev.to/signupbouncer</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/signupbouncer"/>
    <language>en</language>
    <item>
      <title>Block burner emails in the Next.js SaaS starter</title>
      <dc:creator>Josh</dc:creator>
      <pubDate>Thu, 22 Oct 2026 00:06:21 +0000</pubDate>
      <link>https://dev.to/signupbouncer/block-burner-emails-in-the-nextjs-saas-starter-2h5d</link>
      <guid>https://dev.to/signupbouncer/block-burner-emails-in-the-nextjs-saas-starter-2h5d</guid>
      <description>&lt;p&gt;&lt;em&gt;I build &lt;a href="https://signupbouncer.com" rel="noopener noreferrer"&gt;SignupBouncer&lt;/a&gt;, the signup check in this guide. The original is on &lt;a href="https://signupbouncer.com/guides/nextjs-saas-starter" rel="noopener noreferrer"&gt;signupbouncer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Vercel's &lt;a href="https://github.com/nextjs/saas-starter" rel="noopener noreferrer"&gt;Next.js SaaS starter&lt;/a&gt; lets anyone sign up with any email address, including burner addresses from disposable email services. The same person can also sign up again and again by moving a dot or adding a &lt;code&gt;+tag&lt;/code&gt; to a Gmail address. This guide adds a SignupBouncer check to &lt;code&gt;signUp&lt;/code&gt;, the server action that creates the user, so your form refuses burner addresses and repeat signups.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Get the starter running on your machine. You'll run Postgres in Docker and write &lt;code&gt;.env&lt;/code&gt; by hand.&lt;/li&gt;
&lt;li&gt;Create a SignupBouncer API key and copy our helper file into &lt;code&gt;lib/signupbouncer.ts&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;checkSignup&lt;/code&gt; in &lt;code&gt;signUp&lt;/code&gt;, before it hashes the password.&lt;/li&gt;
&lt;li&gt;In production, set &lt;code&gt;SIGNUPBOUNCER_IP_HEADER&lt;/code&gt; to the header that holds the client's IP.&lt;/li&gt;
&lt;li&gt;Try a few signups in shadow mode and see how we marked them on your dashboard. Then switch your app to Enforce.&lt;/li&gt;
&lt;li&gt;Lowercase the email in the starter's schemas, so an address typed with capitals matches the same account.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The starter sends no confirmation email, so someone can even sign up with an address at a domain that can't receive mail. Every new account comes with a team, and every team can start the 14-day trial the starter sets up at Stripe Checkout.&lt;/p&gt;

&lt;p&gt;A refused person sees an error in the signup form you already have. Someone who keeps signing up with the same Gmail inbox gets through the first few times. After that, your form refuses them.&lt;/p&gt;

&lt;p&gt;The finished code is in &lt;a href="https://github.com/signupbouncer/nextjs-saas-starter-signup-protection" rel="noopener noreferrer"&gt;signupbouncer/nextjs-saas-starter-signup-protection&lt;/a&gt;. We built it from the starter at commit &lt;code&gt;6e33e58&lt;/code&gt; (11 Dec 2025) and moved it to Next.js 16.4.2. The finished code keeps the starter's MIT license.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the starter running
&lt;/h2&gt;

&lt;p&gt;If the starter already runs on your machine, do the Next.js upgrade below and then skip ahead to getting a SignupBouncer key. If it doesn't, clone the starter and install its packages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/nextjs/saas-starter
&lt;span class="nb"&gt;cd &lt;/span&gt;saas-starter
pnpm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The starter pins a Next.js 15.6 canary, which you'll see under &lt;code&gt;next&lt;/code&gt; in your &lt;code&gt;package.json&lt;/code&gt;. That canary came out before the fix for &lt;a href="https://github.com/vercel/next.js/security/advisories/GHSA-2464-fhr4-w9xw" rel="noopener noreferrer"&gt;CVE-2026-96889&lt;/a&gt;, a critical remote code execution bug in Next.js. Move the starter to Next.js 16.4.2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add next@16.4.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next.js 16 doesn't accept the starter's experimental &lt;code&gt;ppr&lt;/code&gt; flag. In &lt;code&gt;next.config.ts&lt;/code&gt;, delete the whole &lt;code&gt;experimental&lt;/code&gt; block, which holds &lt;code&gt;ppr&lt;/code&gt; and &lt;code&gt;clientSegmentCache&lt;/code&gt;. That leaves &lt;code&gt;nextConfig&lt;/code&gt; as an empty object.&lt;/p&gt;

&lt;p&gt;The starter's &lt;code&gt;pnpm db:setup&lt;/code&gt; script writes &lt;code&gt;.env&lt;/code&gt; for you, but it needs Docker and the Stripe CLI. The SignupBouncer check doesn't need Stripe, so you can write &lt;code&gt;.env&lt;/code&gt; by hand. Start Postgres in Docker on port 54322, the port &lt;code&gt;pnpm db:setup&lt;/code&gt; uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; saas-starter-db &lt;span class="nt"&gt;-p&lt;/span&gt; 127.0.0.1:54322:5432 &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres postgres:16.4-alpine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;127.0.0.1:&lt;/code&gt; in the &lt;code&gt;docker run&lt;/code&gt; command makes the database reachable from your own machine only. Without &lt;code&gt;127.0.0.1:&lt;/code&gt;, Docker opens port 54322 on every network interface. Anyone on your network could then log in with the password &lt;code&gt;postgres&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then create &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;POSTGRES_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres://postgres:postgres@127.0.0.1:54322/postgres
&lt;span class="nv"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk_test_placeholder
&lt;span class="nv"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;whsec_placeholder
&lt;span class="nv"&gt;BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:3000
&lt;span class="nv"&gt;AUTH_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;replace-with-a-long-random-string
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;code&gt;AUTH_SECRET&lt;/code&gt;, any long random string works, and &lt;code&gt;openssl rand -base64 32&lt;/code&gt; makes one. Signup works with the placeholder Stripe values.&lt;/p&gt;

&lt;p&gt;You'll need your Stripe test keys for the pricing page, Checkout and &lt;code&gt;pnpm build&lt;/code&gt;, because they call Stripe. The starter's README covers the Stripe setup.&lt;/p&gt;

&lt;p&gt;Create the tables, then start the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm db:migrate
pnpm dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't run &lt;code&gt;pnpm db:seed&lt;/code&gt;. It creates products in your Stripe account.&lt;/p&gt;

&lt;p&gt;The first time you run &lt;code&gt;pnpm dev&lt;/code&gt;, Next.js rewrites a few lines of &lt;code&gt;tsconfig.json&lt;/code&gt;, so &lt;code&gt;git status&lt;/code&gt; shows that file as changed. &lt;code&gt;pnpm dev&lt;/code&gt; also warns that &lt;code&gt;middleware&lt;/code&gt; is deprecated. The warning comes from the upstream starter, and signup works with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get a SignupBouncer key
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://signupbouncer.com/signup" rel="noopener noreferrer"&gt;Sign up for SignupBouncer&lt;/a&gt;. On the Free plan, you get 1,000 checks a month and you don't need a card.&lt;/p&gt;

&lt;p&gt;On your app's Integration tab, choose "Create API key". We show the key only once, so copy it right away. Keys start with &lt;code&gt;sb_live_&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add the helper file
&lt;/h2&gt;

&lt;p&gt;The helper is the file &lt;code&gt;lib/signupbouncer.ts&lt;/code&gt;, and it has no dependencies. Download it to the same path in your app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-o&lt;/span&gt; lib/signupbouncer.ts https://raw.githubusercontent.com/signupbouncer/nextjs-saas-starter-signup-protection/master/lib/signupbouncer.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add these lines to &lt;code&gt;.env.example&lt;/code&gt; and to &lt;code&gt;.env&lt;/code&gt;. In &lt;code&gt;.env&lt;/code&gt;, replace &lt;code&gt;sb_live_***&lt;/code&gt; with the key you copied:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# signupbouncer: your sb_live_... key. Server only, never NEXT_PUBLIC_.&lt;/span&gt;
&lt;span class="nv"&gt;SIGNUPBOUNCER_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sb_live_&lt;span class="k"&gt;***&lt;/span&gt;
&lt;span class="c"&gt;# The header your host overwrites with the client IP. On Vercel: x-real-ip.&lt;/span&gt;
&lt;span class="c"&gt;# Leave it unset and the email is checked alone.&lt;/span&gt;
&lt;span class="c"&gt;# SIGNUPBOUNCER_IP_HEADER=x-real-ip&lt;/span&gt;
&lt;span class="c"&gt;# Only for a local signupbouncer API. Leave unset in production.&lt;/span&gt;
&lt;span class="c"&gt;# SIGNUPBOUNCER_BASE_URL=http://127.0.0.1:8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SIGNUPBOUNCER_API_KEY&lt;/code&gt; has no &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix, so the key stays on the server. Leave &lt;code&gt;SIGNUPBOUNCER_BASE_URL&lt;/code&gt; unset, because it's only for our own tests against a local copy of SignupBouncer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Call the check in signUp
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;app/(login)/actions.ts&lt;/code&gt;. Replace the line that imports &lt;code&gt;cookies&lt;/code&gt; from &lt;code&gt;next/headers&lt;/code&gt; with these lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/headers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;blockMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;checkSignup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;clientIp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;shouldBlock&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/signupbouncer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;signUp&lt;/code&gt;, find the &lt;code&gt;existingUser&lt;/code&gt; check and the &lt;code&gt;hashPassword&lt;/code&gt; line after it. Add the SignupBouncer check between them. In the code below, the &lt;code&gt;existingUser&lt;/code&gt; check at the top and the &lt;code&gt;hashPassword&lt;/code&gt; line at the bottom are already in your file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existingUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Failed to create user. Please try again.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;password&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Refuse burner, unreachable and repeat sign-ups before the user exists.&lt;/span&gt;
  &lt;span class="c1"&gt;// If signupbouncer is slow or down, the sign-up goes through.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;check&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkSignup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SIGNUPBOUNCER_API_KEY&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SIGNUPBOUNCER_BASE_URL&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signupbouncer:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;clientIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SIGNUPBOUNCER_IP_HEADER&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;shouldBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;blockMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;passwordHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hashPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;checkSignup&lt;/code&gt; sends us the email and the user agent. It sends the client IP too once you set &lt;code&gt;SIGNUPBOUNCER_IP_HEADER&lt;/code&gt;. &lt;code&gt;shouldBlock(check)&lt;/code&gt; returns true when our answer's &lt;code&gt;action&lt;/code&gt; is &lt;code&gt;"block"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;shouldBlock&lt;/code&gt; returns true, &lt;code&gt;signUp&lt;/code&gt; returns the block message in the starter's own &lt;code&gt;{ error }&lt;/code&gt; state. The signup form already displays &lt;code&gt;error&lt;/code&gt;, so you don't need to edit the signup page.&lt;/p&gt;

&lt;p&gt;The check runs before &lt;code&gt;hashPassword&lt;/code&gt;. So for a refused signup, the starter skips the password hashing and creates no user and no team.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9habzcatw4na0dc6w34u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9habzcatw4na0dc6w34u.png" alt="The /sign-up page refusing a mailinator.com address with " width="528" height="573"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;blockMessage(check)&lt;/code&gt; returns the block message you set on your app's Rules tab in the dashboard. If you keep the default message, the wording depends on the reason for the refusal. Someone signing up with a burner domain sees "Please sign up with a permanent email address." Someone who mistypes a big provider's domain sees "Did you mean &lt;a href="mailto:ann@gmail.com"&gt;ann@gmail.com&lt;/a&gt;?"&lt;/p&gt;

&lt;p&gt;If our answer has no message, &lt;code&gt;blockMessage&lt;/code&gt; falls back to &lt;code&gt;DEFAULT_BLOCK_MESSAGE&lt;/code&gt;, which is the burner-domain wording. To use your own words, change the message on the Rules tab, or return your own string instead of &lt;code&gt;blockMessage(check)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;checkSignup&lt;/code&gt; never throws. It returns &lt;code&gt;action: "allow"&lt;/code&gt; on a timeout after 1.5 seconds, a network error, an error status or an incomplete answer. It also calls &lt;code&gt;onError&lt;/code&gt;, which logs a fixed reason such as &lt;code&gt;signupbouncer: timeout&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Your app refuses a signup only when it gets a complete answer from us. If SignupBouncer is slow or down, the signup goes through. An outage on our side delays a signup by 1.5 seconds at most.&lt;/p&gt;

&lt;p&gt;In our test run with the SignupBouncer API stopped, a new user still reached the starter's dashboard in about a tenth of a second.&lt;/p&gt;

&lt;h2&gt;
  
  
  The client IP
&lt;/h2&gt;

&lt;p&gt;On your machine, the check runs without the client's IP. In production, set &lt;code&gt;SIGNUPBOUNCER_IP_HEADER&lt;/code&gt; to the header your host overwrites with the client's IP.&lt;/p&gt;

&lt;p&gt;On Vercel the header is &lt;code&gt;x-real-ip&lt;/code&gt;. Add &lt;code&gt;SIGNUPBOUNCER_IP_HEADER=x-real-ip&lt;/code&gt; to your Vercel project's environment variables, next to &lt;code&gt;SIGNUPBOUNCER_API_KEY&lt;/code&gt;. For Cloudflare and Fly.io, the comment above &lt;code&gt;clientIp()&lt;/code&gt; in &lt;code&gt;lib/signupbouncer.ts&lt;/code&gt; names the header to use.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;clientIp()&lt;/code&gt; reads only the header you name. Clients can send &lt;code&gt;X-Forwarded-For&lt;/code&gt; or &lt;code&gt;X-Real-IP&lt;/code&gt; themselves. If your host doesn't replace the header you name, the header holds whatever the client sent.&lt;/p&gt;

&lt;p&gt;On the Starter plan and above, we add points for datacenter, VPN and Tor addresses. A VPN or Tor address on its own never scores enough for a block.&lt;/p&gt;

&lt;p&gt;On the Free plan, we still run the IP checks, but their points don't change the score. The answer's &lt;code&gt;shadow&lt;/code&gt; field lists the points a paid plan would add.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shadow mode first, then enforce
&lt;/h2&gt;

&lt;p&gt;Every SignupBouncer app starts in shadow mode. We check and log each signup, and &lt;code&gt;action&lt;/code&gt; is always &lt;code&gt;"allow"&lt;/code&gt;. So &lt;code&gt;shouldBlock&lt;/code&gt; returns false and every signup gets an account.&lt;/p&gt;

&lt;p&gt;Sign up a few times through your form, once with an address at mailinator.com. Test signups count toward your monthly checks.&lt;/p&gt;

&lt;p&gt;Open the Checks tab on your dashboard. The mailinator.com signup shows as block, and its account exists all the same.&lt;/p&gt;

&lt;p&gt;When the results look right, open your app's Rules tab. Choose Enforce next to Mode, then Turn on blocking in the window that opens. From then on, your form refuses any signup marked block.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lowercase the email
&lt;/h2&gt;

&lt;p&gt;The upstream starter stores an email exactly as it was typed, so &lt;code&gt;Test@test.com&lt;/code&gt; and &lt;code&gt;test@test.com&lt;/code&gt; become two accounts (&lt;a href="https://github.com/nextjs/saas-starter/issues/184" rel="noopener noreferrer"&gt;nextjs/saas-starter#184&lt;/a&gt;). To fix it, add &lt;code&gt;.trim().toLowerCase()&lt;/code&gt; after &lt;code&gt;z.string()&lt;/code&gt; in every schema in &lt;code&gt;app/(login)/actions.ts&lt;/code&gt; that reads an email.&lt;/p&gt;

&lt;p&gt;Start with &lt;code&gt;signInSchema&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signInSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;.trim().toLowerCase()&lt;/code&gt; to &lt;code&gt;signUpSchema&lt;/code&gt; too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signUpSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="c1"&gt;// One account per address, whatever its case (nextjs/saas-starter#184).&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;inviteId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change &lt;code&gt;updateAccountSchema&lt;/code&gt;, which the account settings form uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;updateAccountSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Name is required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid email address&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change &lt;code&gt;inviteTeamMemberSchema&lt;/code&gt;, which the team invite form uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inviteTeamMemberSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid email address&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;member&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;owner&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Invitations sent before this change can still have capitals in their stored address. In &lt;code&gt;signUp&lt;/code&gt;, find the query that matches an invite link to its invitation, and change it to compare the stored address in lower case.&lt;/p&gt;

&lt;p&gt;The changed query uses &lt;code&gt;sql&lt;/code&gt;, which the file already imports from &lt;code&gt;drizzle-orm&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;invitation&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invitations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invitations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inviteId&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
          &lt;span class="c1"&gt;// Invitations sent before the lowercase change can hold capitals.&lt;/span&gt;
          &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`lower(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;invitations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invitations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the change, an invitation sent to &lt;code&gt;Ann@gmail.com&lt;/code&gt; matches a signup as &lt;code&gt;ann@gmail.com&lt;/code&gt;. Upstream's &lt;code&gt;signUp&lt;/code&gt; creates the user before it looks up the invitation. So a signup through an expired invite link still leaves an account with no team.&lt;/p&gt;

&lt;p&gt;The schema change only lowercases new input. If your database already has emails with capitals, lowercase them once.&lt;/p&gt;

&lt;p&gt;The email column of &lt;code&gt;users&lt;/code&gt; is unique, so first find accounts that differ only by case, and merge or rename them. Then lowercase the emails in &lt;code&gt;users&lt;/code&gt; and &lt;code&gt;invitations&lt;/code&gt; together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;having&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;invitations&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Repeat signups from the same inbox
&lt;/h2&gt;

&lt;p&gt;Gmail ignores dots in an address and anything after a &lt;code&gt;+&lt;/code&gt;. So &lt;code&gt;a.b@gmail.com&lt;/code&gt;, &lt;code&gt;ab+1@gmail.com&lt;/code&gt; and &lt;code&gt;a.b+2@gmail.com&lt;/code&gt; all reach the same inbox.&lt;/p&gt;

&lt;p&gt;On every plan, we treat those Gmail spellings as the same inbox. We count how many times the inbox signed up over the last 7 days, across every app on your SignupBouncer account.&lt;/p&gt;

&lt;p&gt;Each repeat adds points to the signup: 25 for the second, 40 for the third, 55 for the fourth, 70 for the fifth and 75 from the sixth on. The second signup stays below the challenge cutoff, because people do sign up again after they lose a password.&lt;/p&gt;

&lt;p&gt;With the default cutoffs, a score of 40 marks a signup challenge and a score of 70 marks it block. So we mark the third and fourth signups challenge. They still get accounts, because &lt;code&gt;shouldBlock(check)&lt;/code&gt; returns false for a challenge.&lt;/p&gt;

&lt;p&gt;Your form refuses the fifth signup. In our test run, the same inbox got four accounts, and the form refused its fifth spelling.&lt;/p&gt;

&lt;p&gt;To refuse from the third signup on, change the condition to &lt;code&gt;shouldBlock(check, 'block')&lt;/code&gt;. A challenge is any score between the two cutoffs. So &lt;code&gt;shouldBlock(check, 'block')&lt;/code&gt; also refuses other signups marked challenge, such as an address on a free subdomain like &lt;code&gt;ann@foo.duckdns.org&lt;/code&gt; (45 points).&lt;/p&gt;

&lt;p&gt;On the Pro plan, velocity rules add points when many signups arrive in a short time from the same network, or from the same inbox in several spellings. Velocity points can raise a signup to challenge but never to block. So your form refuses a signup over velocity points only when you refuse challenges.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the check doesn't stop
&lt;/h2&gt;

&lt;p&gt;A person with several real inboxes at different providers, say a Gmail and an Outlook address, gets an account with each. We can't link two unrelated inboxes to the same person, so each address starts its own repeat count.&lt;/p&gt;

&lt;p&gt;We still score each of those signups on its email address and its network. On the Starter plan and above, we add points to a signup from a datacenter, VPN or Tor address, whichever inbox the person uses.&lt;/p&gt;

&lt;p&gt;The check doesn't prove that the person owns the address. For that, add email verification to the starter.&lt;/p&gt;

&lt;p&gt;On the Free plan, once you've used 1,000 checks in a month, signups go through unchecked until the month ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the finished code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/signupbouncer/nextjs-saas-starter-signup-protection
&lt;span class="nb"&gt;cd &lt;/span&gt;nextjs-saas-starter-signup-protection
pnpm &lt;span class="nb"&gt;install
cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fill in &lt;code&gt;.env&lt;/code&gt; the same way as in the first section, and set &lt;code&gt;SIGNUPBOUNCER_API_KEY&lt;/code&gt; to your key. Then create the tables and start the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm db:migrate
pnpm dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:3000/sign-up&lt;/code&gt; and sign up with an address at mailinator.com. With your app set to Enforce, the form shows "Please sign up with a permanent email address." and the starter creates no account.&lt;/p&gt;

&lt;p&gt;The repo keeps the starter's own README as &lt;code&gt;README.upstream.md&lt;/code&gt;, with the Stripe setup and the steps for going to production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/signupbouncer/nextjs-saas-starter-signup-protection" rel="noopener noreferrer"&gt;signupbouncer/nextjs-saas-starter-signup-protection&lt;/a&gt; has the finished code and its tests.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://signupbouncer.com/docs#api" rel="noopener noreferrer"&gt;REST API reference&lt;/a&gt; lists every field in our answer, and the errors.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://signupbouncer.com/docs#reasons" rel="noopener noreferrer"&gt;reason codes&lt;/a&gt; table shows what each reason means and how the points add up.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://signupbouncer.com/docs#modes" rel="noopener noreferrer"&gt;Shadow and enforce&lt;/a&gt; shows what each mode does with a check's result.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
