<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Elijah Brown]]></title><description><![CDATA[Elijah Brown]]></description><link>https://elijahbrown.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Elijah Brown</title><link>https://elijahbrown.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 16 Oct 2026 12:59:32 GMT</lastBuildDate><atom:link href="https://elijahbrown.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[One free trial per person in Laravel: normalise Gmail dots and plus tags, then enforce it in the database]]></title><description><![CDATA[A free trial that hands out credits on signup has a simple loophole. Gmail ignores dots in the local part and drops anything after a plus sign, and it treats googlemail.com as the same mailbox as gmai]]></description><link>https://elijahbrown.hashnode.dev/one-free-trial-per-person-in-laravel-normalise-gmail-dots-and-plus-tags-then-enforce-it-in-the-database</link><guid isPermaLink="true">https://elijahbrown.hashnode.dev/one-free-trial-per-person-in-laravel-normalise-gmail-dots-and-plus-tags-then-enforce-it-in-the-database</guid><category><![CDATA[Laravel]]></category><category><![CDATA[PHP]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Elijah Brown]]></dc:creator><pubDate>Thu, 15 Oct 2026 13:14:47 GMT</pubDate><content:encoded><![CDATA[<p>A free trial that hands out credits on signup has a simple loophole. Gmail ignores dots in the local part and drops anything after a plus sign, and it treats googlemail.com as the same mailbox as gmail.com. So <code>jane.doe@gmail.com</code>, <code>janedoe+2@gmail.com</code> and <code>j.a.n.e.doe@googlemail.com</code> all land in one inbox, and each one looks like a fresh account to a plain <code>unique:users,email</code> rule.</p>
<p>This post builds the fix in a fresh Laravel 13 app in four parts: a normaliser, a validation Rule for a friendly form error, a migration with a unique index on <code>trial_claims</code>, and a claim action that writes the claim and grants the credits in one <code>DB::transaction</code>. Then a Pest suite, including a test where four processes race for the same trial at once. Every snippet below comes from the app I ran the tests against.</p>
<h2>1. Normalise the address</h2>
<p>Keep the stored email exactly as the person typed it. Only the trial key gets normalised.</p>
<pre><code class="language-php">&lt;?php

namespace App\Support;

class EmailIdentity
{
    private const GMAIL_DOMAINS = ['gmail.com', 'googlemail.com'];

    public static function normalise(string $email): string
    {
        $email = strtolower(trim($email));

        $at = strrpos($email, '@');

        if ($at === false) {
            return $email;
        }

        $local = substr($email, 0, $at);
        $domain = substr($email, $at + 1);

        if (! in_array($domain, self::GMAIL_DOMAINS, true)) {
            return $local.'@'.$domain;
        }

        $local = explode('+', $local, 2)[0];
        $local = str_replace('.', '', $local);

        return $local.'@gmail.com';
    }
}
</code></pre>
<p>Two choices worth calling out. Dots and plus tags are only stripped for gmail.com and googlemail.com, because on other providers <code>jane.doe</code> and <code>janedoe</code> can be two different people. And googlemail.com is folded into gmail.com, so the two spellings share one key.</p>
<p>You could extend the plus rule to providers that document it, such as Outlook or Fastmail. I left that out on purpose: a team mailbox like <code>billing+acme@company.com</code> can belong to someone else entirely.</p>
<h2>2. A Rule class for the signup form</h2>
<pre><code class="language-php">&lt;?php

namespace App\Rules;

use App\Models\TrialClaim;
use App\Support\EmailIdentity;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class FirstTrialForEmail implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! is_string($value)) {
            return;
        }

        $taken = TrialClaim::where('normalised_email', EmailIdentity::normalise($value))-&gt;exists();

        if ($taken) {
            $fail('This email has already been used for a free trial.');
        }
    }
}
</code></pre>
<p>Use it next to your usual rules:</p>
<pre><code class="language-php">$request-&gt;validate([
    'email' =&gt; ['required', 'email', 'unique:users,email', new FirstTrialForEmail],
]);
</code></pre>
<p>This gives a clear message at the form. It is not the guarantee. Two requests can both run this check before either one writes, and both will pass. The database has to settle that case.</p>
<h2>3. The migration</h2>
<pre><code class="language-php">&lt;?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table-&gt;unsignedInteger('credits')-&gt;default(0);
        });

        Schema::create('trial_claims', function (Blueprint $table) {
            $table-&gt;id();
            $table-&gt;foreignId('user_id')-&gt;constrained()-&gt;cascadeOnDelete();
            $table-&gt;string('normalised_email');
            $table-&gt;timestamps();

            $table-&gt;unique('normalised_email');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('trial_claims');

        Schema::table('users', function (Blueprint $table) {
            $table-&gt;dropColumn('credits');
        });
    }
};
</code></pre>
<p>The unique index on <code>normalised_email</code> is the line that actually enforces one trial per person. <code>trial_claims</code> is its own table rather than a column on <code>users</code>, so it can outlive the account. As written, deleting a user cascades and frees their email for another trial. If you want the claim to survive account deletion, make <code>user_id</code> nullable and use <code>nullOnDelete()</code> instead of the cascade.</p>
<h2>4. Claim and grant in one transaction</h2>
<pre><code class="language-php">&lt;?php

namespace App\Actions;

use App\Exceptions\TrialAlreadyClaimed;
use App\Models\TrialClaim;
use App\Models\User;
use App\Support\EmailIdentity;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;

class ClaimTrial
{
    public const TRIAL_CREDITS = 500;

    public function __invoke(User $user): void
    {
        try {
            DB::transaction(function () use ($user) {
                TrialClaim::create([
                    'user_id' =&gt; $user-&gt;id,
                    'normalised_email' =&gt; EmailIdentity::normalise($user-&gt;email),
                ]);

                User::whereKey($user-&gt;id)-&gt;increment('credits', self::TRIAL_CREDITS);
            });
        } catch (UniqueConstraintViolationException) {
            throw new TrialAlreadyClaimed('This email has already been used for a free trial.');
        }
    }
}
</code></pre>
<p>The claim row and the credit grant either both happen or neither does. If the insert hits the unique index, Laravel throws <code>UniqueConstraintViolationException</code>, the transaction rolls back, and the credits are never added. No <code>lockForUpdate</code>, no check then insert; the index does the work.</p>
<p>Call it after the user is created, and turn the exception into a normal response:</p>
<pre><code class="language-php">try {
    app(ClaimTrial::class)($user);
} catch (TrialAlreadyClaimed $e) {
    // The account still exists, it just starts without trial credits.
    session()-&gt;flash('status', $e-&gt;getMessage());
}
</code></pre>
<h2>5. Pest tests</h2>
<p>The normaliser first, with a dataset of the variants that should all collapse to one key:</p>
<pre><code class="language-php">&lt;?php

use App\Support\EmailIdentity;

it('folds Gmail dots, plus tags and googlemail.com into one address', function (string $input) {
    expect(EmailIdentity::normalise($input))-&gt;toBe('janedoe@gmail.com');
})-&gt;with([
    'janedoe@gmail.com',
    'jane.doe@gmail.com',
    'j.a.n.e.d.o.e@gmail.com',
    'janedoe+trial2@gmail.com',
    'Jane.Doe+again@GMAIL.com',
    'jane.doe@googlemail.com',
    '  jane.doe+x@googlemail.com ',
]);

it('leaves dots alone on other domains', function () {
    expect(EmailIdentity::normalise('Jane.Doe@Example.com'))-&gt;toBe('jane.doe@example.com');
});
</code></pre>
<p>Then the claim flow. The third test is the important one: both requests pass the Rule, and the second one is still refused by the index.</p>
<pre><code class="language-php">&lt;?php

use App\Actions\ClaimTrial;
use App\Exceptions\TrialAlreadyClaimed;
use App\Models\TrialClaim;
use App\Models\User;
use App\Rules\FirstTrialForEmail;
use Illuminate\Support\Facades\Validator;

uses(Illuminate\Foundation\Testing\RefreshDatabase::class);

it('grants the trial credits once', function () {
    $user = User::factory()-&gt;create(['email' =&gt; 'jane.doe@gmail.com']);

    (new ClaimTrial)($user);

    expect($user-&gt;fresh()-&gt;credits)-&gt;toBe(ClaimTrial::TRIAL_CREDITS)
        -&gt;and(TrialClaim::count())-&gt;toBe(1);
});

it('rejects a Gmail variant in the form rule', function () {
    (new ClaimTrial)(User::factory()-&gt;create(['email' =&gt; 'jane.doe@gmail.com']));

    $validator = Validator::make(
        ['email' =&gt; 'JaneDoe+second@googlemail.com'],
        ['email' =&gt; ['required', 'email', new FirstTrialForEmail]],
    );

    expect($validator-&gt;fails())-&gt;toBeTrue()
        -&gt;and($validator-&gt;errors()-&gt;first('email'))
        -&gt;toBe('This email has already been used for a free trial.');
});

it('lets the database decide when two requests both pass the rule', function () {
    $first = User::factory()-&gt;create(['email' =&gt; 'jane.doe@gmail.com']);
    $second = User::factory()-&gt;create(['email' =&gt; 'janedoe+2@googlemail.com']);

    $rules = ['email' =&gt; [new FirstTrialForEmail]];
    expect(Validator::make(['email' =&gt; $first-&gt;email], $rules)-&gt;passes())-&gt;toBeTrue()
        -&gt;and(Validator::make(['email' =&gt; $second-&gt;email], $rules)-&gt;passes())-&gt;toBeTrue();

    (new ClaimTrial)($first);

    expect(fn () =&gt; (new ClaimTrial)($second))-&gt;toThrow(TrialAlreadyClaimed::class)
        -&gt;and($second-&gt;fresh()-&gt;credits)-&gt;toBe(0)
        -&gt;and(TrialClaim::count())-&gt;toBe(1);
});
</code></pre>
<h2>6. The concurrency case</h2>
<p>The test above runs the two claims one after another. To show the index holding under real parallel writes, this test forks four processes against a SQLite file, each claiming the trial for a different spelling of the same Gmail address. Exactly one should win.</p>
<pre><code class="language-php">&lt;?php

use App\Actions\ClaimTrial;
use App\Exceptions\TrialAlreadyClaimed;
use App\Models\TrialClaim;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;

beforeEach(function () {
    $this-&gt;dbFile = tempnam(sys_get_temp_dir(), 'trial').'.sqlite';
    touch($this-&gt;dbFile);

    config([
        'database.connections.sqlite.database' =&gt; $this-&gt;dbFile,
        'database.connections.sqlite.busy_timeout' =&gt; 5000,
    ]);
    DB::purge('sqlite');
    Artisan::call('migrate', ['--force' =&gt; true]);
});

afterEach(function () {
    DB::purge('sqlite');
    @unlink($this-&gt;dbFile);
});

it('gives the trial to exactly one of several processes racing at once', function () {
    $emails = ['jane.doe@gmail.com', 'janedoe@googlemail.com', 'j.anedoe+a@gmail.com', 'JANEDOE+b@gmail.com'];
    $ids = collect($emails)-&gt;map(fn ($e) =&gt; User::factory()-&gt;create(['email' =&gt; $e])-&gt;id);

    DB::purge('sqlite');
    $outDir = sys_get_temp_dir().'/trial-race-'.getmypid();
    @mkdir($outDir);
    $pids = [];

    foreach ($ids as $id) {
        $pid = pcntl_fork();

        if ($pid === 0) {
            DB::purge('sqlite');
            try {
                (new ClaimTrial)(User::findOrFail($id));
                $result = 'claimed';
            } catch (TrialAlreadyClaimed) {
                $result = 'refused';
            } catch (Throwable $e) {
                $result = 'error: '.$e-&gt;getMessage();
            }
            file_put_contents("$outDir/$id", $result);
            posix_kill(getmypid(), SIGKILL);
        }

        $pids[] = $pid;
    }

    foreach ($pids as $pid) {
        pcntl_waitpid($pid, $status);
    }

    $results = array_map(fn ($f) =&gt; file_get_contents($f), glob("$outDir/*"));
    array_map('unlink', glob("$outDir/*"));
    rmdir($outDir);

    $counts = array_count_values($results);

    expect($counts['claimed'] ?? 0)-&gt;toBe(1)
        -&gt;and($counts['refused'] ?? 0)-&gt;toBe(count($emails) - 1)
        -&gt;and(TrialClaim::count())-&gt;toBe(1)
        -&gt;and((int) User::sum('credits'))-&gt;toBe(ClaimTrial::TRIAL_CREDITS);
});
</code></pre>
<p>A few notes on making this work. The default test setup uses an in memory SQLite database, which a forked child cannot share, so this file points the connection at a temp file and migrates it. <code>DB::purge</code> before forking stops the children from inheriting the parent's PDO handle. <code>busy_timeout</code> makes a child wait for the write lock instead of failing at once. Each child writes its result to a file and kills itself, so it never runs the test runner's shutdown code. It needs the <code>pcntl</code> and <code>posix</code> extensions, which the PHP CLI usually has.</p>
<p>On MySQL or Postgres the same action behaves the same way: the loser gets a unique violation inside its transaction and rolls back.</p>
<h2>Results</h2>
<pre><code>PASS  Tests\Unit\EmailIdentityTest
  ✓ it folds Gmail dots, plus tags and googlemail.com into one address with ('janedoe@gmail.com')
  ✓ it folds Gmail dots, plus tags and googlemail.com into one address with ('jane.doe@gmail.com')
  ✓ it folds Gmail dots, plus tags and googlemail.com into one address with ('j.a.n.e.d.o.e@gmail.com')
  ✓ it folds Gmail dots, plus tags and googlemail.com into one address with ('janedoe+trial2@gmail.com')
  ✓ it folds Gmail dots, plus tags and googlemail.com into one address with ('Jane.Doe+again@GMAIL.com')
  ✓ it folds Gmail dots, plus tags and googlemail.com into one address with ('jane.doe@googlemail.com')
  ✓ it folds Gmail dots, plus tags and googlemail.com into one address with (' jane.doe+x@googlemail.com ')
  ✓ it leaves dots alone on other domains

   PASS  Tests\Feature\TrialClaimConcurrencyTest
  ✓ it gives the trial to exactly one of several processes racing at on… 0.23s  

   PASS  Tests\Feature\TrialClaimTest
  ✓ it grants the trial credits once                                     0.04s  
  ✓ it rejects a Gmail variant in the form rule                          0.01s  
  ✓ it lets the database decide when two requests both pass the rule     0.01s  

  Tests:    12 passed (21 assertions)
  Duration: 0.36s
</code></pre>
<p>I also ran the race test 20 times in a row and it passed every time.</p>
<h2>What this does not cover</h2>
<p>It only closes the Gmail address trick. Someone with two genuinely different mailboxes still gets two trials, and that needs other signals, such as the phone number, the IP or the email domain.</p>
<p>I built Orisift, which handles the other signup checks; there's a free tier. <a href="https://orisift.com/x?m=post&amp;c=hashnode-laravel&amp;to=/">orisift.com</a></p>
]]></content:encoded></item><item><title><![CDATA[One trial per inbox in Supabase, enforced by Postgres]]></title><description><![CDATA[If your Supabase app gives new accounts free credits, someone will eventually sign up as jane.doe@gmail.com, spend the credits, then come back as janedoe+2@gmail.com, then j.a.n.e.doe@googlemail.com. ]]></description><link>https://elijahbrown.hashnode.dev/one-trial-per-inbox-supabase-postgres</link><guid isPermaLink="true">https://elijahbrown.hashnode.dev/one-trial-per-inbox-supabase-postgres</guid><category><![CDATA[supabase]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[Next.js]]></category><category><![CDATA[SaaS]]></category><dc:creator><![CDATA[Elijah Brown]]></dc:creator><pubDate>Wed, 14 Oct 2026 08:24:12 GMT</pubDate><content:encoded><![CDATA[<p>If your Supabase app gives new accounts free credits, someone will eventually sign up as <code>jane.doe@gmail.com</code>, spend the credits, then come back as <code>janedoe+2@gmail.com</code>, then <code>j.a.n.e.doe@googlemail.com</code>. Supabase Auth sees three different strings and creates three users. Gmail delivers all three to the same inbox.</p>
<p>You don't have to block those signups. The person may simply have forgotten they had an account. What you want is narrower: the second account works, it just doesn't get a second batch of credits.</p>
<p>This post does that entirely inside Postgres, in one migration. No edge function, no extra service. Everything below ran against PostgreSQL 17 with a small stand in for the <code>auth</code> schema, and the test files are listed at the end.</p>
<h2>The plan</h2>
<ol>
<li><p>Turn each email into a claim key, so the three addresses above all become <code>janedoe@gmail.com</code>.</p>
</li>
<li><p>Store one row per key in <code>trial_claims</code>, with a unique index on the key.</p>
</li>
<li><p>In a trigger on <code>auth.users</code>, try to insert the claim. If the insert lands, grant credits. If it hits the index, grant nothing.</p>
</li>
<li><p>Separately, a Before User Created hook in SQL that refuses signups from domains on a throwaway list.</p>
</li>
</ol>
<p>The trigger and the hook do different jobs. The trigger never refuses anyone; it only decides whether credits come with the account. The hook runs earlier and can refuse.</p>
<h2>Step 1: the claim key</h2>
<pre><code class="language-sql">create or replace function public.email_claim_key(email text)
returns text
language plpgsql
immutable
set search_path = ''
as $$
declare
  addr   text := lower(trim(email));
  at_pos int;
  local  text;
  domain text;
begin
  if addr is null or addr = '' then
    return null;
  end if;
  at_pos := length(addr) - strpos(reverse(addr), '@') + 1;
  if strpos(addr, '@') = 0 then
    return null;
  end if;
  local  := left(addr, at_pos - 1);
  domain := substr(addr, at_pos + 1);

  if domain = 'googlemail.com' then
    domain := 'gmail.com';
  end if;

  -- drop a +tag on every domain
  local := split_part(local, '+', 1);

  -- Gmail ignores dots in the local part
  if domain = 'gmail.com' then
    local := replace(local, '.', '');
  end if;

  if local = '' then
    return null;
  end if;
  return local || '@' || domain;
end;
$$;
</code></pre>
<p>The rules, and why each one is there:</p>
<p><strong>Lowercase and trim.</strong> Supabase lowercases emails already, but the key should not depend on that.</p>
<p><strong>googlemail.com becomes gmail.com.</strong> They are the same mailbox. Google still accepts mail for both.</p>
<p><strong>Drop everything after the first</strong> <code>+</code><strong>, on every domain.</strong> Gmail, Outlook, iCloud, Fastmail, Proton and most company mail servers treat <code>name+anything</code> as <code>name</code>. A few servers don't, but someone who really has two inboxes that differ only by a +tag is rare, and the cost of a false match here is one account without trial credits, not a lost signup.</p>
<p><strong>Remove dots only for gmail.com.</strong> Gmail ignores dots in the local part. Outlook and most others do not, so <code>jane.doe@outlook.com</code> and <code>janedoe@outlook.com</code> stay different keys.</p>
<p><strong>Return null when there's nothing to key on.</strong> No <code>@</code>, an empty string, or a local part that was only a tag. Phone signups also land here because <code>email</code> is null. A null key means no claim and no credits, which you can change if phone signups should get a trial.</p>
<p>The function finds the last <code>@</code> rather than the first, since quoted local parts can legally contain one. It's marked <code>immutable</code> so you can index on it later if you want to look up existing users by key.</p>
<h2>Step 2: claims and credits</h2>
<pre><code class="language-sql">create table public.trial_claims (
  id         bigint generated always as identity primary key,
  claim_key  text not null,
  user_id    uuid references auth.users (id) on delete set null,
  created_at timestamptz not null default now()
);

