A signup form is one of the cheapest things for an attacker or a bot to hammer, and every request can trigger work on your side: DNS lookups, third-party API calls, database writes. If you validate email domains without limiting how often the form can be hit, you're paying for the abuse.
This walkthrough puts both protections in one Django signup view: rate limiting so the form can't be spammed, and layered email checks so fake or undeliverable addresses are rejected before an account is created.
The order matters
Run the cheap checks first and the expensive ones last:
- Rate limit (almost free, blocks abuse before any real work happens)
- Local checks (disposable-domain list, then a cached MX lookup)
- External API check (costs money and adds latency, so only reached by requests that passed everything else)
Putting the rate limit first means a bot hammering your form never gets as far as your paid API calls.
Setup
pip install django-ratelimit dnspython requests
You also need a cache backend shared across workers (Redis or Memcached). Rate limits stored in a per-process local-memory cache won't work correctly when you run several workers.
Step 1: Cached MX lookup
# validators.py
import dns.resolver
import dns.exception
from django.core.cache import cache
def domain_has_mx(domain: str) -> bool:
cache_key = f"mx:{domain}"
cached = cache.get(cache_key)
if cached is not None:
return cached
try:
answers = dns.resolver.resolve(domain, "MX", lifetime=3.0)
result = len(answers) > 0
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers):
result = False
except dns.exception.Timeout:
# DNS hiccup: fail open and don't cache the result
return True
cache.set(cache_key, result, 60 * 60) # cache for an hour
return result
Caching matters because many signups share the same domains (gmail.com, outlook.com), so you don't need a DNS lookup for every request.
One caveat: under the mail standards, a domain with no MX record can still receive mail at its A record. Treating "no MX" as invalid is a reasonable simplification for signup forms, but it's worth knowing it's a simplification.
Step 2: A local disposable-domain list
DISPOSABLE_DOMAINS = {
"mailinator.com",
"yopmail.com",
"guerrillamail.com",
"10minutemail.com",
"temp-mail.org",
}
This list will go stale quickly, which we'll come back to. It's still a useful free first filter.
Step 3: An external validation check
# validators.py (continued)
import requests
from django.conf import settings
def api_says_acceptable(email: str) -> bool:
try:
resp = requests.get(
"https://api.gamalogic.com/v1/validate",
params={"email": email, "api_key": settings.GAMALOGIC_API_KEY},
timeout=4,
)
resp.raise_for_status()
data = resp.json()
except (requests.RequestException, ValueError):
return True # fail open: don't block real users on an API outage
# Placeholder mapping: check the API docs for the real response fields
if data.get("disposable"):
return False
if data.get("valid") is False:
return False
return True
I'm calling Gamalogic's real-time validation API here. The field names in the last few lines are placeholders, so map them to the response schema in the API documentation. Note the short timeout and the fail-open behaviour: a slow or unavailable third-party service shouldn't stop legitimate signups.
Step 4: The form
# forms.py
from django import forms
from django.core.exceptions import ValidationError
from .validators import DISPOSABLE_DOMAINS, domain_has_mx, api_says_acceptable
class SignupForm(forms.Form):
email = forms.EmailField()
def clean_email(self):
email = self.cleaned_data["email"].lower()
domain = email.split("@", 1)[1]
if domain in DISPOSABLE_DOMAINS:
raise ValidationError("Please use a permanent email address.")
if not domain_has_mx(domain):
raise ValidationError("That email domain can't receive mail.")
if not api_says_acceptable(email):
raise ValidationError("That email address doesn't look valid.")
return email
Step 5: The rate-limited view
# views.py
from django.shortcuts import render, redirect
from django_ratelimit.decorators import ratelimit
from .forms import SignupForm
@ratelimit(key="ip", rate="10/h", method="POST", block=False)
@ratelimit(key="post:email", rate="3/h", method="POST", block=False)
def signup(request):
if getattr(request, "limited", False):
return render(
request,
"signup.html",
{"form": SignupForm(), "error": "Too many attempts. Try again later."},
status=429,
)
if request.method == "POST":
form = SignupForm(request.POST)
if form.is_valid():
# create the account here
return redirect("welcome")
else:
form = SignupForm()
return render(request, "signup.html", {"form": form})
Two limits are stacked on purpose. The IP limit slows one source hammering the form, and the per-email limit stops the same address being retried repeatedly. With block=False, the decorator sets request.limited and lets you return a friendly 429 page instead of a bare error. Because the limit runs before the form is validated, limited requests never reach your DNS lookups or paid API calls.
Pitfalls to watch for
-
Proxies and load balancers.
key="ip"uses the connecting address. Behind a proxy that can be the proxy's own IP, so configure which header to trust carefully, since forwarded headers can be spoofed. - Shared IPs. Offices and mobile networks can share one IP, so don't set the IP limit so low that real users get blocked.
- Cache backend. Use a shared cache such as Redis in production, as mentioned above.
- False positives. No check is perfect, so keep error messages polite and let users correct themselves.
Where the DIY version stops
A hand-kept disposable list and an MX check will catch the obvious cases. They won't tell you whether the specific mailbox exists, and they miss newly launched disposable services and catch-all domains, which accept mail for any address. That gap is why I use a validation API for the last step. Gamalogic email validation API runs the mailbox and risk checks together. ZeroBounce and Kickbox have comparable APIs if you're already integrated with one of them.
How are you protecting your signup endpoints: rate limits, validation, CAPTCHAs, or a mix? I'd like to hear what's worked.
Top comments (0)