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
# 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
# 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
# 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
# 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
# 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 exception

What 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.