create unique index trial_claims_claim_key_idx on public.trial_claims (claim_key);

create table public.credits (
  user_id    uuid primary key references auth.users (id) on delete cascade,
  balance    integer not null default 0 check (balance &gt;= 0),
  updated_at timestamptz not null default now()
);

alter table public.trial_claims enable row level security;
alter table public.credits enable row level security;

create policy "read own credits" on public.credits
  for select to authenticated
  using ((select auth.uid()) = user_id);
</code></pre>
<p>Two things in here are deliberate.</p>
<p><strong>The unique index is what enforces the rule.</strong> Application code that checks "does a claim exist?" and then inserts has a gap between the check and the insert. Two signups arriving together both see nothing and both get credits. The index closes that gap: the second insert waits for the first transaction and then conflicts. I tested exactly that with two sessions, below.</p>
<p><code>on delete set null</code><strong>, not cascade.</strong> My first version used cascade, and the test showed the obvious hole: delete the account, sign up again with the same address, get fresh credits. With <code>set null</code> the claim outlives the account. The key stays taken and the new user starts at zero.</p>
<p>RLS is on for both tables. Users can read their own balance. Nobody reads <code>trial_claims</code> from the client; the trigger writes it as the function owner.</p>
<h2>Step 3: grant on first claim only</h2>
<pre><code class="language-sql">create or replace function public.grant_trial_credits()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
declare
  key     text := public.email_claim_key(new.email);
  claimed bigint;
begin
  if key is not null then
    insert into public.trial_claims (claim_key, user_id)
    values (key, new.id)
    on conflict (claim_key) do nothing
    returning id into claimed;
  end if;

  insert into public.credits (user_id, balance)
  values (new.id, case when claimed is not null then 100 else 0 end);

  return new;
end;
$$;

create trigger on_auth_user_created_grant_credits
  after insert on auth.users
  for each row execute function public.grant_trial_credits();
</code></pre>
<p><code>on conflict (claim_key) do nothing returning id</code> is the whole trick. When the insert lands, <code>returning</code> hands back the new id. When it conflicts, nothing is returned and <code>claimed</code> stays null. One statement, no separate lookup.</p>
<p>The function is <code>security definer</code> because Supabase Auth inserts into <code>auth.users</code> as <code>supabase_auth_admin</code>, which has no rights on your public tables. <code>set search_path = ''</code> plus fully qualified names keeps the definer function from resolving anything unexpected.</p>
<p>Every user still gets a <code>credits</code> row, at zero when the key was taken. Your app code can then always read one row and never has to handle a missing balance.</p>
<h2>Step 4: refuse throwaway domains in the hook</h2>
<p>A claim key doesn't help with addresses at disposable inbox providers, because each one is a fresh, unrelated address. That check belongs before the user exists, in a Postgres Before User Created hook.</p>
<pre><code class="language-sql">create table public.disposable_domains (
  domain text primary key check (domain = lower(domain))
);

alter table public.disposable_domains enable row level security;

create or replace function public.hook_before_user_created(event jsonb)
returns jsonb
language plpgsql
stable
set search_path = ''
as $$
declare
  email  text := lower(trim(event -&gt; 'user' -&gt;&gt; 'email'));
  email_domain text;
begin
  if email is null or strpos(email, '@') = 0 then
    return '{}'::jsonb;
  end if;

  email_domain := split_part(email, '@', -1);

  if exists (
    select 1
    from public.disposable_domains d
    where email_domain = d.domain
       or email_domain like '%.' || d.domain
  ) then
    return jsonb_build_object(
      'error', jsonb_build_object(
        'http_code', 400,
        'message', 'Please sign up with a permanent email address.'
      )
    );
  end if;

  return '{}'::jsonb;
end;
$$;

grant usage on schema public to supabase_auth_admin;
grant execute on function public.hook_before_user_created(jsonb) to supabase_auth_admin;
grant select on table public.disposable_domains to supabase_auth_admin;
create policy "auth admin reads domains" on public.disposable_domains
  as permissive for select to supabase_auth_admin using (true);
revoke execute on function public.hook_before_user_created(jsonb) from authenticated, anon, public;
</code></pre>
<p>Seed the table with whatever list you trust. There are maintained open source lists on GitHub with a few thousand domains; load one with <code>\copy public.disposable_domains from 'domains.txt'</code> and refresh it from time to time.</p>
<p>The <code>like '%.' || d.domain</code> line catches subdomains such as <code>mx.yopmail.com</code> without matching <code>notmailinator.com</code>, because it requires the dot.</p>
<p>The return shape is what Supabase reads. An empty object lets the signup through. An object with <code>error.http_code</code> and <code>error.message</code> stops it, and the message reaches your client as the error on <code>signUp</code>. Then enable it in the dashboard under Authentication, Hooks, Before User Created, choosing Postgres and <code>public.hook_before_user_created</code>.</p>
<p>The grants at the end matter. Supabase calls the hook as <code>supabase_auth_admin</code>, so that role needs execute on the function and select on the domain table (with an RLS policy, since RLS is on). Revoking execute from <code>anon</code> and <code>authenticated</code> keeps the function off your public API.</p>
<h2>In a Next.js app</h2>
<p>Nothing changes on the client. A normal <code>supabase.auth.signUp</code> either errors with the hook message or succeeds, and then you read the balance:</p>
<pre><code class="language-ts">const { data: credits } = await supabase
  .from("credits")
  .select("balance")
  .single();

if (credits?.balance === 0) {
  // show "your trial was already used on this email" instead of an empty dashboard
}
</code></pre>
<p>Telling the person why they have zero credits saves you a support email.</p>
<h2>What I tested</h2>
<p>I ran all of this against PostgreSQL 17.11 with a stub <code>auth</code> schema (an <code>auth.users</code> table, <code>auth.uid()</code> and the three Supabase roles). Inserts into <code>auth.users</code> ran as <code>supabase_auth_admin</code>, the same role Supabase Auth uses. 28 checks passed, plus a two session race:</p>
<ol>
<li><p><code>Jane.Doe+trial2@Gmail.com</code>, <code>j.a.n.e.d.o.e@googlemail.com</code> and <code>janedoe@gmail.com</code> all produce <code>janedoe@gmail.com</code>.</p>
</li>
<li><p><code>jane.doe+x@outlook.com</code> becomes <code>jane.doe@outlook.com</code>, dots kept.</p>
</li>
<li><p>Empty, null, no <code>@</code>, and <code>+onlytag@gmail.com</code> give a null key.</p>
</li>
<li><p>Five email signups across two people: the first for each person gets 100 credits, every repeat gets 0, and all six users (one phone only) are still created.</p>
</li>
<li><p>A direct duplicate insert into <code>trial_claims</code> raises <code>unique_violation</code>.</p>
</li>
<li><p>Deleting an account keeps its claim, and signing up again with the same address gets 0.</p>
</li>
<li><p>A signed in user sees one row in <code>credits</code> and none in <code>trial_claims</code>.</p>
</li>
<li><p>The hook refuses <code>mailinator.com</code>, refuses <code>Someone@MX.Yopmail.com</code>, allows <code>gmail.com</code>, allows <code>notmailinator.com</code>, and allows a phone signup.</p>
</li>
<li><p><code>anon</code> cannot execute the hook function.</p>
</li>
<li><p>Race: one session inserts <code>race.case@gmail.com</code> and holds its transaction for two seconds; a second session inserts <code>racecase+b@googlemail.com</code> meanwhile. The second waits on the index, then gets 0. Final balances: 100 and 0.</p>
</li>
</ol>
<h2>Where this stops</h2>
<p>This keys on the mailbox, so it stops the cheap repeats: dots, tags, the googlemail spelling, delete and return. It does nothing about someone with ten separate Gmail accounts, and a domain list only knows the domains on it. If you need more than that, card checks at signup or phone confirmation are the next steps, each with its own friction.</p>
<p>Also decide what happens when a user changes their email. The trigger only runs on insert, which is usually what you want: the trial is tied to the address they signed up with.</p>
<p>I built Orisift (<a href="https://orisift.com/x?m=post&amp;c=hashnode-supabase-hook&amp;to=%2F">https://orisift.com/x?m=post&amp;c=hashnode-supabase-hook&amp;to=%2F</a>), which does this key plus a throwaway domain check in one call.</p>
]]></content:encoded></item><item><title><![CDATA[Stop one person claiming your Node.js free tier twice: Gmail dots, plus tags and googlemail.com]]></title><description><![CDATA[If your app gives credits to every new account, at some point one person signs up as jane.doe@gmail.com, then janedoe+2@gmail.com, then Jane.Doe@googlemail.com. All three land in the same inbox. A uni]]></description><link>https://elijahbrown.hashnode.dev/stop-nodejs-free-tier-reuse-gmail-dots-plus-tags</link><guid isPermaLink="true">https://elijahbrown.hashnode.dev/stop-nodejs-free-tier-reuse-gmail-dots-plus-tags</guid><category><![CDATA[Next.js]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Node.js]]></category><dc:creator><![CDATA[Elijah Brown]]></dc:creator><pubDate>Tue, 13 Oct 2026 14:13:42 GMT</pubDate><content:encoded><![CDATA[<p>If your app gives credits to every new account, at some point one person signs up as <a href="mailto:jane.doe@gmail.com">jane.doe@gmail.com</a>, then <a href="mailto:janedoe+2@gmail.com">janedoe+2@gmail.com</a>, then <a href="mailto:Jane.Doe@googlemail.com">Jane.Doe@googlemail.com</a>. All three land in the same inbox. A unique constraint on <code>users.email</code> sees three different strings and hands out three lots of credits.</p>
<p>I don't think it's worth fighting account creation itself. What you can control is who gets the free tier. The pattern is short: build a normalised key from the email, store it in a <code>trial_claims</code> table with a unique index, and insert the claim in the same transaction as the credit grant. If the insert collides, the account still exists; it just doesn't get the credits.</p>
<p>Every snippet below is copied from a small project that I ran under vitest against PostgreSQL 17 and SQLite. The test details are at the end.</p>
<h2>The email key</h2>
<p>The key is only for matching. Keep sending mail to the address exactly as the person typed it.</p>
<p><strong>Dots.</strong> Gmail ignores dots in the part before the @, but only on gmail.com and googlemail.com, which are the same mailbox. Google's help says this doesn't apply to Workspace accounts, so on a company domain hosted by Google, j.smith and jsmith can be two different colleagues. Other providers treat dots as part of the name, so removing dots anywhere else would merge different people.</p>
<p><strong>Plus tags.</strong> Gmail and Outlook.com both treat everything after a <code>+</code> as a tag that arrives in the same inbox. On someone's own domain, <code>jo+sales@example.co.uk</code> might be a separate mailbox, or the server may reject it. So strip tags only for providers that document the behaviour, and add to the list only after reading their help pages.</p>
<pre><code class="language-ts">// src/email-key.ts
// Gmail ignores dots in the local part, but only on these two domains.
const DOTS_IGNORED = new Set(["gmail.com", "googlemail.com"]);

// Providers whose own help pages describe +tags. Add one only after reading them.
const PLUS_IS_TAG = new Set(["gmail.com", "googlemail.com", "outlook.com", "hotmail.com"]);

export function emailKey(raw: string): string | null {
  const email = raw.trim().toLowerCase();
  const at = email.lastIndexOf("@");
  if (at &lt;= 0 || at === email.length - 1) return null;

  let local = email.slice(0, at);
  let domain = email.slice(at + 1);

  if (PLUS_IS_TAG.has(domain)) local = local.split("+")[0];
  if (DOTS_IGNORED.has(domain)) {
    local = local.replaceAll(".", "");
    domain = "gmail.com";
  }

  return local ? `${local}@${domain}` : null;
}
</code></pre>
<p><code>emailKey(" J.Doe+trial2@GMAIL.com ")</code> and <code>emailKey("j.doe@googlemail.com")</code> both return <code>jdoe@gmail.com</code>. <code>j.doe+x@outlook.com</code> becomes <code>j.doe@outlook.com</code>. <code>jo+sales@example.co.uk</code> and <code>j.smith@company.com</code> come back unchanged. Junk like <code>+tag@gmail.com</code>, <code>...@gmail.com</code> or <code>nobody</code> returns <code>null</code>, and <code>null</code> means no trial.</p>
<p><code>lastIndexOf("@")</code> splits on the last @, which matters for the rare quoted local part that contains one.</p>
<p>Privacy relays stay allowed. Addresses at <code>privaterelay.appleid.com</code>, <code>mozmail.com</code> or <code>duck.com</code> forward to an inbox the person keeps for years, so the normaliser leaves them alone. Someone can create a second relay address, and that's a limit of email keys in general. If it shows up in your data, add a second key such as a confirmed phone number to the same table.</p>
<h2>Why a separate trial_claims table</h2>
<p>You could put a unique <code>signup_email_key</code> column on <code>users</code>. A separate table has two advantages. The claim outlives the account: if someone deletes their account and signs up again, the row in <code>trial_claims</code> is still there, which is the whole point. (Keep the foreign key as <code>RESTRICT</code>, or drop it if you hard delete users.) And adding another kind of key later, a phone number or a card fingerprint, is just another row with a prefix like <code>phone:+442079460958</code>, with no migration on <code>users</code>.</p>
<p>The unique index is what does the work. Looking first with <code>findFirst</code> or a <code>SELECT</code> and then inserting leaves a gap: two requests can both look at the same moment, both see nothing, and both grant credits. With a unique index, the second insert waits for the first transaction and then fails or does nothing, and that is the whole race handling.</p>
<p>Here is the PostgreSQL schema the tests use:</p>
<pre><code class="language-sql">CREATE TABLE IF NOT EXISTS users (
  id SERIAL PRIMARY KEY,
  email TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS trial_claims (
  id SERIAL PRIMARY KEY,
  claim_key TEXT NOT NULL UNIQUE,
  user_id INTEGER NOT NULL REFERENCES users(id),
  claimed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS credit_grants (
  id SERIAL PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  amount INTEGER NOT NULL,
  reason TEXT NOT NULL
);
</code></pre>
<h2>node-postgres</h2>
<p><code>INSERT ... ON CONFLICT (claim_key) DO NOTHING RETURNING id</code> returns no row when the key is taken, so there is no exception to catch for the normal case. The grant only runs when a row comes back, inside the same transaction. Note that the whole thing runs on one client from the pool; <code>pool.query("BEGIN")</code> would put each statement on whichever connection is free.</p>
<pre><code class="language-ts">// src/grant-pg.ts
import type { Pool } from "pg";

export const TRIAL_CREDITS = 500;

/** Returns true if credits were granted. Call it after the user row exists. */
export async function grantTrial(pool: Pool, userId: number, key: string | null): Promise&lt;boolean&gt; {
  if (key === null) return false;
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const claim = await client.query(
      `INSERT INTO trial_claims (claim_key, user_id) VALUES ($1, $2)
       ON CONFLICT (claim_key) DO NOTHING
       RETURNING id`,
      [key, userId],
    );
    if (claim.rowCount === 0) {
      // Someone already holds this key. Nothing was written.
      await client.query("ROLLBACK");
      return false;
    }
    await client.query(
      "INSERT INTO credit_grants (user_id, amount, reason) VALUES ($1, $2, $3)",
      [userId, TRIAL_CREDITS, "signup_trial"],
    );
    await client.query("COMMIT");
    return true;
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}
</code></pre>
<h2>Prisma</h2>
<p>The models, mapped onto the same tables:</p>
<pre><code class="language-prisma">generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}

model User {
  id      Int           @id @default(autoincrement())
  email   String
  claims  TrialClaim[]
  grants  CreditGrant[]

  @@map("users")
}

model TrialClaim {
  id        Int      @id @default(autoincrement())
  claimKey  String   @unique @map("claim_key")
  userId    Int      @map("user_id")
  user      User     @relation(fields: [userId], references: [id], onDelete: Restrict)
  claimedAt DateTime @default(now()) @map("claimed_at") @db.Timestamptz

  @@map("trial_claims")
}

model CreditGrant {
  id     Int    @id @default(autoincrement())
  userId Int    @map("user_id")
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
  amount Int
  reason String

  @@map("credit_grants")
}
</code></pre>
<p><code>@unique</code> on <code>claimKey</code> becomes the unique index in the migration. The grant uses an interactive transaction. When the claim insert hits the index, Prisma throws <code>P2002</code>, the transaction rolls back, and neither row is written.</p>
<pre><code class="language-ts">// src/prisma/grant.ts
import { Prisma, PrismaClient } from "../generated/prisma/client";

export const TRIAL_CREDITS = 500;

/** Returns true if credits were granted. Call it after the user is created. */
export async function grantTrial(prisma: PrismaClient, userId: number, key: string | null): Promise&lt;boolean&gt; {
  if (key === null) return false;
  try {
    await prisma.$transaction(async (tx) =&gt; {
      await tx.trialClaim.create({ data: { claimKey: key, userId } });
      await tx.creditGrant.create({ data: { userId, amount: TRIAL_CREDITS, reason: "signup_trial" } });
    });
    return true;
  } catch (err) {
    if (err instanceof Prisma.PrismaClientKnownRequestError &amp;&amp; err.code === "P2002") {
      return false; // unique index on claim_key fired; the transaction rolled back
    }
    throw err;
  }
}
</code></pre>
<p>This was tested with Prisma 7 and the <code>@prisma/adapter-pg</code> driver adapter. The error code is the same on Prisma 5 and 6 with the built in engine.</p>
<h2>Drizzle</h2>
<p>The schema:</p>
<pre><code class="language-ts">// src/drizzle/schema.ts
import { integer, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: serial("id").primaryKey(),
  email: text("email").notNull(),
});

export const trialClaims = pgTable("trial_claims", {
  id: serial("id").primaryKey(),
  claimKey: text("claim_key").notNull().unique(),
  userId: integer("user_id").notNull().references(() =&gt; users.id),
  claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().defaultNow(),
});

export const creditGrants = pgTable("credit_grants", {
  id: serial("id").primaryKey(),
  userId: integer("user_id").notNull().references(() =&gt; users.id, { onDelete: "cascade" }),
  amount: integer("amount").notNull(),
  reason: text("reason").notNull(),
});
</code></pre>
<p>Drizzle has <code>onConflictDoNothing</code>, so the grant reads much like the node-postgres version. Returning <code>false</code> from the callback just ends the transaction normally; there's nothing to roll back because nothing was written.</p>
<pre><code class="language-ts">// src/drizzle/grant.ts
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { creditGrants, trialClaims } from "./schema";

export const TRIAL_CREDITS = 500;

/** Returns true if credits were granted. Call it after the user row exists. */
export async function grantTrial(db: NodePgDatabase, userId: number, key: string | null): Promise&lt;boolean&gt; {
  if (key === null) return false;
  return db.transaction(async (tx) =&gt; {
    const claimed = await tx
      .insert(trialClaims)
      .values({ claimKey: key, userId })
      .onConflictDoNothing({ target: trialClaims.claimKey })
      .returning({ id: trialClaims.id });
    if (claimed.length === 0) return false; // already claimed, nothing to grant

    await tx.insert(creditGrants).values({ userId, amount: TRIAL_CREDITS, reason: "signup_trial" });
    return true;
  });
}
</code></pre>
<h2>better-sqlite3</h2>
<p>If you're on SQLite, better-sqlite3's <code>db.transaction()</code> wraps a function, commits when it returns and rolls back when it throws. <code>.immediate()</code> takes the write lock at the start, which avoids a busy error when several connections write at once.</p>
<pre><code class="language-ts">// src/grant-sqlite.ts
import Database from "better-sqlite3";

