Most teams start with a simple email regex and call the signup flow done. That works until abuse, fake trials, and support noise start stacking up. When a burner email address slips through, the real issue is usually not detection alone. It is that the policy lives in three places at once: app code, a vendor dashboard, and somebody's half-remembered runbook.
I prefer to make signup email policy a backend artifact first. Put the rules in PostgreSQL, version them, and let the API return a small set of reviewable outcomes. It sounds a bit boring, but boring systems age better. In auth flows, boring is good even when product wants things fast.
Why signup rules drift over time
Disposable-domain checks often begin as a list in source control. A few weeks later, support asks for exceptions. Marketing wants softer handling for trials. Security wants hard blocks for domains used in credential stuffing. Then another service adds its own copy because it needs a quick decision in middleware. Now the same rule is enforced four differnt ways, and nobody fully trusts the result.
That drift gets worse when the rule engine is hidden behind vague booleans like is_disposable = true. In practice, teams need more nuance:
- block known abuse domains
- allow but flag low-confidence cases
- bypass checks for invited enterprise tenants
- expire temporary overrides automatically
I have seen incidents where engineers searched notes for odd phrases like tamp mail com or temp gamil com because they were trying to reconstruct how a previous exception had been tested. The weird phrasing is not the problem, the missing system record is.
Model the policy in PostgreSQL first
My default design is a small rule table plus a decision log. The table tells the API what to do. The log tells humans why the API did it. That second part matters more than teams expect.
create table signup_email_policy (
id bigserial primary key,
domain text not null,
action text not null check (action in ('allow', 'flag', 'block')),
reason text not null,
source text not null,
confidence smallint not null check (confidence between 0 and 100),
tenant_id uuid,
starts_at timestamptz not null default now(),
ends_at timestamptz,
policy_version integer not null,
unique (domain, coalesce(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid), policy_version)
);
create index signup_email_policy_active_idx
on signup_email_policy (domain, starts_at, ends_at);
There are three choices here that save pain later.
First, keep action explicit. Teams argue less about allow, flag, and block than they do about overloaded booleans. Second, store policy_version so incident reviews can answer which ruleset made the decision. Third, allow tenant-scoped exceptions without forking the whole policy model.
For the read path, I usually normalize the domain, query active rules, then choose the highest-confidence match. PostgreSQL is good at this if you keep the query plain:
select domain, action, reason, confidence, policy_version
from signup_email_policy
where domain = $1
and starts_at <= now()
and (ends_at is null or ends_at > now())
and (tenant_id is null or tenant_id = $2)
order by tenant_id desc nulls last, confidence desc, policy_version desc
limit 1;
That does not need to be clever. It needs to be inspectable at 2 PM and at 2 AM. If your signup service also runs test inbox scenarios, the same discipline used in stable email test isolation across workers is useful here too: isolate one run, one decision, one audit trail.
Keep the API response boring and consistent
The API should not leak every internal signal to the client. Return a stable contract, and keep the richer reasoning in server logs or an admin view. A shape like this is enough for most Authentication workflows:
{
"decision": "flag",
"code": "signup_email_review_required",
"policy_version": 12,
"retryable": false
}
What I try to avoid is returning different status codes for every sub-case. If some burner domains get 422, others get 409, and others get 202, client logic becomes messy for no real gain. In many systems, two patterns are enough:
-
201when signup may proceed -
202or422when the request needs review or is blocked, depending on your product contract
The point is consistency. Client teams should not need a secret decoder ring. This part is easy to under-value, but it keeps mobile, web, and partner API behavior from drifting apart again.
Add reviewable signals instead of hidden magic
A lot of teams bolt on a third-party reputation feed and stop there. That can help, but I would not let an opaque score become the only source of truth. Store the evaluated domain, matched action, policy version, and request metadata in a decision log with retention that matches your privacy policy.
This is also where privacy review gets easier. If you can show what was stored, why it was stored, and when it expires, review conversations stay shorter and much less fuzzy. The same habit behind privacy review evidence for inbox deletion applies to signup policy logs: write down just enough evidence that another engineer can verify the control later.
My checklist is pretty short, and thats on purpose:
- rules live in PostgreSQL, not scattered constants
- policy changes are versioned
- API returns a small and durable decision set
- exceptions expire unless someone renews them
- decision logs are queryable by domain and policy version
- product and support can tell the difference between
flagandblock
If you already have rate limiting, device risk, or invite-only onboarding, this model fits beside them without much drama. The burner email address check becomes one signal among several, instead of a giant if-statement that keeps growing sideways.
Questions teams usually ask
Should we block every disposable domain?
Usually no. Some products genuinely need strict blocking, but many do better with a flag path first. It catches risky signups without punishing legitimate evaluation flows too early.
Should the domain list live only in Redis?
I would not do that for the source of truth. Cache hot decisions in Redis if needed, sure, but keep PostgreSQL as the auditable record. Otherwise policy debugging gets annoyingly fuzzy.
What is the most common mistake?
Treating this as only a detection problem. It is also a change-management problem. If you cannot explain which rule fired and when it was added, the system may work, but it wont stay maintainable for long.
Top comments (0)