I keep seeing Authentication systems rate-limit the wrong thing. The code looks reasonable at first: take the email string from the request, hash it, increment a counter, and decide whether the user can ask for another verification or reset email. The bug shows up later, when Jane.Doe+trial@Example.com and jane.doe@example.com produce different counters, different logs, and a very confusing support trail.
That matters even more when your signup flow already deals with disposable temporary email patterns, temp mail so style test traffic, and a mix of user-entered aliases. If the API does not canonicalize before policy and rate-limit checks, abuse controls get oddly soft while legitimate users still hit rough edges. I have seen teams chase notes with phrases like tamp mail com or temp mailid in incident docs because nobody persisted the normalized form that the system actually reasoned about. It works, until it kind of doesnt.
Why raw email strings create auth bugs
A raw email string is user input, not identity. Case, dots, plus-addressing, Unicode normalization, and domain aliases can all change how the same inbox is represented. Some providers ignore dots, some do not. Some teams strip plus tags globally, which is also wrong. The real backend job is to define a small canonicalization policy per provider class and persist the result that downstream services use.
If you skip that step, four things drift:
- resend cooldowns become inconsistent
- abuse scoring gets split across aliases
- support cannot explain why one request was blocked
- test evidence becomes annoyingly fuzzy
This is the same reason I like keeping reproducible email checks around a single normalized artifact. One input form, one decision record, one trail you can review later.
Canonicalize before policy and rate limits
My preference is simple: parse, normalize, classify, then write one receipt row before any async send begins. That receipt should include the original input, the canonical address key, the domain classification, and the policy version used to make the decision.
For example:
type EmailDecisionReceipt = {
requestId: string;
rawEmail: string;
canonicalKey: string;
providerClass: "generic" | "gmail_like" | "enterprise";
domainCategory: "standard" | "disposable" | "blocked";
policyVersion: number;
resendWindowSeconds: number;
decision: "allow" | "review" | "deny";
};
The useful trick is that canonicalKey should be the input to both policy lookup and resend throttling. Not the raw address. Not a half-normalized cache key in one service and a different hash in another. One canonical key. Boring, explicit, reviewable.
For disposable temporary email checks, I usually classify the domain separately from the canonical local part. That keeps domain-level controls independent from user-specific resend logic. It also means you can tighten trial-abuse rules without rewriting the whole auth path, which is nice when product wants a fast change by Friday afternoon.
A PostgreSQL receipt shape that stays reviewable
I like a table like this because it keeps write-time evidence close to the send decision:
create table auth_email_receipts (
id bigserial primary key,
request_id uuid not null,
raw_email text not null,
canonical_key text not null,
provider_class text not null,
domain_category text not null,
policy_version integer not null,
decision text not null check (decision in ('allow', 'review', 'deny')),
resend_window_seconds integer not null,
created_at timestamptz not null default now()
);
create index auth_email_receipts_lookup_idx
on auth_email_receipts (canonical_key, created_at desc);
This is not meant to be your forever audit lake. It is a working receipt for backend behavior. The benefit is very practical: when retries happen, or a queue delays a send, engineers can still answer which canonical identity the REST API used and which rule set fired. That answer is often missing in systems that only log the raw request body.
If you also publish CI evidence, the same idea behind short CI email summaries helps here too. Keep the receipt terse enough that another engineer can compare runs quickly.
What to return from the REST API
The client does not need all of this detail. It needs stable outcomes. My usual response shape is small on purpose:
{
"status": "accepted",
"request_id": "d6f5c4b2-2b36-4b87-8d0d-112233445566",
"cooldown_seconds": 90,
"decision_code": "email_verification_scheduled"
}
Then the server-side receipt preserves the backend truth. If a user retries with a different casing or alias, you can still tie the cooldown to the same canonical identity when your policy says that is appropriate. For Authentication APIs, that reduces duplicate sends and weird branchy logic in workers. It also makes Postgres queries much saner, which is not glamorous but is honestly where half the reliability comes from.
One caution: do not over-normalize. Gmail-like dot folding is not universal, and stripping plus tags for enterprise domains can create bad collisions. A small provider-class map is better than a fake universal rule. Overconfidence here causes more mess than slow rollout does.
Small checks that prevent painful regressions
The most useful checks are not huge:
- test that raw variants map to one canonical key when your provider rules say they should
- test that different enterprise aliases do not collapse by mistake
- log the policy version next to the receipt
- keep retention short and documented
- make the resend query hit
canonical_key, notraw_email
I would also add one contract test that proves a disposable temporary email domain still gets classified correctly after a policy update. Teams often validate the deny path and forget the review path, which is where real-world edge cases live a bit more often.
Q&A
Should I canonicalize in the API or in the database?
Usually in the API, then persist the result. PostgreSQL can enforce constraints and help with lookup speed, but the normalization policy is application behavior and should be versioned there.
Do I need a separate receipt row for every resend attempt?
Yes, if you care about debugging. Keep each decision small and timestamped. Trying to mutate one row into the whole story gets messy fast.
Is this worth it for small apps?
If you send verification or reset emails, yes. The table is tiny, the logic is boring, and the payoff arrives the first time support asks why two "same" addresses behaved differently.
Top comments (0)