export const TRIAL_CREDITS = 500;

export const SCHEMA = `
CREATE TABLE IF NOT EXISTS users (
  id INTEGER PRIMARY KEY,
  email TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS trial_claims (
  id INTEGER PRIMARY KEY,
  claim_key TEXT NOT NULL UNIQUE,
  user_id INTEGER NOT NULL REFERENCES users(id),
  claimed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS credit_grants (
  id INTEGER PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id),
  amount INTEGER NOT NULL,
  reason TEXT NOT NULL
);
`;

export function grantTrial(db: Database.Database, userId: number, key: string | null): boolean {
  if (key === null) return false;
  const insertClaim = db.prepare("INSERT INTO trial_claims (claim_key, user_id) VALUES (?, ?)");
  const insertGrant = db.prepare("INSERT INTO credit_grants (user_id, amount, reason) VALUES (?, ?, ?)");

  // db.transaction() commits when the function returns and rolls back if it throws.
  const claimAndGrant = db.transaction(() =&gt; {
    insertClaim.run(key, userId);
    insertGrant.run(userId, TRIAL_CREDITS, "signup_trial");
  });

  try {
    claimAndGrant.immediate();
    return true;
  } catch (err) {
    if (err instanceof Database.SqliteError &amp;&amp; err.code === "SQLITE_CONSTRAINT_UNIQUE") {
      return false; // key already claimed, both inserts rolled back
    }
    throw err;
  }
}
</code></pre>
<p>SQLite also supports <code>INSERT ... ON CONFLICT DO NOTHING RETURNING</code>, so you can use the node-postgres shape here too if you prefer it. I kept the exception version to show the error code.</p>
<h2>A Next.js route handler</h2>
<p>The route creates the user first, then tries the grant. A taken key still gets a working account, just with no trial credits, so the person who already had one isn't blocked from signing in on a second address.</p>
<pre><code class="language-ts">// app/api/signup/route.ts
import { pool } from "../../../src/db";
import { emailKey } from "../../../src/email-key";
import { grantTrial } from "../../../src/grant-pg";

export async function POST(request: Request) {
  const body = await request.json().catch(() =&gt; null);
  const email = typeof body?.email === "string" ? body.email.trim() : "";
  if (!email.includes("@")) {
    return Response.json({ error: "Please enter an email address." }, { status: 400 });
  }

  // Store the address as typed. The key is only for matching trial claims.
  const { rows } = await pool.query&lt;{ id: number }&gt;(
    "INSERT INTO users (email) VALUES ($1) RETURNING id",
    [email],
  );
  const userId = rows[0].id;
  const trialGranted = await grantTrial(pool, userId, emailKey(email));

  return Response.json({ userId, trialGranted }, { status: 201 });
}
</code></pre>
<p>The pool lives in its own module so every request reuses it:</p>
<pre><code class="language-ts">// src/db.ts
import { Pool } from "pg";

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL ?? "postgresql://trial:trial@localhost:5432/trial",
});
</code></pre>
<p>If you want the user insert and the claim to commit or fail together, move the <code>INSERT INTO users</code> onto the same client inside <code>grantTrial</code>. I kept them apart here on purpose, because an account without credits is the outcome I wanted when the key is taken.</p>
<h2>The tests</h2>
<p>The four signups used across the tests are <code>jane.doe@gmail.com</code>, <code>janedoe+2@gmail.com</code>, <code>Jane.Doe@googlemail.com</code> and <code>sam@example.com</code>. Expected result: credits for the first and the last, none for the two in the middle, four accounts either way.</p>
<pre><code class="language-ts">// test/pg.test.ts
test("node-postgres: one trial per key", async () =&gt; {
  const results = [];
  for (const email of SIGNUPS) {
    results.push(await grantTrial(pool, await addUser(pool, email), emailKey(email)));
  }
  expect(results).toEqual(EXPECTED);
  expect(await count(pool, "users")).toBe(4);
  expect(await count(pool, "credit_grants")).toBe(2);
  expect(await count(pool, "trial_claims")).toBe(2);
});
</code></pre>
<p>The concurrency test is the one I cared about most. It creates eight users whose addresses all normalise to <code>janedoe@gmail.com</code>, then fires all eight grants at once with <code>Promise.all</code>. The pool opens up to ten connections, so these are separate transactions racing on the unique index in Postgres:</p>
<pre><code class="language-ts">// test/pg.test.ts
test("node-postgres: eight concurrent claims, one winner", async () =&gt; {
  const emails = Array.from({ length: 8 }, (_, i) =&gt; `jane.doe+${i}@gmail.com`);
  const ids = [];
  for (const e of emails) ids.push(await addUser(pool, e));

  const results = await Promise.all(ids.map((id, i) =&gt; grantTrial(pool, id, emailKey(emails[i]))));

  expect(results.filter(Boolean)).toHaveLength(1);
  expect(await count(pool, "credit_grants")).toBe(1);
  expect(await count(pool, "trial_claims")).toBe(1);
});
</code></pre>
<p>Exactly one call wins and exactly one credit row exists. The same race runs against the Prisma and Drizzle versions, and against the route handler with six simultaneous requests:</p>
<pre><code class="language-ts">// test/route.test.ts
test("route handler: concurrent signups", async () =&gt; {
  const responses = await Promise.all(
    Array.from({ length: 6 }, (_, i) =&gt; signup(`janedoe+${i}@gmail.com`)),
  );
  const bodies = await Promise.all(responses.map((r) =&gt; r.json()));
  expect(responses.every((r) =&gt; r.status === 201)).toBe(true);
  expect(bodies.filter((b) =&gt; b.trialGranted)).toHaveLength(1);
  expect(await count(pool, "users")).toBe(6);
});
</code></pre>
<p>For better-sqlite3, which is synchronous, <code>Promise.all</code> wouldn't race anything, so that test starts eight worker threads, each with its own connection to one database file in WAL mode. They wait on a shared <code>Atomics.wait</code> gate and all call <code>grantTrial</code> when it opens. One gets <code>true</code>, seven get <code>false</code>.</p>
<h2>What this won't stop</h2>
<p>Someone with a fresh inbox on a new domain gets a fresh free tier. That's fine for most products; the aim is to make repeat claims cost more effort than the credits are worth, not to make them impossible. It also does nothing about throwaway inbox services on their own, so pair it with a domain check at signup if those show up in your data.</p>
<h2>Tested with</h2>
<p>Node.js 24.21.0, TypeScript 6.0.3, vitest 5.0.4, PostgreSQL 17, pg 8.23.1, Prisma 7.10.0 with @prisma/adapter-pg, drizzle-orm 0.45.4, better-sqlite3 13.0.3 and Next.js 16.4.0 (the route handler is called directly with a <code>Request</code>). 26 tests in 6 files, all passing, and the TypeScript compiler reports no errors.</p>
<p>I built Orisift (<a href="https://orisift.com/x?m=post&amp;c=hashnode-node-trial&amp;to=%2F">https://orisift.com/x?m=post&amp;c=hashnode-node-trial&amp;to=%2F</a>), which does this key plus a throwaway domain check in one call.</p>
]]></content:encoded></item><item><title><![CDATA[Stop one person claiming your Python free tier twice: Gmail dots, plus tags and googlemail.com]]></title><description><![CDATA[If your app hands out credits to every new account, sooner or later one person signs up as jane.doe@gmail.com, then janedoe+2@gmail.com, then Jane.Doe@googlemail.com. Every one of those lands in the s]]></description><link>https://elijahbrown.hashnode.dev/stop-one-person-claiming-your-python-free-tier-twice-gmail-dots-plus-tags-and-googlemail-com</link><guid isPermaLink="true">https://elijahbrown.hashnode.dev/stop-one-person-claiming-your-python-free-tier-twice-gmail-dots-plus-tags-and-googlemail-com</guid><category><![CDATA[Python]]></category><category><![CDATA[Django]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Elijah Brown]]></dc:creator><pubDate>Tue, 13 Oct 2026 11:07:43 GMT</pubDate><content:encoded><![CDATA[<p>If your app hands out credits to every new account, sooner or later one person signs up as <a href="mailto:jane.doe@gmail.com">jane.doe@gmail.com</a>, then <a href="mailto:janedoe+2@gmail.com">janedoe+2@gmail.com</a>, then <a href="mailto:Jane.Doe@googlemail.com">Jane.Doe@googlemail.com</a>. Every one of those lands in the same inbox. A <code>unique=True</code> on the email field sees three different strings and gives out three lots of credits.</p>
<p>You can't stop people making accounts, and it isn't worth trying very hard. What you can control is who gets the free tier. The pattern here is short: build a normalised key from the email, store it in a <code>trial_claims</code> table with a unique index, and insert the claim in the same transaction as the credit grant. If the insert collides, the account still exists; it just doesn't get the credits.</p>
<p>The code below was run under pytest; the setup is at the end.</p>
<h2>The email key</h2>
<p>The key is only for matching. Keep sending mail to the address exactly as the person typed it.</p>
<p><strong>Dots.</strong> Gmail ignores dots in the part before the @, but only on gmail.com and googlemail.com, which are the same mailbox. Google's help says this doesn't apply to Workspace accounts, so on a company domain hosted by Google, j.smith and jsmith can be two different colleagues. Other providers treat dots as part of the name. Removing dots anywhere else would merge different people.</p>
<p><strong>Plus tags.</strong> Gmail and Outlook.com both treat everything after a <code>+</code> as a tag that arrives in the same inbox. On someone's own domain, <code>jo+sales@example.co.uk</code> might be a separate mailbox, or the server may reject it outright. So strip tags only for providers that document the behaviour, and add to the list only after reading their help pages.</p>
<pre><code class="language-python"># Gmail ignores dots in the local part, but only on these two domains.
DOTS_IGNORED = {"gmail.com", "googlemail.com"}

# Providers whose own help pages describe +tags. Add one only after reading them.
PLUS_IS_TAG = {"gmail.com", "googlemail.com", "outlook.com", "hotmail.com"}


def email_key(raw: str) -&gt; str | None:
    email = raw.strip().lower()
    local, sep, domain = email.rpartition("@")
    if not sep or not local or not domain:
        return None

    if domain in PLUS_IS_TAG:
        local = local.split("+", 1)[0]
    if domain in DOTS_IGNORED:
        local = local.replace(".", "")
        domain = "gmail.com"

    return f"{local}@{domain}" if local else None
</code></pre>
<p><code>email_key(" J.Doe+trial2@GMAIL.com ")</code> and <code>email_key("j.doe@googlemail.com")</code> both return <code>jdoe@gmail.com</code>. <code>j.doe+x@outlook.com</code> becomes <code>j.doe@outlook.com</code>. <code>jo+sales@example.co.uk</code> and <code>j.smith@company.com</code> come back unchanged. Junk like <code>+tag@gmail.com</code> or <code>...@gmail.com</code> returns <code>None</code>, and <code>None</code> means no trial.</p>
<p><code>rpartition("@")</code> splits on the last @, which matters for the rare quoted local part that contains one. It also means <code>"nobody"</code> gives an empty separator, which we treat as no key.</p>
<p>Privacy relays stay allowed. Addresses at <code>privaterelay.appleid.com</code>, <code>mozmail.com</code> or <code>duck.com</code> forward to an inbox the person keeps for years, so the normaliser leaves them alone. Someone can generate a second relay address, and that's a limit of email keys in general; if it shows up in your data, add a second key such as a confirmed phone number to the same table.</p>
<h2>Why a separate trial_claims table</h2>
<p>You could put a unique <code>signup_email_key</code> column on the user model. A separate table has two advantages. First, the claim outlives the account: if someone deletes their account and signs up again, the row in <code>trial_claims</code> is still there, which is the whole point. (Use <code>on_delete=PROTECT</code> or drop the foreign key if you hard delete users.) Second, adding another kind of key later, a phone number or a payment card fingerprint, is just another row with a prefix like <code>phone:+442079460958</code>, with no migration on <code>users</code>.</p>
<p>The unique index is what does the work. Checking first with <code>.exists()</code> and then inserting leaves a gap: two requests can both look at the same moment, both see nothing, and both grant credits. With a unique index, whichever insert commits second gets an <code>IntegrityError</code>, and catching that exception is the whole race handling.</p>
<h2>Django</h2>
<p>The models:</p>
<pre><code class="language-python">from django.conf import settings
from django.db import models


class TrialClaim(models.Model):
    claim_key = models.CharField(max_length=320, unique=True)
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
    claimed_at = models.DateTimeField(auto_now_add=True)


class CreditGrant(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    amount = models.PositiveIntegerField()
    reason = models.CharField(max_length=50)
</code></pre>
<p><code>unique=True</code> creates the index in the migration that <code>makemigrations</code> writes for you.</p>
<p>The grant:</p>
<pre><code class="language-python">from django.db import IntegrityError, transaction

from .models import CreditGrant, TrialClaim

TRIAL_CREDITS = 500


def grant_trial(user, key: str | None) -&gt; bool:
    """Returns True if credits were granted. Call it after the user is saved."""
    if key is None:
        return False
    try:
        with transaction.atomic():
            TrialClaim.objects.create(claim_key=key, user=user)
            CreditGrant.objects.create(user=user, amount=TRIAL_CREDITS, reason="signup_trial")
    except IntegrityError:
        # Key already claimed. atomic() rolled back both rows; the account stays.
        return False
    return True
</code></pre>
<p>The <code>try</code> sits outside <code>atomic()</code>, and that's deliberate. Django's docs say to catch database errors around an atomic block, not inside it, because after an <code>IntegrityError</code> the transaction is broken until it rolls back. When the block exits with the exception, Django rolls it back, so you never end up with a claim and no credits, or credits and no claim.</p>
<p>If your signup view already runs inside <code>transaction.atomic()</code> (or you have <code>ATOMIC_REQUESTS = True</code>), the inner block becomes a savepoint. A collision rolls back only the claim and the grant, and the new user row survives. There's a test for exactly that below.</p>
<p>Calling it from the signup flow:</p>
<pre><code class="language-python">user = User.objects.create_user(username=username, email=form.cleaned_data["email"])
if not grant_trial(user, email_key(user.email)):
    # Account created, free tier skipped. Say so plainly on the onboarding screen.
    ...
</code></pre>
<h2>SQLAlchemy 2.0</h2>
<p>Same shape. <code>begin_nested()</code> opens a savepoint, so a collision undoes just the claim and the grant and leaves the rest of the session usable.</p>
<pre><code class="language-python">from sqlalchemy import ForeignKey, String, func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column

TRIAL_CREDITS = 500


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(320))


