Integration guide · Django
How to block disposable emails in Django
One validator keeps throwaway inboxes and dead domains out of your users table. Plug it into a signup form, django-allauth or Django REST Framework. No API key, and a slow lookup never blocks a real signup.
By the Vetto team · Updated
Quick answer
Write a validator that takes the domain after the @, looks it up, and raises ValidationError when the domain is a throwaway service or cannot receive mail. Attach it to the email field of your signup form, or call it from your allauth adapter or DRF serializer. Cache each domain for a day and let the signup through if the lookup is slow.
Why EmailField is not enough
EmailField and EmailValidator only check the format. x7@mailinator.com is a perfectly valid address, and so is every one of the 9,000+ throwaway domains people use to take a second free trial, skip your email confirmation or flood a waiting list. To stop them you need to know what the domain is, not just what it looks like.
1. Write the validator
It calls the free keyless lookup with a 2-second timeout and stores the answer in Django’s cache for a day. It needs requests (pip install requests):
# accounts/validators.py
import requests
from django.core.cache import cache
from django.core.exceptions import ValidationError
LOOKUP_URL = "https://usevetto.com/v1/audit"
DEAD = {"nxdomain", "no_mx_a", "null_mx", "no_mail"}
def validate_not_disposable(email):
if "@" not in (email or ""):
return # EmailField reports malformed addresses
domain = email.rsplit("@", 1)[1].strip().lower()
key = f"vetto:domain:{domain}"
result = cache.get(key)
if result is None:
try:
response = requests.post(LOOKUP_URL, json={"domains": [domain]}, timeout=2)
result = (response.json().get("results") or {}).get(domain)
except (requests.RequestException, ValueError):
return # timeout, network error or bad JSON: fail open
if result is None:
return # rate limited or no answer: fail open, don't cache
cache.set(key, result, 60 * 60 * 24) # one lookup per domain per day
if result.get("disposable"):
raise ValidationError(
"Please use a permanent email address. Temporary inboxes are not accepted.",
code="disposable_email",
)
if result.get("status") in DEAD:
raise ValidationError(
"This email domain cannot receive mail. Please check it for typos.",
code="dead_email_domain",
)2. Use it in your signup flow
A plain Django form
# accounts/forms.py
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User
from .validators import validate_not_disposable
class SignupForm(UserCreationForm):
email = forms.EmailField(required=True, validators=[validate_not_disposable])
class Meta(UserCreationForm.Meta):
model = User
fields = ("username", "email")django-allauth
allauth sends every new address through the account adapter’s clean_email, for signup and when a user adds an email later. Override it in a custom adapter:
# accounts/adapter.py
from allauth.account.adapter import DefaultAccountAdapter
from .validators import validate_not_disposable
class AccountAdapter(DefaultAccountAdapter):
def clean_email(self, email):
email = super().clean_email(email)
validate_not_disposable(email) # raises ValidationError
return email
# settings.py
ACCOUNT_ADAPTER = "accounts.adapter.AccountAdapter"Django REST Framework
DRF runs Django validators on serializer fields and returns their message in a 400 response:
# accounts/serializers.py
from rest_framework import serializers
from .validators import validate_not_disposable
class RegisterSerializer(serializers.Serializer):
email = serializers.EmailField(validators=[validate_not_disposable])
password = serializers.CharField(write_only=True, min_length=8)3. Test it
Sign up with x7@mailinator.com: you get “Please use a permanent email address”. A domain that does not exist, like jane@doesnotexist-xyz-123.com, gets “This email domain cannot receive mail”. Gmail, Outlook and Apple Hide My Email go through. In your test suite, mock the HTTP call so tests stay fast and offline:
# accounts/tests.py
from unittest import mock
import requests
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase
from .validators import validate_not_disposable
def answer(domain, **result):
response = mock.Mock()
response.json.return_value = {"ok": True, "results": {domain: {"mx": [], "disposable": False, **result}}}
return response
class NotDisposableTests(SimpleTestCase):
def setUp(self):
cache.clear()
@mock.patch("accounts.validators.requests.post")
def test_rejects_throwaway(self, post):
post.return_value = answer("mailinator.com", status="skipped", disposable=True)
with self.assertRaises(ValidationError):
validate_not_disposable("x7@mailinator.com")
@mock.patch("accounts.validators.requests.post", side_effect=requests.Timeout)
def test_fails_open(self, post):
validate_not_disposable("jane@slow.example") # no exceptionWhat the lookup returns
The validator calls POST /v1/audit. For each domain you get a status (ok, nxdomain, no_mx_a, null_mx, no_mail), its mail servers and a disposable flag. Privacy relays such as Apple Hide My Email, Firefox Relay, DuckDuckGo, SimpleLogin and Proton Pass come back as not disposable, because a real person reads them. The free tier allows 300 calls a day per IP; with the daily cache that covers most signup forms. Try any domain by hand on the disposable email checker.
Going further: one person, many accounts
The other common fake signup is one Gmail inbox reused through dots and +tags: j.a.n.e+1@gmail.com, jane+2@gmail.com and jane@googlemail.com all reach the same person. Vetto Pro’s /v1/check endpoint (Authorization: Bearer sk_..., body {"email": "..."}) catches those, plus VPN and data-center signups and your own deny list, and logs every check in a dashboard. See how free-trial abuse works and the Gmail alias checker.
Other guides: Laravel · Supabase · Firebase · how to detect fake signups.
Frequently asked questions
How do I block disposable emails in Django?
Write a validator that looks up the email domain and raises ValidationError when it is a throwaway service, then add it to the email field of your signup form, your allauth adapter or your DRF serializer. The validator on this page uses a free lookup, caches each domain for a day and fails open.
Does it work with django-allauth?
Yes. Override clean_email in a custom account adapter and call the validator there. allauth uses clean_email for signup and when a user adds an email address, so both paths are covered.
Does it work with Django REST Framework?
Yes. Pass it in the validators list of a serializers.EmailField. DRF runs Django validators and turns their ValidationError into a 400 response with your message.
Will this block Apple Hide My Email or Firefox Relay?
No. Privacy relays forward to a real inbox, so the lookup reports them as not disposable and the validator lets them through.
What happens if the lookup is down?
The validator returns without raising, so the signup goes through. A 2-second timeout keeps your form fast, and failed lookups are not cached.
Give your signup form sharp eyes.
Free on one site. One line of HTML. Remove it any time and your form works exactly as before.
Founding offer: Pro for life for $69 once, first 100 buyers, until Nov 27.