class TrialClaim(Base):
    __tablename__ = "trial_claims"
    claim_key: Mapped[str] = mapped_column(String(320), primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
    claimed_at: Mapped[str] = mapped_column(server_default=func.current_timestamp())


class CreditGrant(Base):
    __tablename__ = "credit_grants"
    id: Mapped[int] = mapped_column(primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
    amount: Mapped[int]
    reason: Mapped[str] = mapped_column(String(50))


def grant_trial(session: Session, user_id: int, key: str | None) -&gt; bool:
    """Returns True if credits were granted. The user row must already be committed."""
    if key is None:
        return False
    try:
        with session.begin_nested():  # SAVEPOINT: a collision undoes only this block
            session.add(TrialClaim(claim_key=key, user_id=user_id))
            session.add(CreditGrant(user_id=user_id, amount=TRIAL_CREDITS, reason="signup_trial"))
            session.flush()
    except IntegrityError:
        return False
    return True
</code></pre>
<p><code>session.flush()</code> inside the block forces the inserts to hit the database while the savepoint is open, so the <code>IntegrityError</code> is raised there and not at some later commit. The caller commits afterwards as usual.</p>
<h2>Plain sqlite3</h2>
<p>No ORM at all. <code>with conn:</code> commits on success and rolls back on any exception.</p>
<pre><code class="language-python">import sqlite3

TRIAL_CREDITS = 500

SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY,
    email TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS trial_claims (
    claim_key TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id),
    claimed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS credit_grants (
    id INTEGER PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id),
    amount INTEGER NOT NULL,
    reason TEXT NOT NULL
);
"""


def grant_trial(conn: sqlite3.Connection, user_id: int, key: str | None) -&gt; bool:
    """Returns True if credits were granted. Call it after the user row exists."""
    if key is None:
        return False
    try:
        with conn:  # one transaction: commit on success, roll back on exception
            conn.execute(
                "INSERT INTO trial_claims (claim_key, user_id) VALUES (?, ?)",
                (key, user_id),
            )
            conn.execute(
                "INSERT INTO credit_grants (user_id, amount, reason) VALUES (?, ?, ?)",
                (user_id, TRIAL_CREDITS, "signup_trial"),
            )
        return True
    except sqlite3.IntegrityError:
        # Someone already holds this key. Nothing from this block was written.
        return False
</code></pre>
<p>On PostgreSQL with psycopg the code is the same apart from <code>%s</code> placeholders, the exception class (<code>psycopg.errors.UniqueViolation</code>, a subclass of <code>IntegrityError</code>) and the timestamp default. If you'd rather not use exceptions for control flow there, <code>INSERT ... ON CONFLICT (claim_key) DO NOTHING RETURNING claim_key</code> returns no row on a collision, and you grant credits only when a row comes back, still inside the same transaction.</p>
<h2>The tests</h2>
<p>The four signups used across the tests are <code>jane.doe@gmail.com</code>, <code>janedoe+2@gmail.com</code>, <code>Jane.Doe@googlemail.com</code> and <code>sam@example.com</code>. Expected result: credits for the first and the last, none for the two in the middle, four accounts either way.</p>
<pre><code class="language-python">SIGNUPS = ["jane.doe@gmail.com", "janedoe+2@gmail.com", "Jane.Doe@googlemail.com", "sam@example.com"]
EXPECTED = [True, False, False, True]


def test_django(django_db):
    from django.contrib.auth.models import User
    from trials.models import CreditGrant, TrialClaim
    from trials.services import grant_trial

    results = []
    for i, email in enumerate(SIGNUPS):
        user = User.objects.create_user(username=f"u{i}", email=email)
        results.append(grant_trial(user, email_key(email)))
    assert results == EXPECTED
    assert User.objects.count() == 4
    assert CreditGrant.objects.count() == 2
    assert TrialClaim.objects.count() == 2
</code></pre>
<p>The race test is the one I cared about most. It creates eight users whose addresses all normalise to <code>janedoe@gmail.com</code>, then starts eight threads, each with its own sqlite3 connection to a file database, holds them at a <code>threading.Barrier</code> and releases them together:</p>
<pre><code class="language-python">def test_sqlite3_race(tmp_path):
    db = tmp_path / "race.db"
    setup = sqlite3.connect(db)
    setup.executescript(grant_sqlite.SCHEMA)
    with setup:
        for i in range(8):
            setup.execute("INSERT INTO users (email) VALUES (?)", (f"jane.doe+{i}@gmail.com",))
    barrier = threading.Barrier(8)
    results = []

    def worker(uid):
        conn = sqlite3.connect(db, timeout=10)
        barrier.wait()
        results.append(grant_sqlite.grant_trial(conn, uid, email_key(f"jane.doe+{uid}@gmail.com")))

    threads = [threading.Thread(target=worker, args=(i + 1,)) for i in range(8)]
    [t.start() for t in threads]
    [t.join() for t in threads]
    assert sorted(results) == [False] * 7 + [True]
    assert setup.execute("SELECT count(*) FROM credit_grants").fetchone()[0] == 1
</code></pre>
<p>Exactly one thread wins and exactly one credit row exists. SQLite serialises writers, so this shows the logic holds rather than stress testing Postgres, but the unique index behaves the same way there.</p>
<p>The full suite also covers 13 normaliser cases, the same four signups through SQLAlchemy and plain sqlite3, and a Django test where both signups happen inside one outer <code>transaction.atomic()</code>: the second user collides, gets no credits, and both user rows are still there afterwards.</p>
<h2>What this won't stop</h2>
<p>Someone with a fresh inbox on a new domain gets a fresh free tier. That's fine for most products; the aim is to make repeat claims cost more effort than the credits are worth, not to make them impossible. It also does nothing about throwaway inbox services on their own, so pair it with a domain check at signup if those show up in your data.</p>
<h2>Tested with</h2>
<p>Python 3.13.5, Django 6.1.2, SQLAlchemy 2.1.4 and pytest 9.1.1, all on SQLite (in memory, plus a temporary file for the threaded test). 18 tests, all passing. Django tables were created with <code>migrate --run-syncdb</code> in a test fixture, so no migration files were generated for this post.</p>
<p>I built Orisift (<a href="https://orisift.com/x?m=post&amp;c=hashnode-python-trial&amp;to=%2F">https://orisift.com/x?m=post&amp;c=hashnode-python-trial&amp;to=%2F</a>), which does this key plus a throwaway domain check in one call.</p>
]]></content:encoded></item><item><title><![CDATA[The SMS bill nobody planned for: when a signup form sends a code to any number typed in]]></title><description><![CDATA[If your signup form sends a one-time code by SMS, you have built a button that spends money on behalf of whoever presses it. Most of the time the person pressing it wants to sign up. Sometimes they wa]]></description><link>https://elijahbrown.hashnode.dev/sms-code-signup-pumping-costs</link><guid isPermaLink="true">https://elijahbrown.hashnode.dev/sms-code-signup-pumping-costs</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[PHP]]></category><category><![CDATA[Security]]></category><category><![CDATA[SaaS]]></category><dc:creator><![CDATA[Elijah Brown]]></dc:creator><pubDate>Mon, 12 Oct 2026 12:11:16 GMT</pubDate><content:encoded><![CDATA[<p>If your signup form sends a one-time code by SMS, you have built a button that spends money on behalf of whoever presses it. Most of the time the person pressing it wants to sign up. Sometimes they want the message to be sent, and nothing else.</p>
<p>That second case has a name: SMS pumping. Twilio describes it plainly in its docs: someone uses a phone number field to trigger messages to a range of numbers controlled by a particular mobile network, and takes a share of the revenue those messages generate (<a href="https://www.twilio.com/docs/verify/preventing-toll-fraud">Twilio, SMS pumping</a>). Nobody ever enters the code. Your form did its job perfectly, and the bill is the only evidence.</p>
<p>This post is the set of checks I would put in front of any SMS send on a signup form. None of them is clever. The point is that they run before the provider is called, because once the message is sent the money has gone.</p>
<h2>What the traffic looks like</h2>
<p>Pumping rarely looks like one person hammering one number. The usual signs are:</p>
<ul>
<li>many sends to numbers that share a long prefix, often in a country you have never had a customer in;</li>
<li>sends spread across many IP addresses, so a per IP limit alone does not catch it;</li>
<li>almost no codes ever entered;</li>
<li>bursts at odd hours, then nothing.</li>
</ul>
<p>So the defences have to work on the number, the prefix and the country, not only on the person.</p>
<h2>Step one: parse to E.164 and refuse impossible numbers</h2>
<p>Everything else depends on having one canonical form of the number. Parse what was typed, using the country the form is showing, and turn it into E.164 (<code>+447400123456</code>). If the library says the number cannot exist, stop there. There is no reason to pay to send a code to <code>12345</code>.</p>
<p>Order matters a little. Check whether the number is possible, then the country, then the reserved ranges, and only then whether it is valid in the numbering plan. That way your logs say <em>why</em> a number was refused, which helps when someone emails support.</p>
<h2>Step two: allowlist the countries you serve</h2>
<p>If your customers are in the UK, Ireland, North America and Australia, there is no reason for your form to send codes anywhere else. An allowlist is the single most effective change here, because pumping depends on reaching expensive destinations. Twilio says the same thing: disable the countries you do not plan to send to (<a href="https://www.twilio.com/docs/verify/preventing-toll-fraud/verify-geo-permissions">Twilio, geo permissions</a>).</p>
<p>Do it twice: in your code, so the provider is never called, and in the provider's console, so a bug in your code cannot undo it.</p>
<h2>Step three: refuse reserved and fictional ranges</h2>
<p>Every numbering plan sets aside numbers for films, adverts and documentation. Nobody can hold them, so a code sent there is wasted, and seeing them at signup usually means test data or someone filling the form in by hand without caring.</p>
<ul>
<li><strong>North America:</strong> 555-0100 to 555-0199 in any area code are reserved as fictitious, non working numbers (<a href="https://www.nanpa.com/numbering/555-line-numbers">NANPA</a>).</li>
<li><strong>UK:</strong> Ofcom publishes drama ranges, including 07700 900000 to 900999 for mobiles, 020 7946 0000 to 0999 for London, and 01632 960000 to 960999 (<a href="https://www.ofcom.org.uk/phones-and-broadband/phone-numbers/numbers-for-drama">Ofcom</a>).</li>
<li><strong>Australia:</strong> ACMA lists geographic ranges such as (02) 5550 xxxx and (02) 7010 xxxx, plus a set of individual 0491 mobile numbers (<a href="https://web.archive.org/web/20200128064327/https:/www.acma.gov.au/use-phone-numbers-fiction">ACMA, archived page</a>). ACMA lists single mobile numbers, not a block, so match them exactly.</li>
</ul>
<p>These ranges are small. Blocking them will not stop pumping on its own, but it costs nothing and removes a class of sends that can never succeed.</p>
<h2>Step four: rate limit per number, per IP and per prefix</h2>
<p>Three counters, each with its own window:</p>
<ul>
<li><strong>per number:</strong> a few codes an hour is plenty for someone who mistyped or lost signal;</li>
<li><strong>per IP:</strong> catches the lazy cases;</li>
<li><strong>per prefix:</strong> the one that matters for pumping. Take the country code and the next few digits and count sends across all users. Twenty codes in ten minutes to one prefix you have never seen before is worth refusing and alerting on.</li>
</ul>
<p>Twilio's own guidance is in the same spirit: no more than one message per so many seconds to the same range or prefix, and limits by user, IP or device (<a href="https://www.twilio.com/docs/verify/preventing-toll-fraud">Twilio</a>).</p>
<h2>The Node version</h2>
<p>Tested with Node 20.19.2 and libphonenumber-js 1.13.15. The counters are in memory to keep the logic readable; in production put them in Redis with <code>INCR</code> and <code>EXPIRE</code>.</p>
<pre><code class="language-js">// sms-guard.mjs: decide whether a signup may send an SMS code to this number.
import { parsePhoneNumberFromString } from 'libphonenumber-js/max'

// Countries you actually serve. Everything else is refused before any send.
const ALLOWED = new Set(['GB', 'US', 'CA', 'AU', 'IE'])

// Ranges set aside for fiction and testing, as E.164 prefixes or patterns.
const RESERVED = [
  /^\+1\d{3}5550(1\d\d)$/,           // NANP 555-0100 to 555-0199, any area code
  /^\+447700900\d{3}$/,              // Ofcom drama mobile 07700 900000 to 900999
  /^\+44(11[3-8]|121|131|141|151|161)4960\d{3}$/, // Ofcom drama, 0113 to 0161
  /^\+442079460\d{3}$/,              // London 020 7946 0000 to 0999
  /^\+441914980\d{3}$/,              // Tyneside 0191 498 0000 to 0999
  /^\+442896496\d{3}$/,              // Northern Ireland 028 9649 6000 to 6999
  /^\+442920180\d{3}$/,              // Cardiff 029 2018 0000 to 0999
  /^\+441632960\d{3}$/,              // 01632 960000 to 960999
  /^\+61[2378](5550|7010)\d{4}$/,    // ACMA geographic (0X) 5550 xxxx and 7010 xxxx
]

// ACMA lists individual mobile numbers for fiction rather than a block.
const ACMA_MOBILE = new Set(['570006','570110','570156','570157','570158','570159','570313','570737',
  '571266','571491','571804','572549','572665','572983','573087','573770','574118','574632',
  '575254','575789','576398','576801','577426','577644','578148','578888','578957','579212',
  '579455','579760'].map((n) =&gt; '+61491' + n))

export function checkNumber(input, country) {
  const phone = parsePhoneNumberFromString(String(input), country)
  if (!phone || !phone.isPossible()) return { ok: false, reason: 'impossible' }
  if (!phone.country || !ALLOWED.has(phone.country)) return { ok: false, reason: 'country_not_served' }
  const e164 = phone.number
  if (ACMA_MOBILE.has(e164) || RESERVED.some((re) =&gt; re.test(e164))) return { ok: false, reason: 'reserved_range', e164 }
  if (!phone.isValid()) return { ok: false, reason: 'not_in_numbering_plan', e164 }
  return { ok: true, e164 }
}

// Fixed window counters. In production use Redis INCR with EXPIRE; this keeps the logic visible.
export function makeLimiter(now = () =&gt; Date.now()) {
  const buckets = new Map()
  return function hit(key, limit, windowMs) {
    const t = now()
    const b = buckets.get(key)
    if (!b || t &gt;= b.reset) { buckets.set(key, { count: 1, reset: t + windowMs }); return true }
    b.count += 1
    return b.count &lt;= limit
  }
}

// Prefix used for burst detection: country code plus the next few digits.
export const prefixOf = (e164, digits = 6) =&gt; e164.slice(0, 1 + digits)

export function maySend({ e164, ip }, hit) {
  if (!hit('num:' + e164, 3, 60 * 60_000)) return { ok: false, reason: 'number_limit' }
  if (!hit('ip:' + ip, 5, 60 * 60_000)) return { ok: false, reason: 'ip_limit' }
  if (!hit('pfx:' + prefixOf(e164), 20, 10 * 60_000)) return { ok: false, reason: 'prefix_burst' }
  return { ok: true }
}
</code></pre>
<p>A few results from the test run:</p>
<pre><code class="language-text">(202) 555-0143, US      -&gt; reserved_range
07700 900123, GB        -&gt; reserved_range
020 7946 0958, GB       -&gt; reserved_range
(02) 5550 1234, AU      -&gt; reserved_range
0491 570 156, AU        -&gt; reserved_range
0491 570 160, AU        -&gt; ok, +61491570160
12345, GB               -&gt; impossible
+371 2000 0000          -&gt; country_not_served
07400 123456, GB        -&gt; ok, +447400123456
4th code to one number in an hour   -&gt; number_limit
6th code from one IP in an hour     -&gt; ip_limit
21st code to one prefix in 10 min   -&gt; prefix_burst
</code></pre>
<h2>The PHP version</h2>
<p>Tested with PHP 8.4.26 and giggsey/libphonenumber-for-php 9.0.41. Add the ACMA mobile list from the Node version if you serve Australia.</p>
<pre><code class="language-php">&lt;?php

use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;
use libphonenumber\ValidationResult;

final class SmsGuard
{
    private const ALLOWED = ['GB', 'US', 'CA', 'AU', 'IE'];

    private const RESERVED = [
        '/^\+1\d{3}55501\d\d$/',                        // NANP 555-0100 to 555-0199
        '/^\+447700900\d{3}$/',                         // Ofcom drama mobile
        '/^\+44(11[3-8]|121|131|141|151|161)4960\d{3}$/',
        '/^\+442079460\d{3}$/', '/^\+441914980\d{3}$/',
        '/^\+442896496\d{3}$/', '/^\+442920180\d{3}$/', '/^\+441632960\d{3}$/',
        '/^\+61[2378](5550|7010)\d{4}$/',              // ACMA geographic
    ];

    /** @return array{ok: bool, reason?: string, e164?: string} */
    public static function check(string $input, string $country): array
    {
        $util = PhoneNumberUtil::getInstance();
        try {
            $number = $util-&gt;parse($input, $country);
        } catch (NumberParseException) {
            return ['ok' =&gt; false, 'reason' =&gt; 'impossible'];
        }
        if ($util-&gt;isPossibleNumberWithReason($number) !== ValidationResult::IS_POSSIBLE) {
            return ['ok' =&gt; false, 'reason' =&gt; 'impossible'];
        }
        // An invalid number has no region, so fall back to the country calling code.
        $region = $util-&gt;getRegionCodeForNumber($number)
            ?? $util-&gt;getRegionCodeForCountryCode($number-&gt;getCountryCode());
        if (! in_array($region, self::ALLOWED, true)) {
            return ['ok' =&gt; false, 'reason' =&gt; 'country_not_served'];
        }
        $e164 = $util-&gt;format($number, PhoneNumberFormat::E164);
        foreach (self::RESERVED as $pattern) {
            if (preg_match($pattern, $e164)) {
                return ['ok' =&gt; false, 'reason' =&gt; 'reserved_range', 'e164' =&gt; $e164];
            }
        }
        if (! $util-&gt;isValidNumber($number)) {
            return ['ok' =&gt; false, 'reason' =&gt; 'not_in_numbering_plan', 'e164' =&gt; $e164];
        }

        return ['ok' =&gt; true, 'e164' =&gt; $e164];
    }

    public static function prefix(string $e164, int $digits = 6): string
    {
        return substr($e164, 0, 1 + $digits);
    }
}
</code></pre>
<p>One thing tripped me up while testing: for a number that is not valid in the plan, such as the Ofcom drama mobile range, <code>getRegionCodeForNumber()</code> returns null. If you allowlist on that alone, every reserved number looks like a foreign one. Falling back to the region for the country calling code fixes it. I also used <code>isPossibleNumberWithReason()</code> rather than <code>isPossibleNumber()</code>, because the latter accepts numbers that are only possible when dialled locally, such as <code>12345</code>.</p>
<p>In Laravel, the three counters map neatly onto <code>RateLimiter::attempt()</code> with keys like <code>sms:num:+447400123456</code>, <code>sms:ip:203.0.113.7</code> and <code>sms:pfx:+447400</code>. I have not included that code because it is a thin wrapper; the decisions are the part worth testing.</p>
<h2>Step five: cap spend at the provider</h2>
<p>Your code can have bugs. The provider's spend limit is the backstop that does not.</p>
<ul>
<li><strong>AWS End User Messaging SMS</strong> has an account limit and an optional enforced monthly limit; when you hit it, sending stops until you raise it (<a href="https://docs.aws.amazon.com/sms-voice/latest/userguide/spend-limit.html">AWS</a>). Amazon SNS has an account spend limit too, and its docs note sending stops <em>within minutes</em> of the limit being passed, so a little overspend is possible (<a href="https://docs.aws.amazon.com/sns/latest/dg/sms_preferences.html">AWS SNS</a>).</li>
<li><strong>Twilio</strong> has a feature for its one-time code service that looks at your historical traffic and blocks destination prefixes when it sees pumping patterns. Its docs say it covers SMS only, not voice, and lists conditions on which code and protection settings it supports (<a href="https://www.twilio.com/docs/verify/preventing-toll-fraud/sms-fraud-guard">Twilio</a>). It sits alongside the per country geo permissions mentioned above.</li>
</ul>
<p>Set the cap low enough that a bad night is annoying rather than serious, and set an alert well below it.</p>
<h2>Watch the ratio</h2>
<p>The best single number to chart is codes sent against codes entered, per country and per prefix. Normal traffic converts. Pumped traffic does not. If a prefix sends fifty codes and none come back, you do not need anything more sophisticated to know what happened.</p>
<h2>Disclosure</h2>
<p>I built Orisift, which flags reserved and fictional ranges at signup, among other checks on the phone, email, IP and domain. There is a free checker for the reserved ranges here: <a href="https://orisift.com/x?m=post&amp;c=hashnode-sms-pumping&amp;to=%2Freserved-phone-number-checker">reserved phone number checker</a>. Everything above works without it.</p>
]]></content:encoded></item><item><title><![CDATA[Stop one person claiming your Laravel free tier twice: normalising Gmail dots, plus tags and googlemail.com]]></title><description><![CDATA[If your Laravel app gives credits or a free tier to every new account, someone will eventually sign up three times: as jane.doe@gmail.com, then janedoe+2@gmail.com, then jane.doe@googlemail.com. All t]]></description><link>https://elijahbrown.hashnode.dev/stop-one-person-claiming-your-laravel-free-tier-twice-normalising-gmail-dots-plus-tags-and-googlemail-com</link><guid isPermaLink="true">https://elijahbrown.hashnode.dev/stop-one-person-claiming-your-laravel-free-tier-twice-normalising-gmail-dots-plus-tags-and-googlemail-com</guid><category><![CDATA[Laravel]]></category><category><![CDATA[PHP]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Elijah Brown]]></dc:creator><pubDate>Mon, 12 Oct 2026 11:00:56 GMT</pubDate><content:encoded><![CDATA[<p>If your Laravel app gives credits or a free tier to every new account, someone will eventually sign up three times: as <a href="mailto:jane.doe@gmail.com">jane.doe@gmail.com</a>, then <a href="mailto:janedoe+2@gmail.com">janedoe+2@gmail.com</a>, then <a href="mailto:jane.doe@googlemail.com">jane.doe@googlemail.com</a>. All three land in the same inbox. Your <code>unique:users,email</code> rule sees three different strings and hands out three lots of credits.</p>
<p>You can't stop people making accounts, and it isn't worth trying hard. What you can control is who receives the free tier. The approach here stores a normalised copy of the email and the phone on the user, puts a unique index on each, and grants credits only if both keys can be written. A collision still creates the account; it just skips the credits.</p>
<h2>The migration</h2>
<p>Two nullable, unique columns on <code>users</code>. Nullable matters: a user whose keys collide keeps <code>null</code>, and unique indexes allow many nulls on MySQL, PostgreSQL and SQLite.</p>
<pre><code class="language-php">&lt;?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table-&gt;string('signup_email_key')-&gt;nullable()-&gt;unique();
            $table-&gt;string('signup_phone_key', 20)-&gt;nullable()-&gt;unique();
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table-&gt;dropUnique(['signup_email_key']);
            $table-&gt;dropUnique(['signup_phone_key']);
            $table-&gt;dropColumn(['signup_email_key', 'signup_phone_key']);
        });
    }
};
</code></pre>
<p>One consequence of keeping the keys on <code>users</code>: if you hard delete a user, their keys go with them and the next signup can claim again. If people can delete their own accounts, use <code>SoftDeletes</code> on the model so the row, and the keys, stay. If you need hard deletes for data protection reasons, move the keys into a separate table that you keep after the account is gone.</p>
<h2>The email key</h2>
<p>The key is only for matching. Keep sending mail to the address exactly as the person typed it.</p>
<p><strong>Dots.</strong> Gmail ignores dots in the part before the @, but only on gmail.com and googlemail.com, which are the same inbox. Google's own help says this does not apply to Gmail for work or school, so on a company domain hosted by Google, j.smith and jsmith can be two different colleagues. Other providers treat dots as part of the name. Removing dots anywhere else would merge different people.</p>
<p><strong>Plus tags.</strong> Gmail and Outlook.com both treat everything after a <code>+</code> as a tag that still arrives in the same inbox. On someone's own domain, <code>jo+sales@example.co.uk</code> may be an entirely separate address, or the server may reject it. So strip tags only for providers that document the behaviour, and add to the list only after reading their help pages.</p>
<pre><code class="language-php">&lt;?php

namespace App\Support;

use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;

final class SignupKeys
{
    // Gmail ignores dots in the local part, but only on these two domains.
    private const DOTS_IGNORED = ['gmail.com', 'googlemail.com'];

    // Providers whose own help pages describe +tags. Add one only after reading them.
    private const PLUS_IS_TAG = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com'];

    public static function email(string $raw): ?string
    {
        $email = mb_strtolower(trim($raw));
        $at = strrpos($email, '@');
        if ($at === false || $at === 0 || $at === strlen($email) - 1) {
            return null;
        }

        $local = substr($email, 0, $at);
        $domain = substr($email, $at + 1);

        if (in_array($domain, self::PLUS_IS_TAG, true)) {
            $local = explode('+', $local, 2)[0];
        }
        if (in_array($domain, self::DOTS_IGNORED, true)) {
            $local = str_replace('.', '', $local);
            $domain = 'gmail.com';
        }

        return $local === '' ? null : $local . '@' . $domain;
    }

    public static function phone(string $raw, string $country): ?string
    {
        $util = PhoneNumberUtil::getInstance();
        try {
            $number = $util-&gt;parse($raw, strtoupper($country));
        } catch (NumberParseException) {
            return null;
        }

        return $util-&gt;isValidNumber($number)
            ? $util-&gt;format($number, PhoneNumberFormat::E164)
            : null;
    }
}
</code></pre>
<p><code>SignupKeys::email(' J.Doe+trial2@GMAIL.com ')</code> and <code>SignupKeys::email('j.doe@googlemail.com')</code> both return <code>jdoe@gmail.com</code>. <code>j.doe+x@outlook.com</code> becomes <code>j.doe@outlook.com</code>. <code>jo+sales@example.co.uk</code> and <code>j.smith@company.com</code> come back unchanged.</p>
<p><strong>Privacy relays stay allowed.</strong> Addresses such as <code>abc123@privaterelay.appleid.com</code> (Sign in with Apple), Firefox Relay's <code>mozmail.com</code> or DuckDuckGo's <code>duck.com</code> forward to an inbox the person keeps for years. The normaliser leaves them alone, and they shouldn't be on whatever disposable list you use either. Each relay address is unique, so someone can generate a second one; the phone key is what catches that.</p>
<h2>The phone key</h2>
<p>The same number turns up as <code>020 7946 0958</code>, <code>+44 20 7946 0958</code>, <code>0044 20 7946 0958</code> or <code>(020) 7946-0958</code>. A string comparison sees four people. giggsey/libphonenumber-for-php, the PHP port of Google's libphonenumber, parses all four with country <code>GB</code> and <code>SignupKeys::phone()</code> returns <code>+442079460958</code> for each. Inputs like <code>12345</code>, <code>0000000000</code> or <code>call me</code> return <code>null</code>.</p>
<p>Two notes. <code>isValidNumber()</code> checks the number against the numbering plan; it says nothing about whether anyone answers. 020 7946 0xxx is a range Ofcom sets aside for drama, which is why it's safe to print here, and it still counts as valid. More importantly, only use a phone number that has been confirmed with a one-time code before you build the key. Otherwise a new number per signup resets the free tier, and anyone could block a stranger's credits by typing that stranger's number.</p>
<h2>Claim the keys and grant credits in one transaction</h2>
<p>The unique indexes do the real work. Both keys and the credit row are written inside one transaction. If either key already belongs to another user, the database refuses the update, the transaction rolls back, and no credits are written.</p>
<pre><code class="language-php">&lt;?php

namespace App\Actions;

use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;

final class GrantTrial
{
    public const CREDITS = 500;

    /** Returns true if credits were granted. Call it right after the user row exists. */
    public static function for(int $userId, ?string $emailKey, ?string $phoneKey): bool
    {
        if ($emailKey === null || $phoneKey === null) {
            return false;
        }

        try {
            return DB::transaction(function () use ($userId, $emailKey, $phoneKey) {
                DB::table('users')-&gt;where('id', $userId)-&gt;update([
                    'signup_email_key' =&gt; $emailKey,
                    'signup_phone_key' =&gt; $phoneKey,
                ]);

                DB::table('credit_grants')-&gt;insert([
                    'user_id' =&gt; $userId,
                    'amount' =&gt; self::CREDITS,
                    'reason' =&gt; 'signup_trial',
                    'created_at' =&gt; now(),
                ]);

                return true;
            });
        } catch (UniqueConstraintViolationException) {
            // Another account already holds one of the keys. The transaction has
            // rolled back, so neither key nor credits were written for this user.
            return false;
        }
    }
}
</code></pre>
<p>Call it after the user row exists, for example in your registration action once the phone code has been confirmed:</p>
<pre><code class="language-php">$granted = GrantTrial::for(
    $user-&gt;id,
    SignupKeys::email($request-&gt;input('email')),
    SignupKeys::phone($request-&gt;input('phone'), $request-&gt;input('country')),
);

if (! $granted) {
    // Account created, free tier skipped. Say so plainly in the onboarding screen.
}
</code></pre>
<p><strong>Why not check first, then insert?</strong> Two requests can both run <code>exists()</code> at the same moment, both see nothing, and both grant credits. The unique index closes that gap: whichever <code>UPDATE</code> commits second gets a unique violation, which Laravel 10 and later raise as <code>Illuminate\Database\UniqueConstraintViolationException</code>. Catching that exception is the whole race handling.</p>
<p><strong>Why <code>DB::transaction</code> and not a bare update?</strong> The credit row must not exist without the keys, and the keys must not exist without the credit row. On PostgreSQL there is a second reason: a failed statement aborts the surrounding transaction, so the work has to roll back as a unit. If your registration code already runs inside a transaction, Laravel turns the inner <code>DB::transaction</code> into a savepoint, which rolls back only this part and leaves the new account in place.</p>
<p>If you'd rather keep claims in their own table, the same idea works with <code>DB::table('trial_claims')-&gt;insertOrIgnore([...])</code>, which returns the number of rows inserted. Grant credits only when it returns the number of keys you tried to insert, all inside the same transaction.</p>
<h2>What this won't stop</h2>
<p>Someone with a fresh confirmed phone number and a fresh inbox on a new domain gets a fresh free tier. That's acceptable for most products; the aim is to make repeat claims cost more than the credits are worth, not to make them impossible. It also does nothing about throwaway inbox services on their own; pair it with a domain check at signup if those show up in your data.</p>
<h2>Tested with</h2>
<p>PHP 8.4.26, giggsey/libphonenumber-for-php 9.0.41, illuminate/database 12.69.3 on SQLite (in memory). The test covered the email and phone normalisers, and the transaction against a table with the same unique columns: the first signup received credits; a second signup with the same Gmail address (dots and a plus tag added, on googlemail.com) and a different number received none; a third with a new address but the same number in international format received none; a fourth with both new received credits. All four accounts were created, two credit rows existed, and the rejected users kept <code>null</code> keys. The migration file above wasn't run through <code>artisan migrate</code>; the test built the same columns with the schema builder.</p>
<p>I built Orisift, which checks the email domain, phone and IP at signup and returns a separate result for each, so your registration code can decide what to do with throwaway domains and unusual numbers: <a href="https://orisift.com/x?m=post&amp;c=hashnode-laravel-trial&amp;to=%2F">https://orisift.com/x?m=post&amp;c=hashnode-laravel-trial&amp;to=%2F</a></p>
]]></content:encoded></item><item><title><![CDATA[Privacy aliases are not disposable email addresses]]></title><description><![CDATA[A common bit of signup hygiene is a list of disposable email domains: Mailinator, 10 Minute Mail and the thousands of lookalikes that hand out an inbox nobody owns and that disappears within the hour.]]></description><link>https://elijahbrown.hashnode.dev/privacy-aliases-not-disposable</link><guid isPermaLink="true">https://elijahbrown.hashnode.dev/privacy-aliases-not-disposable</guid><category><![CDATA[email]]></category><category><![CDATA[privacy]]></category><category><![CDATA[Laravel]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Elijah Brown]]></dc:creator><pubDate>Sun, 11 Oct 2026 14:31:43 GMT</pubDate><content:encoded><![CDATA[<p>A common bit of signup hygiene is a list of disposable email domains: Mailinator, 10 Minute Mail and the thousands of lookalikes that hand out an inbox nobody owns and that disappears within the hour. Blocking those is reasonable. The mistake is letting that list, or a homemade regex, sweep up privacy aliases as well. They look odd, they hide the person's main address, and they come from a domain the person didn't choose, but they behave very differently.</p>
<h2>What the common ones look like</h2>
<p><strong>Apple Hide My Email.</strong> When someone uses Sign in with Apple and chooses to hide their address, Apple creates a unique address at <code>@privaterelay.appleid.com</code> for your app, and it forwards to the address on their Apple Account. Apple's developer documentation says relay addresses end in <code>@privaterelay.appleid.com</code>, <code>@private.icloud.com</code> or <code>@icloud.com</code>. iCloud+ subscribers can also create unique, random Hide My Email addresses for any form, and those forward the same way.</p>
<p><strong>Firefox Relay.</strong> Mozilla's masks use the <code>mozmail.com</code> domain (older ones used <code>relay.firefox.com</code>). Premium subscribers get their own subdomain and can make up masks on the spot, such as <code>shop@yourdomain.mozmail.com</code>.</p>
<p><strong>SimpleLogin.</strong> Run by Proton these days. It offers several shared alias domains, including <code>aleeas.com</code> and <code>simplelogin.co</code>, and paid users can bring their own custom domain, which you cannot list at all.</p>
<p><strong>DuckDuckGo Email Protection.</strong> People get a personal Duck Address such as <code>name@duck.com</code>, and can generate private ones such as <code>amaze-gem-spider@duck.com</code>. Each private address can be switched off on its own.</p>
<p><strong>Fastmail Masked Email.</strong> A unique, automatically generated address, created at <code>fastmail.com</code> or at a custom domain the person has set up in their Fastmail account.</p>
<p><strong>Proton Pass.</strong> Hide-my-email aliases are randomised addresses that forward to the person's Proton inbox, and paid users can create them on their own domains. Proton's help page doesn't list the shared domains, so I've left them out of the code below. The same page calls these "throwaway" addresses, yet by its own description they keep forwarding until the person disables or deletes them.</p>
<h2>How they differ from throwaway inboxes</h2>
<p>The useful question isn't "does this address hide something?" but "will the person still be able to receive mail here next month?"</p>
<ul>
<li><strong>Lifetime.</strong> A relay alias lasts until the person turns it off, usually years. A throwaway inbox is gone in minutes or hours.</li>
<li><strong>Ownership.</strong> A relay alias belongs to one account holder, who signed in to create it. A throwaway inbox is often readable by anyone who types the same name.</li>
<li><strong>Receiving.</strong> Mail sent to a relay alias lands in a long-lived personal inbox. Password resets, receipts and account notices work months later.</li>
<li><strong>Intent.</strong> People use aliases precisely because they plan to stay but want to control who can email them, and to switch an address off if it starts getting spam. That is the opposite of someone passing through.</li>
</ul>
<p>If you block aliases, the people you turn away are the careful ones: those who read privacy settings, pay for iCloud+ or Proton, and use a password manager that offers an alias by default. Sign in with Apple is worse still, because the person may never have typed the address. Apple handed it to you, and rejecting it breaks a login button you offered.</p>
<h2>What the CC0 disposable list does</h2>
<p>The most widely reused source is the public domain (CC0) <code>disposable-email-domains/disposable-email-domains</code> repository on GitHub. I checked its <code>disposable_email_blocklist.conf</code> on 11 October 2026. It had 9,222 entries, and none of these were among them: <code>privaterelay.appleid.com</code>, <code>private.icloud.com</code>, <code>icloud.com</code>, <code>mozmail.com</code>, <code>relay.firefox.com</code>, <code>aleeas.com</code>, <code>simplelogin.co</code>, <code>duck.com</code>, <code>fastmail.com</code>. <code>mailinator.com</code> is on it, as you would expect.</p>
<p>The repository used to ship an <code>allowlist.conf</code> as well. It was removed on 12 April 2026 (commit <code>0bccfe3</code>, "remove allowlist and the attached logic"), with the maintainers describing it as an unmaintained list. Just before removal it had 189 entries, including <code>mozmail.com</code> and <code>fastmail.com</code>, but not the Apple, SimpleLogin or DuckDuckGo domains above. So if your code still fetches <code>allowlist.conf</code>, it now gets a 404, and you need your own.</p>
<p>Two more things worth knowing. The blocklist does contain lookalike domains such as <code>domozmail.com</code> and <code>duck2.club</code>, so a substring match on <code>mozmail.com</code> or <code>duck</code> would treat a listed throwaway domain as a relay, or the other way round. And other lists, especially older or hand-maintained ones, do include relay domains. Check whatever list you use before trusting it.</p>
<h2>Practical guidance</h2>
<ol>
<li>Keep a small relay allowlist of your own and check it before the blocklist.</li>
<li>Match on the exact domain or a dot-separated suffix (<code>janedoe.mozmail.com</code> ends with <code>.mozmail.com</code>), never by substring.</li>
<li>Treat custom domains as ordinary domains. You can't tell a SimpleLogin custom domain from a small business, and you don't need to.</li>
<li>If you need to recognise the same person across sign-ins, key it on the provider's user ID (Apple's <code>sub</code> claim), not on the email string.</li>
<li>Apple's relay only forwards mail from sending addresses or domains you have registered for Sign in with Apple. Register yours, or your emails to those users will not arrive.</li>
<li>Add the shared domains of any other alias service you see in your own signup data, after checking the provider's documentation.</li>
</ol>
<p>In Laravel, a rule:</p>
<pre><code class="language-php">&lt;?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Str;

class NotThrowawayEmail implements ValidationRule
{
    private const RELAYS = [
        'privaterelay.appleid.com', 'private.icloud.com', 'icloud.com',
        'mozmail.com', 'relay.firefox.com', 'duck.com',
        'aleeas.com', 'simplelogin.co', 'fastmail.com',
    ];

    public function __construct(private array $blocklist) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $domain = Str::lower(Str::afterLast((string) $value, '@'));

        if (self::matches($domain, self::RELAYS)) {
            return; // a privacy alias, keep it
        }

        if (self::matches($domain, $this-&gt;blocklist)) {
            $fail('Please use an address you will still have next month.');
        }
    }

    private static function matches(string $domain, array $list): bool
    {
        foreach ($list as $entry) {
            if ($domain === $entry || str_ends_with($domain, '.' . $entry)) {
                return true;
            }
        }
        return false;
    }
}
</code></pre>
<p>Load the blocklist once (from a cached copy of the CC0 file) and bind it in a service provider, then use <code>['required', 'email:rfc', app(NotThrowawayEmail::class)]</code>.</p>
<p>In Node the same idea:</p>
<pre><code class="language-js">const RELAYS = new Set([
  "privaterelay.appleid.com", "private.icloud.com", "icloud.com",
  "mozmail.com", "relay.firefox.com", "duck.com",
  "aleeas.com", "simplelogin.co", "fastmail.com",
]);

function inList(domain, set) {
  const parts = domain.split(".");
  for (let i = 0; i &lt; parts.length - 1; i++) {
    if (set.has(parts.slice(i).join("."))) return true;
  }
  return false;
}

export function classifyEmailDomain(email, blocklist) {
  const domain = email.slice(email.lastIndexOf("@") + 1).toLowerCase();
  if (inList(domain, RELAYS)) return "relay";
  if (inList(domain, blocklist)) return "throwaway";
  return "ordinary";
}
</code></pre>
<p>Review the relay list every few months. Providers add domains, and the cost of a stale entry is a careful person seeing an error.</p>
<p>I built Orisift, which flags domains on known throwaway lists. Relay services aren't something it calls out separately, so I keep a relay allowlist like the one above in front of it. The docs are here: <a href="https://orisift.com/x?m=post&amp;c=hashnode-privacy-aliases&amp;to=%2Fdocs">https://orisift.com/x?m=post&amp;c=hashnode-privacy-aliases&amp;to=%2Fdocs</a></p>
]]></content:encoded></item><item><title><![CDATA[A DNS timeout is not an invalid email address]]></title><description><![CDATA[Laravel makes it very easy to check that an email address has a working domain behind it. You add email:rfc,dns to a rule and move on. Most of the time that is fine. The trouble is the small number of]]></description><link>https://elijahbrown.hashnode.dev/dns-timeout-not-invalid-email</link><guid isPermaLink="true">https://elijahbrown.hashnode.dev/dns-timeout-not-invalid-email</guid><category><![CDATA[Laravel]]></category><category><![CDATA[PHP]]></category><category><![CDATA[email]]></category><category><![CDATA[dns]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Elijah Brown]]></dc:creator><pubDate>Sun, 11 Oct 2026 14:29:34 GMT</pubDate><content:encoded><![CDATA[<p>Laravel makes it very easy to check that an email address has a working domain behind it. You add <code>email:rfc,dns</code> to a rule and move on. Most of the time that is fine. The trouble is the small number of times the DNS lookup does not get an answer at all, because the rule treats "I couldn't find out" exactly like "this domain does not exist". On a signup form, that means a person with a perfectly normal address sees "The email field must be a valid email address" because a resolver had a slow second.</p>
<p>This is a look at what the rule does, line by line, and a pattern for treating the three different outcomes differently.</p>
<h2>What <code>email:dns</code> does</h2>
<p>The rule lives in <code>Illuminate\Validation\Concerns\ValidatesAttributes::validateEmail()</code> (lines 940 to 967 on the 12.x branch). It maps each option to a class from the egulias/email-validator package. The <code>dns</code> option becomes <code>new DNSCheckValidation()</code> (line 954), and all the chosen checks are combined with <code>MultipleValidationWithAnd</code>, so any single failure fails the whole rule.</p>
<p>Everything below was read from Laravel 12.x, egulias/email-validator 4.x and PHP 8.4 on Linux. Older versions may differ in the details.</p>
<p>The interesting part is in <code>DNSCheckValidation</code> (4.x branch):</p>
<ol>
<li><p><code>isValid()</code> takes everything after the last <code>@</code>. If the domain has a single label, or its last label is on the reserved list (<code>test</code>, <code>example</code>, <code>invalid</code>, <code>localhost</code>, <code>local</code>, <code>intranet</code>, <code>internal</code>, <code>private</code>, <code>corp</code>, <code>home</code>, <code>lan</code>), it fails straight away with <code>LocalOrReservedDomain</code> (lines 88 to 95). No DNS query is made. So <code>someone@shop.invalid</code> is rejected without touching the network.</p>
</li>
<li><p><code>checkDns()</code> converts the domain to ASCII and then walks it from the right. For <code>a@mail.shop.co.uk</code> it tries <code>co.uk</code>, then <code>shop.co.uk</code>, then <code>mail.shop.co.uk</code>, and returns true as soon as one level passes (lines 118 to 136). A side effect worth knowing: <code>anything@made-up-name.gmail.com</code> passes, because <code>gmail.com</code> is checked first and passes.</p>
</li>
<li><p><code>validateDnsRecords()</code> asks for A and MX records together (<code>DNS_A + DNS_MX</code>, line 148). If that query errors, the result is <code>UnableToGetDNSRecord</code> and the check fails (lines 150 to 153). It then asks for AAAA separately and quietly ignores an error there (lines 157 to 162).</p>
</li>
<li><p>If there are no A, AAAA or MX records at all, it fails with <code>NoDNSRecord</code> (lines 165 to 168).</p>
</li>
<li><p>Each record goes through <code>validateMxRecord()</code>. Non-MX records pass, so a domain with only an A record is accepted, matching the implicit MX fallback in RFC 5321. An MX record whose target is empty or <code>.</code> is a Null MX, and fails with <code>DomainAcceptsNoMail</code> (lines 202 to 205).</p>
</li>
</ol>
<p>The query itself goes through <code>DNSGetRecordWrapper::getRecords()</code>, which calls PHP's <code>dns_get_record()</code> with an error handler that turns any warning into an exception and then into an "error" result.</p>
<p>So what does <code>dns_get_record()</code> do in each case? In php-src (<code>ext/standard/dns.c</code>, PHP 8.4), <code>HOST_NOT_FOUND</code> and <code>NO_DATA</code> are skipped with <code>continue</code>, so NXDOMAIN gives an empty array. <code>TRY_AGAIN</code> raises the warning "A temporary server error occurred." and <code>NO_RECOVERY</code> raises "An unexpected server failure occurred.", and the function returns false. On Linux, where PHP asks the system resolver library, a timeout or a SERVFAIL normally comes back as TRY_AGAIN, so it lands in the first of those branches. Other platforms and resolver set-ups can behave differently, so it is worth testing yours.</p>
<p>Putting it together:</p>
<table>
<thead>
<tr>
<th>What DNS said</th>
<th>What the rule reports</th>
<th>Rule result</th>
</tr>
</thead>
<tbody><tr>
<td>NXDOMAIN</td>
<td><code>NoDNSRecord</code></td>
<td>fails</td>
</tr>
<tr>
<td>No MX, but an A or AAAA record</td>
<td>accepted</td>
<td>passes</td>
</tr>
<tr>
<td>Null MX (<code>0 .</code>)</td>
<td><code>DomainAcceptsNoMail</code></td>
<td>fails</td>
</tr>
<tr>
<td>SERVFAIL or timeout</td>
<td><code>UnableToGetDNSRecord</code></td>
<td>fails</td>
</tr>
</tbody></table>
<p>The last row is the problem. Inside egulias the reasons are distinct, but Laravel only sees a boolean, so all three failures produce the same message.</p>
<h2>Three different answers</h2>
<p>They mean different things and deserve different responses.</p>
<p><strong>The domain does not exist.</strong> NXDOMAIN is an authoritative answer. Nobody can receive mail there today. Very often it is a typo, <code>gmial.com</code> or <code>hotmial.co.uk</code>. Ask the person to correct it.</p>
<p><strong>The domain says it takes no mail.</strong> A Null MX record, defined in RFC 7505, is the domain owner stating that no mail should be sent there. <code>example.com</code> publishes one. This is also an authoritative answer, and the right response is the same: ask for a different address, ideally saying why.</p>
<p><strong>We couldn't find out.</strong> A timeout or SERVFAIL tells you about the path between your server and the domain's name servers, not about the address. Resolvers time out, upstream servers have bad minutes, DNSSEC misconfigurations come and go. Rejecting here turns a temporary problem on your side into a lost signup. Accept the address, mark it as unconfirmed, and check again later.</p>
<h2>A Laravel pattern</h2>
<p>The plain PHP part needs a lookup that tells the outcomes apart and has a timeout you control. <code>dns_get_record()</code> cannot report the status code, and its timeout comes from the system resolver settings (<code>options timeout:</code> and <code>attempts:</code> in <code>/etc/resolv.conf</code>). One way round both is a DNS over HTTPS JSON endpoint, which returns the DNS status as a number (0 for NOERROR, 2 for SERVFAIL, 3 for NXDOMAIN).</p>
<pre><code class="language-php">&lt;?php

namespace App\Support;

class MailDomainCheck
{
    public static function classify(string $domain, float $timeout = 3.0): string
    {
        $mx = self::query($domain, 'MX', $timeout);
        if ($mx['status'] === null) return 'unknown';      // timeout or network error
        if ($mx['status'] === 3)    return 'does_not_exist'; // NXDOMAIN
        if ($mx['status'] !== 0)    return 'unknown';      // SERVFAIL and friends

        $records = array_filter($mx['answers'], fn ($r) =&gt; $r['type'] === 15);
        foreach ($records as $r) {
            if (preg_match('/^0\s+\.$/', trim($r['data']))) return 'accepts_no_mail';
        }
        if ($records !== []) return 'ok';

        $a = self::query($domain, 'A', $timeout);
        if ($a['status'] === null) return 'unknown';
        $aaaa = self::query($domain, 'AAAA', $timeout);
        $addresses = array_filter(
            array_merge($a['answers'], $aaaa['answers']),
            fn ($r) =&gt; in_array($r['type'], [1, 28], true)
        );

        return $addresses ? 'ok' : 'does_not_exist';
    }

    private static function query(string $name, string $type, float $timeout): array
    {
        $url = 'https://dns.google/resolve?' . http_build_query(['name' =&gt; $name, 'type' =&gt; $type]);
        $context = stream_context_create(['http' =&gt; [
            'timeout' =&gt; $timeout,
            'ignore_errors' =&gt; true,
            'header' =&gt; "Accept: application/dns-json\r\n",
        ]]);
        $body = @file_get_contents($url, false, $context);
        if ($body === false) {
            return ['status' =&gt; null, 'answers' =&gt; []];
        }
        $json = json_decode($body, true);

        return ['status' =&gt; $json['Status'] ?? null, 'answers' =&gt; $json['Answer'] ?? []];
    }
}
</code></pre>
<p>Calling <code>MailDomainCheck::classify()</code> from the command line against a few known domains gave:</p>
<pre><code class="language-plaintext">gmail.com                  ok
example.com                accepts_no_mail
no-such-name-7f3a9q.com    does_not_exist
neverssl.com               ok   (no MX, has an A record)
dnssec-failed.org          unknown
gmail.com, 1 ms timeout    unknown
</code></pre>
<p><code>dnssec-failed.org</code> is deliberately broken, so a validating resolver returns SERVFAIL for it, which makes it a handy test case. If your company already runs its own resolver, swap the HTTPS call for something like <code>Net_DNS2</code> with a short timeout; the important part is keeping "unknown" separate.</p>
<p>Then a rule that only fails on the two authoritative answers:</p>
<pre><code class="language-php">&lt;?php

namespace App\Rules;

use App\Support\MailDomainCheck;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Str;

class MailDomainAcceptsMail implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $domain = Str::lower(Str::afterLast((string) $value, '@'));

        match (MailDomainCheck::classify($domain)) {
            'does_not_exist'  =&gt; $fail('We could not find the domain :input. Is there a typo?'),
            'accepts_no_mail' =&gt; $fail('That domain does not accept email. Please use another address.'),
            default           =&gt; null,
        };
    }
}
</code></pre>
<p>Use it as <code>['required', 'email:rfc', new MailDomainAcceptsMail]</code>, leaving out <code>dns</code> so the two checks don't disagree. After creating the user, store whether the domain answered and queue a recheck when it didn't:</p>
<pre><code class="language-php">&lt;?php

namespace App\Jobs;

use App\Models\User;
use App\Support\MailDomainCheck;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Str;

class RecheckMailDomain implements ShouldQueue
{
    use Queueable;

    public $tries = 4;
    public $backoff = [600, 3600, 21600];

    public function __construct(public User $user) {}

    public function handle(): void
    {
        $result = MailDomainCheck::classify(Str::afterLast($this-&gt;user-&gt;email, '@'));

        if ($result === 'unknown') {
            $this-&gt;release($this-&gt;backoff[$this-&gt;attempts() - 1] ?? 21600);
            return;
        }

        $this-&gt;user-&gt;forceFill(['email_domain_status' =&gt; $result])-&gt;save();
    }
}
</code></pre>
<p>What you do with a final <code>does_not_exist</code> is a product decision. Usually it is a banner asking the person to update their address, not a deleted account.</p>
<h2>Takeaway</h2>
<p>Treat DNS results as three answers, not two. "It doesn't exist" and "it takes no mail" are reasons to ask for a correction. "We couldn't tell" is a reason to accept the address and look again later.</p>
<p>I built Orisift, which returns these as separate findings (including which DNS answers it could not get) so a form can choose to continue, ask for a correction or retry. The docs are here: <a href="https://orisift.com/x?m=post&amp;c=hashnode-laravel-dns&amp;to=%2Fdocs">https://orisift.com/x?m=post&amp;c=hashnode-laravel-dns&amp;to=%2Fdocs</a></p>
]]></content:encoded></item><item><title><![CDATA[Stop free trial reuse at signup: an email key and a phone key in Node]]></title><description><![CDATA[If your app gives free credits or a free trial at signup, some people will collect it more than once. The usual route is cheap: sign up again as j.doe+2@gmail.com, then jdoe@googlemail.com, then a thr]]></description><link>https://elijahbrown.hashnode.dev/stop-free-trial-reuse-at-signup-email-key-phone-key-node</link><guid isPermaLink="true">https://elijahbrown.hashnode.dev/stop-free-trial-reuse-at-signup-email-key-phone-key-node</guid><category><![CDATA[Node.js]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[supabase]]></category><category><![CDATA[PostgreSQL]]></category><dc:creator><![CDATA[Elijah Brown]]></dc:creator><pubDate>Thu, 08 Oct 2026 10:39:40 GMT</pubDate><content:encoded><![CDATA[<p>If your app gives free credits or a free trial at signup, some people will collect it more than once. The usual route is cheap: sign up again as <a href="mailto:j.doe+2@gmail.com">j.doe+2@gmail.com</a>, then <a href="mailto:jdoe@googlemail.com">jdoe@googlemail.com</a>, then a throwaway address, sometimes deleting the old account first, with the same phone number each time. Your app sees new people. Your costs see the same one.</p>
<p>You can't stop someone from creating accounts, and you shouldn't try too hard. What you can control is who gets the free credits. This post builds two matching keys, one for the email and one for the phone, records both in one Postgres insert, and grants credits only when both are new. Everything below was run on Node 20 and Postgres 17.</p>
<h2>The email key</h2>
<p>The key is only for matching. Keep sending mail to the address exactly as the person typed it.</p>
<p>Two rules decide what goes into the key. Gmail ignores dots before the @, but only on gmail.com and googlemail.com. Google's help page says dots do change the address for Gmail used through work or school, so <a href="mailto:j.smith@company.com">j.smith@company.com</a> and <a href="mailto:jsmith@company.com">jsmith@company.com</a> can be two colleagues even if Google hosts their mail.</p>
<p>Plus tags need the same care. Gmail and Outlook.com treat everything after a + as a tag, but on a company's own domain jo+sales can be someone else's address. So strip the tag only for providers that document it, and keep a short list you add to after reading their docs.</p>
<pre><code class="language-js">// email-key.js
// Gmail ignores dots, but only on these two domains.
const DOTS_IGNORED = new Set(['gmail.com', 'googlemail.com'])

// Providers that document +tags as tags. Add one only after reading its docs.
const PLUS_IS_TAG = new Set(['gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com'])

export function emailKey(raw) {
  const email = String(raw).trim().toLowerCase()
  const at = email.lastIndexOf('@')
  if (at &lt; 1 || at === email.length - 1) return null

  let local = email.slice(0, at)
  let domain = email.slice(at + 1)

  if (PLUS_IS_TAG.has(domain)) local = local.split('+')[0]
  if (DOTS_IGNORED.has(domain)) {
    local = local.replaceAll('.', '')
    domain = 'gmail.com'
  }
  return local ? local + '@' + domain : null
}
</code></pre>
<p>That turns " <a href="mailto:J.Doe+trial2@GMAIL.com">J.Doe+trial2@GMAIL.com</a> " and "<a href="mailto:j.doe@googlemail.com">j.doe@googlemail.com</a>" into <a href="mailto:jdoe@gmail.com">jdoe@gmail.com</a>, and leaves <a href="mailto:j.doe@outlook.com">j.doe@outlook.com</a> and <a href="mailto:jo+sales@example.co.uk">jo+sales@example.co.uk</a> as they are.</p>
<h2>The phone key</h2>
<p>The same number arrives in many shapes: 020 7946 0958, +44 20 7946 0958, 0044 20 7946 0958, (020) 7946-0958. A plain string match treats those as four people. Parse with libphonenumber-js and store the E.164 form:</p>
<pre><code class="language-js">// phone-key.js
import { parsePhoneNumberFromString } from 'libphonenumber-js'

// country is the ISO code the person picked in your form, e.g. 'GB'
export function phoneKey(input, country) {
  const phone = parsePhoneNumberFromString(String(input), country)
  return phone?.isValid() ? phone.number : null
}
</code></pre>
<p>With country GB, all four of those inputs return +442079460958, and input such as 12345, 0000000000 or "call me" returns null. (020 7946 0xxx is a range Ofcom sets aside for drama, so it makes a safe example. It still passes isValid, which is about the numbering plan, not about whether anyone answers.)</p>
<p>One condition matters more than the code: only use a phone number that has been confirmed with a one-time code. If anyone can type any valid number, a new number per signup resets the trial, and someone could block a stranger's trial by typing theirs.</p>
<h2>Claim both keys in one insert</h2>
<p>Claims live in their own table, apart from the account, so deleting an account doesn't release them. One row per key, with the key as the primary key:</p>
<pre><code class="language-sql">create table public.trial_claims (
  claim_key  text primary key,
  kind       text not null check (kind in ('email', 'phone')),
  user_id    uuid not null,
  claimed_at timestamptz not null default now()
);
alter table public.trial_claims enable row level security;

create function public.claim_trial(p_user uuid, p_email_key text, p_phone_key text, p_credits int)
returns boolean
language sql
begin atomic
  with wanted (claim_key, kind) as (
    values (p_email_key, 'email'), (p_phone_key, 'phone')
  ),
  inserted as (
    insert into public.trial_claims (claim_key, kind, user_id)
    select claim_key, kind, p_user from wanted where claim_key is not null
    on conflict (claim_key) do nothing
    returning claim_key
  ),
  granted as (
    update public.profiles set credits = credits + p_credits
    where id = p_user and (select count(*) from inserted) = 2
    returning id
  )
  select exists (select 1 from granted);
end;

revoke execute on function public.claim_trial(uuid, text, text, int) from public, anon, authenticated;
grant execute on function public.claim_trial(uuid, text, text, int) to service_role;
</code></pre>
<p>Row level security is on with no policies, so nothing in the browser can read or write the table. The insert tries both rows and skips any key that already exists. The update adds credits only if the count of inserted rows is 2, and it runs in the same statement, so the claim and the credits succeed or fail together.</p>
<p>The rule "credits only if both rows went in" means any old key loses:</p>
<ul>
<li>new email, new phone: both rows go in, credits granted</li>
<li>old email, new phone: only the phone row goes in, no credits</li>
<li>new email, old phone: only the email row goes in, no credits</li>
<li>the same user calling again: nothing goes in, no second grant</li>
</ul>
<p>The partial cases still record the new key. That's deliberate: the new address now belongs to someone who has had a trial, so pairing it with yet another phone later won't work either.</p>
<p>I ran those cases against Postgres 17, plus 50 rounds of three concurrent claims sharing one email. Every round granted credits exactly once, because the primary key makes the second insert wait for the first and then skip.</p>
<p>If you don't collect phone numbers, drop the phone row and compare the count with 1. The function also expects a profiles row for the user, which most Supabase apps create with a trigger on signup.</p>
<h2>Two kinds of domain to refuse</h2>
<p>A throwaway inbox can receive your confirmation email, so it passes that step. Check the domain against a list instead, and either refuse it at signup or, as in the route further down, create the account without credits. The disposable-email-domains project on GitHub keeps one in disposable_email_blocklist.conf under CC0, so you can ship it in your repo. Pin it to a commit so it only changes when you update it on purpose. The list holds registrable domains, so check each parent of a subdomain too:</p>
<pre><code class="language-js">// throwaway.js
import { readFileSync } from 'node:fs'

// disposable_email_blocklist.conf from github.com/disposable-email-domains, pinned to a commit
const BLOCKLIST = new Set(
  readFileSync(new URL('./disposable_email_blocklist.conf', import.meta.url), 'utf8')
    .split('\n').map((line) =&gt; line.trim().toLowerCase()).filter(Boolean)
)

// The list holds registrable domains, so also check each parent of a subdomain
export function isThrowaway(domain) {
  const labels = domain.toLowerCase().split('.')
  for (let i = 0; i &lt; labels.length - 1; i++) {
    if (BLOCKLIST.has(labels.slice(i).join('.'))) return true
  }
  return false
}
</code></pre>
<p>A domain with a Null MX record (RFC 7505) says it accepts no email at all, so your confirmation email has nowhere to go. Check it on the server when the address is submitted, and return a field error the person can fix while they're still on the page:</p>
<pre><code class="language-js">// null-mx.js
import { resolveMx } from 'node:dns/promises'

// RFC 7505: one MX record, preference 0, exchange "." (Node may report it as '')
export async function hasNullMx(domain, resolve = resolveMx) {
  const records = await resolve(domain).catch(() =&gt; [])
  return records.length === 1 &amp;&amp; records[0].priority === 0 &amp;&amp;
    ['', '.'].includes(records[0].exchange)
}
</code></pre>
<p>The resolver is a parameter so you can test it without live DNS. I covered the other DNS cases (no MX record at all, and lookups that time out) in <a href="https://elijahbrown.hashnode.dev/half-your-waitlist-is-bots-three-server-side-checks-in-node">an earlier post on waitlist bots</a>.</p>
<h2>Create the account, skip the credits</h2>
<p>When a key matches, don't refuse the signup. The person may be a returning customer, a colleague on a shared phone, or someone who deleted an account by mistake. Create the account, skip the credits, and say so plainly. A clear sentence is kinder than a vague error, and someone farming trials gets nothing from it.</p>
<h2>Where this runs in Supabase</h2>
<p>In a Supabase app, run the claim in a server route or an Edge Function, never in the browser. The anon key ships to every visitor, so anything the client can call, a script can call too. That's why the SQL above revokes execute from anon and authenticated, and only the service role can call it:</p>
<pre><code class="language-js">// claim-trial.js: server only (route handler, Express, Edge Function)
import { createClient } from '@supabase/supabase-js'
import { emailKey } from './email-key.js'
import { phoneKey } from './phone-key.js'
import { isThrowaway } from './throwaway.js'

const admin = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY)
const TRIAL_CREDITS = 100

export async function claimTrial(accessToken) {
  const { data, error } = await admin.auth.getUser(accessToken)
  const user = data?.user
  if (error || !user?.email_confirmed_at || !user.phone_confirmed_at) {
    return { status: 403, message: 'Confirm your email address and phone number first.' }
  }

  const domain = user.email.split('@').pop().toLowerCase()
  if (isThrowaway(domain)) {
    return { status: 200, credits: 0, message: 'Your account is ready. Trial credits need an email address you will keep, so change it in settings to claim them.' }
  }

  const { data: granted, error: rpcError } = await admin.rpc('claim_trial', {
    p_user: user.id,
    p_email_key: emailKey(user.email),
    p_phone_key: phoneKey('+' + user.phone.replace(/^\+/, '')),
    p_credits: TRIAL_CREDITS,
  })
  if (rpcError) throw rpcError

  if (granted) return { status: 200, credits: TRIAL_CREDITS, message: 'Your account is ready, with your trial credits.' }
  return { status: 200, credits: 0, message: 'Your account is ready. This email address or phone number has already had a free trial, so this account starts without trial credits.' }
}
</code></pre>
<p>Call it once, after the person has confirmed their email and phone. In an Edge Function the same logic works with npm: imports and keys from Deno.env, and the blocklist can be bundled as a module instead of read from disk.</p>
<h2>What this won't stop</h2>
<p>It won't stop one person with several genuine Gmail accounts and several confirmed phone numbers. Each of those is a new key, and nothing here can tell that they belong to the same person. It raises the cost of a second trial from a few seconds to a new inbox and a new SIM, which is usually enough for a free tier.</p>
<p>Lowercasing the whole address is a practical choice too. The part before the @ is case sensitive in theory, but almost no provider treats it that way.</p>
<h2>I built Orisift</h2>
<p>I built Orisift to screen the phone, email domain, IP and domain parts of a signup, with a separate result for each field, through POST /v1/lookup or POST /v1/signup. It flags known throwaway domains, Null MX and reserved phone ranges such as the drama range above. It does not tell you the carrier, line type or whether a number is VoIP, whether a mailbox exists or mail will arrive, whether an IP is a VPN, and it does no KYC.</p>
<p>There's a free checker on the homepage with no account needed: <a href="https://orisift.com/x?m=post&amp;c=hashnode-trial-reuse&amp;to=%2F">https://orisift.com/x?m=post&amp;c=hashnode-trial-reuse&amp;to=%2F</a></p>
<p>Signing up gives 500 free credits once, and no card is needed. The Node SDK, @orisift/sdk, includes a Supabase Auth helper: <a href="https://orisift.com/x?m=post&amp;c=hashnode-trial-reuse-sdk&amp;to=%2Fdocs%23sdk">https://orisift.com/x?m=post&amp;c=hashnode-trial-reuse-sdk&amp;to=%2Fdocs%23sdk</a></p>
]]></content:encoded></item><item><title><![CDATA[Half your waitlist is bots? Three server-side checks in Node]]></title><description><![CDATA[You open your waitlist table and something is off. Rows arrive in bursts, half of them are the same Gmail address with different dots and plus tags, and a fair number sit on domains that don't exist. ]]></description><link>https://elijahbrown.hashnode.dev/half-your-waitlist-is-bots-three-server-side-checks-in-node</link><guid isPermaLink="true">https://elijahbrown.hashnode.dev/half-your-waitlist-is-bots-three-server-side-checks-in-node</guid><category><![CDATA[Node.js]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Elijah Brown]]></dc:creator><pubDate>Wed, 07 Oct 2026 11:49:08 GMT</pubDate><content:encoded><![CDATA[<p>You open your waitlist table and something is off. Rows arrive in bursts, half of them are the same Gmail address with different dots and plus tags, and a fair number sit on domains that don't exist. If you're about to quote that number to anyone, it's worth cleaning up first.</p>
<p>This post covers three checks in plain Node and Postgres, plus a rate limit. Every snippet below runs on Node 20 with ES modules (<code>"type": "module"</code> in package.json).</p>
<h2>Why the checks have to live on the server</h2>
<p>Your form is not the gate. A signup form is HTML and JavaScript that ends in a request to an endpoint, and anyone can send that request without the form. Bots usually do exactly that: they read the network tab once, then post straight to <code>/api/waitlist</code> in a loop. Client-side validation, hidden honeypot fields and disabled buttons never run.</p>
<p>So whatever decides "this counts" has to run in the handler that receives the request. Everything below does.</p>
<h2>1. Normalise the email before the unique check</h2>
<p>A unique constraint on the raw email doesn't stop much. <code>J.Doe+7@GMAIL.com</code>, <code>jdoe@gmail.com</code> and <code>j.doe@googlemail.com</code> are three different strings and, for Gmail, one inbox. So store a second column, a matching key, and put the unique index on that.</p>
<p>The rules:</p>
<ul>
<li>trim whitespace and lowercase the whole address</li>
<li>drop everything from the first <code>+</code> in the part before the <code>@</code></li>
<li>remove dots only for <code>gmail.com</code> and <code>googlemail.com</code> (other providers treat dots as significant, so <code>a.b@outlook.com</code> and <code>ab@outlook.com</code> stay different)</li>
<li>map <code>googlemail.com</code> to <code>gmail.com</code></li>
</ul>
<pre><code class="language-js">// email-key.js
export function emailKey(raw) {
  const email = String(raw).trim().toLowerCase()
  const at = email.lastIndexOf('@')
  if (at &lt; 1 || at === email.length - 1) return null

  let local = email.slice(0, at).split('+')[0]
  let domain = email.slice(at + 1)

  if (domain === 'googlemail.com') domain = 'gmail.com'
  if (domain === 'gmail.com') local = local.replace(/\./g, '')

  return local ? `${local}@${domain}` : null
}
</code></pre>
<p>Test it with the built-in runner (<code>node --test</code>):</p>
<pre><code class="language-js">// email-key.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { emailKey } from './email-key.js'

test('emailKey', () =&gt; {
  assert.equal(emailKey(' J.Doe+7@GMAIL.com '), 'jdoe@gmail.com')
  assert.equal(emailKey('j.doe@googlemail.com'), 'jdoe@gmail.com')
  assert.equal(emailKey('a.b+x@outlook.com'), 'a.b@outlook.com')
  assert.equal(emailKey('+x@gmail.com'), null)
  assert.equal(emailKey('no-at-sign'), null)
})
</code></pre>
<p>Use the key only for matching. Keep the address as typed for sending, because that's what the person gave you.</p>
<pre><code class="language-sql">-- schema.sql
create table waitlist (
  id           bigint generated always as identity primary key,
  email        text not null,   -- as typed, used for sending
  email_key    text not null,   -- from emailKey(), used for matching
  status       text not null default 'pending'
               check (status in ('pending', 'confirmed')),
  token_hash   text,
  created_at   timestamptz not null default now(),
  confirmed_at timestamptz
);

create unique index waitlist_email_key_idx on waitlist (email_key);
</code></pre>
<p>Now <code>j.doe+7</code> stops counting twice, and the database enforces it even if two requests race.</p>
<h2>2. Drop domains that can't take mail</h2>
<p>If the domain has no mail setup, a confirmation email has nowhere to go, so there's no point storing the row. <code>node:dns/promises</code> gives you everything you need, but there are three cases to get right:</p>
<ul>
<li><strong>ENOTFOUND</strong>: the domain doesn't exist (NXDOMAIN). Reject.</li>
<li><strong>ENODATA</strong>: the domain exists but has no MX records. This is not an instant reject. RFC 5321 says a sender falls back to the domain's A or AAAA record in that case, so check those before deciding.</li>
<li><strong>Null MX</strong> (RFC 7505): a single MX record with preference 0 and exchange <code>.</code> is the domain owner saying "this domain accepts no mail". Node may give you the root exchange as an empty string rather than <code>.</code>, so check for both.</li>
</ul>
<p>Anything else (timeouts, SERVFAIL, refused connections) means DNS is having a bad moment, not that the address is bad. Fail open: let the signup through and let the confirmation step deal with it. A DNS outage shouldn't lock out genuine people.</p>
<pre><code class="language-js">// mail-domain.js
import { Resolver } from 'node:dns/promises'

const defaultResolver = new Resolver({ timeout: 1500, tries: 2 })
const settle = (p) =&gt; p.then((records) =&gt; ({ records }), (err) =&gt; ({ code: err.code }))

function withTimeout(promise, ms) {
  let timer
  const timeout = new Promise((resolve) =&gt; {
    timer = setTimeout(() =&gt; resolve('unknown'), ms)
  })
  return Promise.race([promise, timeout]).finally(() =&gt; clearTimeout(timer))
}

// Returns 'ok', 'no-domain', 'no-mail' or 'unknown' (DNS trouble: let it through)
export function mailDomain(domain, dns = defaultResolver, ms = 3000) {
  return withTimeout(check(domain, dns), ms)
}

async function check(domain, dns) {
  const mx = await settle(dns.resolveMx(domain))

  if (mx.records?.length) {
    const [first] = mx.records
    const nullMx = mx.records.length === 1 &amp;&amp; first.priority === 0 &amp;&amp;
      (first.exchange === '' || first.exchange === '.')
    return nullMx ? 'no-mail' : 'ok'
  }
  if (mx.code === 'ENOTFOUND') return 'no-domain'
  if (mx.code &amp;&amp; mx.code !== 'ENODATA') return 'unknown'

  // No MX records: RFC 5321 falls back to the address records
  const [a, aaaa] = await Promise.all([settle(dns.resolve4(domain)), settle(dns.resolve6(domain))])
  if (a.records?.length || aaaa.records?.length) return 'ok'
  const gone = (r) =&gt; r.records || ['ENODATA', 'ENOTFOUND'].includes(r.code)
  return gone(a) &amp;&amp; gone(aaaa) ? 'no-mail' : 'unknown'
}
</code></pre>
<p>The <code>Resolver</code> options cap each query, and <code>withTimeout</code> caps the whole check, so a slow name server costs you at most three seconds before the signup goes through.</p>
<p>You don't need a live domain to test each branch. Pass a fake resolver:</p>
<pre><code class="language-js">// mail-domain.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mailDomain } from './mail-domain.js'

const fail = (code) =&gt; () =&gt; Promise.reject(Object.assign(new Error(code), { code }))
const ok = (records) =&gt; () =&gt; Promise.resolve(records)
const fake = (mx, a = fail('ENODATA'), aaaa = fail('ENODATA')) =&gt;
  ({ resolveMx: mx, resolve4: a, resolve6: aaaa })

test('mailDomain', async () =&gt; {
  assert.equal(await mailDomain('x', fake(ok([{ exchange: 'mx.x', priority: 10 }]))), 'ok')
  assert.equal(await mailDomain('x', fake(ok([{ exchange: '', priority: 0 }]))), 'no-mail')
  assert.equal(await mailDomain('x', fake(ok([{ exchange: '.', priority: 0 }]))), 'no-mail')
  assert.equal(await mailDomain('x', fake(fail('ENOTFOUND'))), 'no-domain')
  assert.equal(await mailDomain('x', fake(fail('ENODATA'), ok(['192.0.2.1']))), 'ok')
  assert.equal(await mailDomain('x', fake(fail('ENODATA'))), 'no-mail')
  assert.equal(await mailDomain('x', fake(fail('ESERVFAIL'))), 'unknown')
  const hang = () =&gt; new Promise(() =&gt; {})
  assert.equal(await mailDomain('x', fake(hang), 50), 'unknown')
})
</code></pre>
<p>This only tells you about the domain. It says nothing about the part before the <code>@</code>, which is what step 3 is for.</p>
<h2>3. Only count confirmed entries</h2>
<p>Bots rarely open an inbox. So every new row starts as <code>pending</code>, you email a one-time link, and the row only flips to <code>confirmed</code> when someone clicks it. Your waitlist number is the confirmed count, nothing else.</p>
<p>Here is the whole handler, with the rate limit from the next section first in line. It takes any client with a <code>query(sql, params)</code> method that returns <code>{ rows }</code>, such as a <code>pg</code> Pool.</p>
<pre><code class="language-js">// waitlist.js
import { randomBytes, createHash } from 'node:crypto'
import { emailKey } from './email-key.js'
import { mailDomain } from './mail-domain.js'
import { allow } from './rate-limit.js'

const sha256 = (s) =&gt; createHash('sha256').update(String(s)).digest('hex')

export async function joinWaitlist({ email, ip }, db, sendLink) {
  if (!allow(ip)) return { status: 429 }
  const key = emailKey(email)
  if (!key) return { status: 400 }
  const domainStatus = await mailDomain(key.split('@')[1])
  if (domainStatus === 'no-domain' || domainStatus === 'no-mail') return { status: 400 }

  const token = randomBytes(32).toString('base64url')
  const { rows } = await db.query(
    `insert into waitlist (email, email_key, token_hash) values ($1, $2, $3)
     on conflict (email_key) do update
       set email = excluded.email, token_hash = excluded.token_hash, created_at = now()
       where waitlist.status = 'pending'
     returning id`,
    [email.trim(), key, sha256(token)])
  if (rows.length) await sendLink(email.trim(), token)
  return { status: 202 } // same answer for new, resent and already confirmed
}

export async function confirmEntry(token, db) {
  const { rows } = await db.query(
    `update waitlist set status = 'confirmed', confirmed_at = now(), token_hash = null
     where token_hash = $1 and status = 'pending'
       and created_at &gt; now() - interval '7 days'
     returning id`,
    [sha256(token)])
  return rows.length === 1
}
</code></pre>
<p>A few details worth keeping:</p>
<ul>
<li>Only a hash of the token is stored, so a leaked table can't be used to confirm entries.</li>
<li>Signing up again while pending stores the new spelling, sends a fresh link to it and resets the clock. Signing up again once confirmed changes nothing.</li>
<li>The response is the same either way, so the endpoint doesn't tell anyone which emails are already on the list.</li>
</ul>
<p>Then expire the leftovers on a schedule, and count only what's confirmed:</p>
<pre><code class="language-sql">-- maintenance.sql
delete from waitlist
where status = 'pending' and created_at &lt; now() - interval '7 days';

select count(*) as waitlist_size from waitlist where status = 'confirmed';
</code></pre>
<p>Pick the number of days that suits you. Seven is a reasonable start.</p>
<h2>4. A per-IP rate limit</h2>
<p>A script that can't fake confirmations can still fill your pending table and your email sending quota. A simple per-IP cap in front of everything helps:</p>
<pre><code class="language-js">// rate-limit.js
const hits = new Map()

export function allow(ip, limit = 5, windowMs = 60 * 60 * 1000, now = Date.now()) {
  const entry = hits.get(ip)
  if (!entry || now - entry.start &gt;= windowMs) {
    hits.set(ip, { start: now, count: 1 })
    return true
  }
  entry.count += 1
  return entry.count &lt;= limit
}
</code></pre>
<p>Three notes. This <code>Map</code> lives in one process, so on several instances or serverless functions, keep the counter in Redis or a Postgres table instead. Read the client IP from your proxy's trusted header, not whatever the request claims. And for IPv6, count per /64 rather than per address, since one connection usually gets a whole range.</p>
<h2>Putting it together</h2>
<p>On the server, in this order: rate limit, normalise, check the domain, store as pending, count only confirmed. None of it needs a third-party service, and all of it runs where a script posting straight to your endpoint can't skip it.</p>
<h2>I built Orisift</h2>
<p>I built Orisift because I kept writing these checks for every project. It runs the domain checks (mail setup and a throwaway domain list) plus phone and IP screening in one call, through <code>POST /v1/lookup</code> or <code>POST /v1/signup</code>, and <code>@orisift/sdk</code> includes a Supabase Auth helper. The homepage has a free checker, three previews a day with no account: <a href="https://orisift.com/x?m=post&amp;c=hashnode-waitlist-bots&amp;to=%2F">https://orisift.com/x?m=post&amp;c=hashnode-waitlist-bots&amp;to=%2F</a></p>
<p>Signing up gives 500 free credits once, and there's no card to enter.</p>
]]></content:encoded></item><item><title><![CDATA[Your AI-built app's signup is open to anyone who calls the API]]></title><description><![CDATA[If you built your app with Lovable, Bolt or Cursor on Supabase, your signup form is a suggestion. The form runs in the browser, and so do the Supabase URL and the anon (or publishable) key it uses. An]]></description><link>https://elijahbrown.hashnode.dev/your-ai-built-app-s-signup-is-open-to-anyone-who-calls-the-api</link><guid isPermaLink="true">https://elijahbrown.hashnode.dev/your-ai-built-app-s-signup-is-open-to-anyone-who-calls-the-api</guid><category><![CDATA[supabase]]></category><category><![CDATA[lovable]]></category><category><![CDATA[Security]]></category><category><![CDATA[webdev]]></category><category><![CDATA[authentication]]></category><dc:creator><![CDATA[Elijah Brown]]></dc:creator><pubDate>Wed, 07 Oct 2026 08:27:22 GMT</pubDate><content:encoded><![CDATA[<p>If you built your app with Lovable, Bolt or Cursor on Supabase, your signup form is a suggestion. The form runs in the browser, and so do the Supabase URL and the anon (or publishable) key it uses. Anyone with the developer tools open can copy both and call Supabase Auth directly, skipping your invite code, terms checkbox and anything else the form enforces.</p>
<p>That is how Supabase is designed, and it is fine as long as the rules that matter live on the server. Steps 1 to 5 below need nothing but Supabase and an afternoon.</p>
<h2>1. Sign up without your UI</h2>
<p>Run this with Node, using the values from your built app:</p>
<pre><code class="language-ts">import { createClient } from "@supabase/supabase-js";

// Both values ship in your app's JavaScript bundle.
const supabase = createClient("https://YOUR-PROJECT.supabase.co", "YOUR-ANON-OR-PUBLISHABLE-KEY");

const { data, error } = await supabase.auth.signUp({
  email: "Your.Name+test1@gmail.com",
  password: "a-long-throwaway-password",
  options: { data: { plan: "pro", credits: 100000 } },
});
console.log(error ?? data.user?.user_metadata);
</code></pre>
<p>If that creates a user, everything your form checks is optional. Look at <code>user_metadata</code> too: it holds whatever the caller sent, so if any code reads a plan or credits from it, the caller sets their own. Keep anything that grants access in a table only your server writes, or in <code>app_metadata</code>, which clients cannot change.</p>
<h2>2. Check Row Level Security with a second account</h2>
<p>Create accounts A and B. Signed in as B, try to read A's rows, update A's rows, and update your own <code>credits</code> or <code>plan</code> column. Repeat for every table the app touches.</p>
<p>The usual gap in generated code is not a missing policy. It is "users can update their own profile" on a table that also holds credits, so the owner can write any column in their row. Move those columns to a table with no client write policy, or revoke table-level update and grant it back only on the columns people may edit.</p>
<h2>3. Normalise emails and phones on the server</h2>
<p>To spot a repeat signup you need one canonical form of each contact detail. For email: trim and lowercase, drop everything from a <code>+</code> in the local part, and for gmail.com and googlemail.com remove the dots and treat both domains as one. Use the result only for matching; keep sending mail to the address as typed.</p>
<pre><code class="language-sql">create or replace function public.normalise_email(raw text)
returns text language sql immutable as $$
  select case when dom in ('gmail.com', 'googlemail.com')
    then replace(loc, '.', '') || '@gmail.com'
    else loc || '@' || dom end
  from (select split_part(split_part(e, '@', 1), '+', 1) as loc,
               split_part(e, '@', 2) as dom
        from (select lower(trim(raw)) as e) a) b
$$;
</code></pre>
<p>Stripping plus tags on every domain is a judgement call.</p>
<p>For phones, parse to E.164 with libphonenumber-js in a server function, using the country the person selected:</p>
<pre><code class="language-ts">import { parsePhoneNumberFromString } from "libphonenumber-js";

const parsed = parsePhoneNumberFromString("020 7946 0958", "GB");
const phoneKey = parsed?.isValid() ? parsed.number : null; // "+442079460958"
</code></pre>
<h2>4. Tie free credits to the contact detail, not the account</h2>
<p>If every new account gets free credits, deleting an account and signing up again is a refill. Record the claim against the normalised email, and the E.164 phone if you collect one, in a table clients cannot touch:</p>
<pre><code class="language-sql">create table public.trial_claims (
  claim_key text primary key,  -- normalised email or E.164 phone
  user_id uuid not null,
  claimed_at timestamptz not null default now()
);
alter table public.trial_claims enable row level security;  -- no client policies
</code></pre>
<p>Grant credits only from server code: insert the keys with <code>on conflict (claim_key) do nothing</code>, and add credits only when a row was inserted. Deleting an account leaves the claim behind, which is the point, so mention it in your privacy notice.</p>
<h2>5. Turn on captcha and review rate limits</h2>
<p>Supabase Auth supports hCaptcha and Cloudflare Turnstile. Enable it in the Auth settings (under Bot and Abuse Protection at the time of writing) and pass <code>captchaToken</code> in the <code>options</code> of <code>signUp</code>. The step 1 script should then fail without a token.</p>
<p>Then set sign-up limits under Authentication &gt; Rate Limits for the traffic you expect. If signups go through your own server, read the IP forwarding section, or every request counts against your server's address.</p>
<p>Captcha raises the cost of a script. It does not stop one person signing up by hand twenty times; steps 3 and 4 handle that.</p>
<h2>6. Move the checks into a Before User Created hook</h2>
<p>Steps 3 and 4 only hold if nothing can skip them. Supabase's Before User Created hook runs before the user row exists, whether the signup came from your form, a script or OAuth. It can be a Postgres function or an HTTP endpoint, chosen under Authentication &gt; Hooks.</p>
<p>A Postgres version that refuses a second account for the same normalised email. It keeps one row per normalised email in a table you own, with the email as the primary key, so the check is an index lookup instead of a scan of <code>auth.users</code> (a Postgres hook has two seconds to finish). It also handles two signups for the same address arriving at once: the second waits for the first to commit, then is refused.</p>
<pre><code class="language-sql">-- One row per normalised email. The primary key does the duplicate check.
create table public.signup_keys (
  email_key text primary key,  -- public.normalise_email(email)
  created_at timestamptz not null default now()
);
alter table public.signup_keys enable row level security;
revoke all on public.signup_keys from anon, authenticated, public;

-- The hook runs as supabase_auth_admin, which does not bypass RLS.
grant usage on schema public to supabase_auth_admin;
grant insert on public.signup_keys to supabase_auth_admin;
create policy "auth adds signup keys" on public.signup_keys
  for insert to supabase_auth_admin with check (true);

create or replace function public.hook_before_user_created(event jsonb)
returns jsonb language plpgsql as $$
declare
  added int;
begin
  -- Nothing to check for phone-only signups.
  if coalesce(event-&gt;'user'-&gt;&gt;'email', '') = '' then
    return '{}'::jsonb;
  end if;

  -- A second signup for the same key waits here until the first commits.
  insert into public.signup_keys (email_key)
  values (public.normalise_email(event-&gt;'user'-&gt;&gt;'email'))
  on conflict do nothing;
  get diagnostics added = row_count;

  if added = 0 then
    return jsonb_build_object('error', jsonb_build_object('http_code', 400,
      'message', 'Please sign in, or use a different email address.'));
  end if;
  return '{}'::jsonb;
end $$;

grant execute on function public.normalise_email to supabase_auth_admin;
grant execute on function public.hook_before_user_created to supabase_auth_admin;
revoke execute on function public.hook_before_user_created from authenticated, anon, public;

-- Add keys for existing accounts. Safe to rerun after you switch the hook on.
insert into public.signup_keys (email_key)
select public.normalise_email(email) from auth.users
where email &lt;&gt; ''
on conflict do nothing;
</code></pre>
<p>Supabase Auth runs the hook as <code>supabase_auth_admin</code>, which does not bypass Row Level Security, so it needs the grants and the insert policy above. The last insert adds keys for the accounts you already have. Run it again after you switch the hook on, to cover anyone who signed up in between.</p>
<p>Rerun the step 1 script with <code>Your.Name+test2@gmail.com</code> and it should be refused.</p>
<p>A key stays behind when an account is deleted, and also when a signup fails after the hook has run, for example if the confirmation email could not be sent. That person is then asked to sign in to an account that does not exist, so clear the key in the SQL editor with <code>delete from public.signup_keys where email_key = public.normalise_email('their@email.com');</code>. Their <code>trial_claims</code> row from step 4 stays, so coming back does not refill their free credits.</p>
<p>Thanks to Mike Dabydeen, who pointed out in the comments that my first version of this hook scanned every user and let two simultaneous signups through.</p>
<h3>One option for the HTTP hook: Orisift</h3>
<p>I built Orisift, so weigh this accordingly. It checks the email, phone and IP on a signup and returns the evidence, a recommended action, and what it could not establish. The <code>@orisift/sdk</code> package has a Supabase helper that answers the HTTP hook. I covered its settings in <a href="https://elijahbrown.hashnode.dev/screening-supabase-signups-before-the-user-is-created">Screening Supabase signups before the user is created</a>, so here is just the shape:</p>
<pre><code class="language-ts">import { createBeforeUserCreatedHandler } from "@orisift/sdk/supabase";
import { decideSignup, EXAMPLE_ENFORCEMENT, type EnforcementPolicy, type ScreeningLike } from "@orisift/sdk/enforcement";

// Refuse when the person can fix what they typed.
const policy: EnforcementPolicy = {
  ...EXAMPLE_ENFORCEMENT,
  map: { ...EXAMPLE_ENFORCEMENT.map, ask_to_correct: "block" },
};

const handle = createBeforeUserCreatedHandler({
  apiKey: process.env.ORISIFT_API_KEY!,
  hookSecret: process.env.SUPABASE_HOOK_SECRET!, // v1,whsec_...
  policy: "contact_standard",
  decide: (screening) =&gt; decideSignup(screening as ScreeningLike, policy),
  denyOn: ["block"],
  onUnavailable: "allow", // required, no default
  dedupe: { mode: "single-process" },
  onUnavailableEvent: (e) =&gt; console.warn("signup not screened", e.reason),
});

export const POST = (request: Request) =&gt; handle(request);
</code></pre>
<p><code>onUnavailable</code> has no default because letting people in when screening is unavailable is your decision, not the library's. <code>dedupe</code> is required too; the earlier tutorial explains the modes. The SDK is built for Node, so host it on a Node route, not an Edge Function. Supabase runs one Before User Created hook, Postgres or HTTP, so if you pick this one the duplicate email check above does not run. Keep the step 4 claim check either way, so a repeat signup still gets no free credits.</p>
<p>No credits are charged when a screening establishes no evidence; one that establishes something, including a conclusive negative, is charged. New accounts get 500 credits once on signup, no card required. The <a href="https://orisift.com/x?m=post&amp;c=hashnode-lovable-tutorial&amp;to=%2Fdocs%23sdk">SDK docs</a> cover the rest, or <a href="https://orisift.com/x?m=post&amp;c=hashnode-lovable-tutorial&amp;to=%2F">try a check on the homepage</a> first.</p>
<h2>A prompt for Lovable or Cursor</h2>
<p>Paste this to apply steps 3 to 6:</p>
<pre><code class="language-text">My app uses Supabase Auth. Make these changes server-side only, and show me each SQL migration before applying it.

1. Create a SQL function public.normalise_email(text) that trims, lowercases, removes everything from "+" in the local part, and for gmail.com and googlemail.com removes dots and uses gmail.com. Use it only for matching; keep storing and emailing the address as typed.
2. If the app collects phone numbers, normalise them to E.164 with libphonenumber-js in a server function, using the country the user selected.
3. Create public.trial_claims (claim_key text primary key, user_id uuid, claimed_at timestamptz) with RLS enabled and no client policies. Grant free credits only from server code, by inserting the normalised email and phone with ON CONFLICT DO NOTHING, and only when a row was inserted. Never read plan, credits or roles from user_metadata.
4. Create public.signup_keys (email_key text primary key, created_at timestamptz) with RLS enabled and no client policies, and fill it with the normalised email of every existing auth.users row using ON CONFLICT DO NOTHING. Then create a Postgres function public.hook_before_user_created(event jsonb) for Supabase's Before User Created hook. It must not query auth.users. If the event has no email, return {}. Otherwise insert the normalised email into signup_keys with ON CONFLICT DO NOTHING (no column list, so the hook needs no select access) and return {"error":{"http_code":400,"message":"Please sign in, or use a different email address."}} when no row was inserted, and {} otherwise. The hook runs as supabase_auth_admin, which does not bypass RLS: grant it usage on schema public, insert on signup_keys, an insert policy on signup_keys, and execute on both functions. Revoke execute on the hook from authenticated, anon and public, and revoke all on signup_keys from authenticated, anon and public.
5. List the dashboard settings I must change by hand: Authentication &gt; Hooks, captcha and rate limits.
</code></pre>
<p>Then run the step 1 script again. If it still creates an account your form would have refused, a rule is still living on the client.</p>
]]></content:encoded></item><item><title><![CDATA[Screening Supabase signups before the user is created]]></title><description><![CDATA[Most signup forms check the shape of what people type and stop there. That catches typos, but not a well formed address at a known throwaway email provider, or a phone number that is valid on paper bu]]></description><link>https://elijahbrown.hashnode.dev/screening-supabase-signups-before-the-user-is-created</link><guid isPermaLink="true">https://elijahbrown.hashnode.dev/screening-supabase-signups-before-the-user-is-created</guid><category><![CDATA[supabase]]></category><category><![CDATA[authentication]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[webhooks]]></category><dc:creator><![CDATA[Elijah Brown]]></dc:creator><pubDate>Tue, 06 Oct 2026 11:30:38 GMT</pubDate><content:encoded><![CDATA[<p>Most signup forms check the shape of what people type and stop there. That catches typos, but not a well formed address at a known throwaway email provider, or a phone number that is valid on paper but sits in a range set aside for fiction.</p>
<p>Take 020 7946 0958. It passes libphonenumber's validity check as a UK fixed line number. It is also inside the London range Ofcom sets aside for TV and radio drama, so it can't be allocated to a communications provider's customers. NANPA reserves 555-0100 to 555-0199 as fictitious numbers. A format check accepts both, because the format is fine.</p>
<p>Timing matters too. A database trigger or post-signup webhook runs after the user exists, so you end up deleting accounts rather than declining signups.</p>
<h2>The Before User Created hook</h2>
<p>Supabase Auth has a hook for this. Before User Created runs before the user row exists, so a refused signup never creates a user. It can be a Postgres function or an HTTP endpoint, chosen under Authentication &gt; Hooks. An HTTP hook receives a signed request with the user's email or phone and metadata that can include the IP address, and your response decides whether the user is created.</p>
<p>Two details matter more than they look.</p>
<p><strong>The refusal shape.</strong> Supabase's documentation describes returning a 4xx status with an error object. When I tested that against a real project, the signup was blocked but the client got an opaque 500, "Invalid payload sent to hook", and my message never arrived. What worked was HTTP 200 with a nested error:</p>
<pre><code class="language-json">{ "error": { "http_code": 400, "message": "..." } }
</code></pre>
<p>That blocked the signup and passed my message through as a 400. A 200 with <code>error</code> as a plain string blocked nothing. If you write your own hook, test the refusal against a real project.</p>
<p><strong>The time budget.</strong> Supabase gives the whole invocation 5 seconds, including its own retries, and a hook that doesn't answer in time fails the signup. Letting people through when your check is slow means answering 200 quickly, not timing out quietly.</p>
<h2>Setting up the handler</h2>
<p>I wrote a handler for this in the Orisift Node SDK. It checks the Standard Webhooks signature, sends the email, phone and IP to Orisift's signup screening endpoint in one request, and answers Supabase in the shape above.</p>
<pre><code class="language-bash">npm install @orisift/sdk
</code></pre>
<pre><code class="language-ts">import { createBeforeUserCreatedHandler } from "@orisift/sdk/supabase";

const handle = createBeforeUserCreatedHandler({
  apiKey: process.env.ORISIFT_API_KEY!,
  // Supabase's hook secret, exactly as given: v1,whsec_&lt;base64&gt;
  hookSecret: process.env.SUPABASE_HOOK_SECRET!,
  onUnavailable: "allow",
  dedupe: { mode: "single-process" },
  decide: toDecision, // your function: the screening in, a ScreeningDecision out
  denyOn: ["block"],
  onDecision: (e) =&gt; console.log("screened", e.screeningId, e.decision.enforcement, e.creditsCharged),
  onUnavailableEvent: (e) =&gt; console.warn("not screened", e.reason, e.applied, e.elapsedMs),
});
</code></pre>
<p><code>handle</code> takes a standard <code>Request</code> and returns a <code>Response</code>, so your hook route passes the request in and returns the result.</p>
<p><code>decide</code> is yours. It receives the screening and returns a decision whose <code>enforcement</code> is one of these:</p>
<pre><code class="language-ts">export type Enforcement = "allow" | "challenge" | "review_queue" | "block" | "fix_request";
</code></pre>
<p><code>denyOn</code> lists which of those refuse the signup. It has no default either.</p>
<h2>onUnavailable has no default</h2>
<p>Sometimes a screening won't produce a decision: a wrong key, no credits left, a rate limit, a timeout, an error on Orisift's side, or your own <code>decide</code> throwing. Then you either let the signup through unscreened or refuse it.</p>
<p><code>onUnavailable</code> must be <code>"allow"</code> or <code>"deny"</code>, and there is no default. A default would be the library deciding whether your product accepts people it could not screen. That is your call.</p>
<p>Either way, the signup is never recorded as screened, and <code>onUnavailableEvent</code> gets the specific reason (<code>timeout</code>, <code>auth_failed</code>, <code>insufficient_credits</code> and so on), so an outage doesn't look like a quiet week. With <code>"allow"</code> and no <code>onUnavailableEvent</code>, a warning naming the reason goes to the console, at most once per reason per minute. With <code>"deny"</code>, the person sees your <code>unavailableMessage</code> or a default asking them to try again shortly, never error detail.</p>
<h2>The dedupe setting</h2>
<p>In testing, one signup attempt produced four deliveries, each with a new webhook id, <code>metadata.uuid</code> and <code>user.id</code>. Screen each delivery separately and you can pay four times.</p>
<p><code>dedupe</code> is required and has four modes:</p>
<ul>
<li><code>"attempt"</code>: you supply <code>attemptId</code>, returning the same value for every delivery of one attempt, such as a request id passed through <code>user_metadata</code>. Return <code>null</code> when there is none, and that delivery is screened and billed on its own.</li>
<li><code>"shared"</code>: bounded deduplication over storage you provide, via a <code>store</code> with an atomic <code>claim(key, candidate, ttlMs)</code> such as Redis <code>SET NX PX</code> or Postgres <code>INSERT ... ON CONFLICT DO NOTHING</code>.</li>
<li><code>"single-process"</code>: the same in memory, correct only while one process handles every delivery.</li>
<li><code>"none"</code>: every delivery is screened and charged.</li>
</ul>
<p>The bounded modes use a <code>windowMs</code> that defaults to two minutes; the longest retry sequence observed was 11.8 seconds. The trade runs both ways: separate attempts with identical details inside the window share one screening, and a retry after it is a new screening. If the shared store fails, the default <code>onStoreFailure: "unavailable"</code> makes no screening request and applies your <code>onUnavailable</code> choice. <code>"screen"</code> screens anyway and accepts duplicate charges.</p>
<h2>A 2.5 second deadline inside 5 seconds</h2>
<p>The handler budgets from Supabase's 5 seconds. Minus 1 second of margin for the network and Supabase's overhead, the handler has 4. Minus 1.5 seconds of headroom to answer after a slow call, the screening call gets 2.5. Of those figures, only the 2.5 seconds is an armed timer.</p>
<p><code>screenTimeoutMs</code> can lower it, not raise it: anything above 2,500 ms is capped. The handler makes one attempt and never retries, because a retry inside a budget that already contains Supabase's retries is how a slow hook becomes one that never answers. A timeout arrives as reason <code>timeout</code> and follows your <code>onUnavailable</code> choice.</p>
<h2>Bad configuration fails at construction</h2>
<p>The handler refuses to construct without <code>apiKey</code>, without <code>hookSecret</code>, or without <code>onUnavailable</code> set to <code>"allow"</code> or <code>"deny"</code>. Since 1.3.0 it also throws when <code>dedupe</code> is missing, when <code>{mode: "attempt"}</code> has no <code>attemptId</code> function, when <code>{mode: "shared"}</code> has no store with <code>claim()</code>, or when the mode isn't one of the four.</p>
<p>That last group used to construct and then fail on every request. With <code>onUnavailable: "allow"</code>, every signup was allowed with nothing screened, returning HTTP 200 in about 12 ms, which looks exactly like working software. A process that won't start is easier to notice. On plain JavaScript, check your <code>dedupe</code> option before upgrading.</p>
<h2>What to do with findings</h2>
<p>Once <code>decide</code> has returned, you have three moves.</p>
<p><strong>Block.</strong> Put the enforcement in <code>denyOn</code>. No user is created and the person sees your message.</p>
<p><strong>Ask the person to correct it.</strong> Also a refusal, but the message says what to change. By default it is the first end user reason in the decision, falling back to "We could not accept those contact details. Please use a different email address." Pass <code>denyMessage</code> to write your own. Reasons meant for you as the integrator never reach the form. A corrected submission is a different request and is screened again (in <code>"attempt"</code> mode, with a new attempt id, because the same id with a changed request is refused with a 409).</p>
<p><strong>Allow and log.</strong> Leave the enforcement out of <code>denyOn</code>. <code>onDecision</code> fires with the decision, <code>screeningId</code>, <code>creditsCharged</code> and the dedupe fields, so you can follow up after the account exists, for example on a <code>challenge</code> or <code>review_queue</code> outcome.</p>
<p>A signup with no email, phone or IP is allowed without a screening, because refusing on absent evidence is the wrong default.</p>
<h2>Trying it</h2>
<p>You can try checks on the Orisift homepage, 3 a day with no account, and a new account starts with 500 credits. A screening that establishes no evidence costs nothing, and a partial one can be charged. Replaying a matching request returns the original screening at no charge.</p>
<h2>Disclosure</h2>
<p>I built Orisift, so read the above with that in mind. The SDK docs are here: <a href="https://orisift.com/x?m=post&amp;c=hashnode-supabase-tutorial&amp;to=%2Fdocs%23sdk">https://orisift.com/x?m=post&amp;c=hashnode-supabase-tutorial&amp;to=%2Fdocs%23sdk</a></p>
<p>Until the Supabase guide is in the docs, the setup details and the reasoning behind each choice are in the handler source: <a href="https://github.com/orisift/orisift-node/blob/main/src/integrations/supabase.ts">https://github.com/orisift/orisift-node/blob/main/src/integrations/supabase.ts</a></p>
]]></content:encoded></item></channel></rss>