Skip to main content

Block disposable emails in Django

Published Updated 23 minute readGuidesRichelo Killian

To block disposable emails in Django, validate the address server-side before you create the account, in a form's clean_email method, a reusable validator, a Django REST Framework (DRF) serializer, or a django-allauth adapter, and keep the check fail-open so a slow or failed lookup never blocks a real signup. You can do this two ways: install the official isitdisposable Python Software Development Kit (SDK), or call the detection Application Programming Interface (API) yourself with requests. Either way, run the check on the server, block on the recommended action, and let relay and alias addresses like Apple Hide My Email through, because they forward to real inboxes. The sections below give copy-pasteable Python for each of those integration points, plus the fail-open wrapper and relay handling.

I have spent more than fifteen years in email deliverability and shipped this exact check into production signup flows. The mechanics are simple and the mistakes are predictable. What follows is the version that works, with the reasoning behind each choice so you can adapt it to your own Django stack.

How do you block disposable emails in Django?

At a high level you add one server-side check to your signup path and wire your form, serializer, or adapter around it. The check takes an email address, asks the detection service whether the domain is disposable, and returns a verdict your code acts on. Everything else is about putting that check in the right layer and making it safe to depend on.

Three pieces make up a complete implementation. A validation entry point (a form's clean_email, a DRF serializer method, or an allauth adapter) owns the decision and rejects the signup on a block verdict. A small detection helper does the actual lookup, either through the official SDK or a raw call to the API, and returns a normalized result. And a fail-open design guarantees that a timeout or outage results in "allow," not a broken form.

Layer Where it runs Its job Authoritative?
Form, serializer, or adapter Server (Django request cycle) Call the check and reject on a block verdict Yes
Detection helper (check_email) Server Look up the address (SDK or raw), normalize the result, fail open on error Yes, this is the check itself
Template or JavaScript hint Browser Optional instant feedback before submit No, feedback only
Middleware Before the view Not the place for this check No, wrong layer

The single most important rule in that table is the "Authoritative?" column. The server is the only place a check can be trusted, because a determined user can bypass any client-side hint and post straight to your endpoint. Get that division right and the rest is wiring.

Why block disposable email signups at all?

Blocking disposable signups is about protecting your account standing with your Email Service Provider (ESP) and keeping your data honest. It is not about protecting your reputation at Gmail, and that distinction changes how you should think about the whole feature.

Here is the mechanism. A disposable address works just long enough to confirm a signup, then its domain expires, so your next email to it hard bounces. A spike in hard bounces is one of the strongest list-quality signals your ESP watches. Mailchimp, SendGrid, Klaviyo, and the rest share sending infrastructure across many customers, so they police bounce and complaint rates aggressively, and a sudden spike can trigger a warning, a throttle, a forced list cleaning, or account suspension. That is the real cost of letting disposables pile up.

What it does not do is damage your sender reputation at Gmail, Outlook, or Yahoo. Those mailbox providers score you on how their own users treat your mail, and a dead throwaway domain is not one of those providers, so its bounce never reaches them. I cover the two-layer distinction in full in The Complete Guide to Disposable Email Detection. For this tutorial, hold on to the practical version: you are blocking disposables to stay in good standing with your ESP and to keep your metrics trustworthy, not to defend a Gmail score that was never at risk.

Where should the disposable email check live in a Django app?

The check belongs on the server, wherever your signup input is validated. In a traditional Django app that means a form's clean_email. If you accept signups through an API, it means a DRF serializer. If you use django-allauth, it means a custom adapter. A reusable validator lets you share one rule across forms and model fields. It does not belong in the browser as the sole gate, in middleware, or in Model.save().

Placement Best for Use as the authority?
Form clean_email Server-rendered Django forms Yes
Reusable validator on EmailField Sharing one rule across forms and models Yes, when a form runs it
DRF serializer validate_email API and single-page-app signups Yes
django-allauth adapter Projects using allauth for signup Yes, covers all allauth flows
Middleware Nothing here; wrong layer, runs on every request No
Model.save() Nothing here; not a validation hook, and it skips validators No

Two of those "No" rows are worth explaining, because they look tempting. Middleware runs before the view on every request that matches its configuration, so putting an email check there adds a network call to requests that have nothing to do with signup, and it runs before your view has cleanly parsed the form. And Model.save() is not a validation hook: Django field validators run during form validation and full_clean(), not automatically on save(), so a check placed there is both the wrong layer and trivially skipped by any code path that saves directly. Keep the check where validation actually happens.

The rest of this guide builds the plain-form path first, since it is the most common, then covers the reusable validator, DRF, and allauth.

How do you block disposable emails with a Django form?

Start with configuration and the detection helper, because everything else depends on them. Store your API key as an environment variable, never in source. The isitdisposable API uses two kinds of key: sk_ secret keys for server-side use, and pk_ publishable keys that are safe in the browser and restricted to origins you approve. This check runs on the server, so it uses a secret key. Read it into Django settings from the environment, in settings.py:

import os

ISITDISPOSABLE_API_KEY = os.environ["ISITDISPOSABLE_API_KEY"]  # a secret sk_ key

Keep the value itself in your environment (a .env loaded by your process manager, your host's secrets, and so on), and out of the committed settings.py.

Now the detection helper. You have two ways to write it, and they are interchangeable: both return the same small EmailCheck value, so every form, serializer, and adapter in this guide works without change regardless of which you pick. The recommended path is the official SDK, which handles timeouts, retries, and fail-open behavior for you. This is emailcheck.py:

from dataclasses import dataclass

from isitdisposable import Client

client = Client()  # one client for the whole app; reads ISITDISPOSABLE_API_KEY


@dataclass
class EmailCheck:
    checked: bool       # False means the lookup did not run, so treat as allow
    disposable: bool    # the core verdict
    relay: bool         # forwarding or alias provider, allow these
    action: str         # "allow", "warn", or "block"


def check_email(email: str) -> EmailCheck:
    # fail_open is True by default: on a timeout, connection problem, rate
    # limit, or server error, .check() returns checked=False, action="allow"
    # and logs a warning under the "isitdisposable" logger instead of raising.
    # A missing or malformed key still raises, so you catch it in development.
    result = client.check(email=email)
    return EmailCheck(
        checked=result.checked,
        disposable=result.disposable is True,  # None (not checked) becomes False
        relay=result.relay is True,
        action=result.action,
    )

Install it with pip install isitdisposable (or uv add isitdisposable).

If you would rather not add a dependency, here is the exact same helper written against the raw API with requests, which most Django projects already have. It calls POST https://api.isitdisposable.com/v1/check with a bearer token and normalizes the response into the identical value. It is the same emailcheck.py, without the SDK:

import logging
from dataclasses import dataclass

import requests
from django.conf import settings

logger = logging.getLogger("isitdisposable")

API_URL = "https://api.isitdisposable.com/v1/check"


@dataclass
class EmailCheck:
    checked: bool
    disposable: bool
    relay: bool
    action: str


_ALLOW_ON_FAILURE = EmailCheck(
    checked=False, disposable=False, relay=False, action="allow"
)


def check_email(email: str) -> EmailCheck:
    try:
        response = requests.post(
            API_URL,
            headers={"Authorization": f"Bearer {settings.ISITDISPOSABLE_API_KEY}"},
            json={"email": email},
            timeout=2,  # fail open: cap the wait at 2 seconds
        )
    except requests.RequestException as exc:
        # Timeout or connection error: allow the signup and move on.
        logger.warning("check_email request failed, allowing: %s", exc)
        return _ALLOW_ON_FAILURE

    if response.status_code != 200:
        # A 401 (bad key) or 400 (bad input) lands here. Log it, never block.
        logger.warning("check_email non-200 response, allowing: %s", response.status_code)
        return _ALLOW_ON_FAILURE

    data = response.json()
    # The API itself fails open: an over-quota or inactive account returns a
    # 200 response with checked=false and action="allow", which maps straight through.
    return EmailCheck(
        checked=data.get("checked") is True,
        disposable=data.get("disposable") is True,  # None becomes False
        relay=data.get("relay") is True,
        action=data.get("action") or "allow",
    )

The timeout=2 is doing important work: without it, requests waits indefinitely, and a slow response would stall your signup form. Both helpers log under the isitdisposable logger name, so if you add that logger to your Django LOGGING config you will see fail-open events from either version in one place.

Now the form, in forms.py. Its clean_email runs the check and rejects the signup on a block verdict.

from django import forms

from .emailcheck import check_email


class SignupForm(forms.Form):
    email = forms.EmailField()

    def clean_email(self):
        email = self.cleaned_data["email"].strip().lower()
        result = check_email(email)

        # Honor the recommended action from your dashboard policy. By default
        # disposable maps to block and relay or alias addresses map to allow.
        # On a fail-open result the action is "allow", so nothing is blocked.
        if result.action == "block":
            raise forms.ValidationError(
                "Please sign up with an email address you check regularly. "
                "Disposable or throwaway addresses are not allowed."
            )

        return email

If you would rather decide policy in your own code instead of the dashboard, block on result.disposable directly. Just remember to let relays through explicitly in that case, since result.relay is a separate signal. Honoring action gets that right for you, which is the point of a later section.

One note on async. Django form validation runs synchronously, so the sync Client and check_email above are correct inside clean_email. If you are validating inside an async view instead, use the SDK's AsyncClient and await it, so a blocking network call never stalls the event loop:

from isitdisposable import AsyncClient

async_client = AsyncClient()  # reads ISITDISPOSABLE_API_KEY


async def acheck_email(email: str) -> EmailCheck:
    result = await async_client.check(email=email)
    return EmailCheck(
        checked=result.checked,
        disposable=result.disposable is True,
        relay=result.relay is True,
        action=result.action,
    )

Should you use the isitdisposable Python SDK or call the API yourself?

Use the SDK unless you have a specific reason not to. It is the fastest correct path: it reads your key from the environment, applies sensible timeouts, retries once, fails open by default, and ships an async client, so the common mistakes are handled before you write a line. Roll your own only when you are avoiding dependencies or need full control over the request.

Consideration Official SDK (isitdisposable) Roll it yourself (requests)
Setup pip install isitdisposable, construct a client No new dependency if you already use requests
Fail-open Built in (fail_open=True): returns an allow on network trouble, raises on misconfiguration You implement the timeout, the allow-on-failure, and the logging
Types Typed result object You read a dict yourself
Async AsyncClient with await You bring your own async HTTP client
Batch check_batch([...]) for up to 100 You call /v1/check/batch and parse it
Control Sensible defaults, less code Full control over every request detail

Both are shown in full above, and both return the same EmailCheck value, so this is a local decision inside one file. It does not affect your forms, serializers, or adapters. The isitdisposable Python SDK is one of the official clients released alongside a TypeScript SDK and a Model Context Protocol (MCP) server, covered in the announcement of the official SDKs and MCP server. If your stack is JavaScript rather than Python, the same approach in Next.js is written up separately. The full request and response reference lives at the isitdisposable docs.

How do you add a reusable disposable email validator in Django?

If more than one form needs this rule, factor it into a validator you can attach to any EmailField. A Django validator is just a callable that raises ValidationError when the value is unacceptable. Put it in validators.py:

from django.core.exceptions import ValidationError

from .emailcheck import check_email


def validate_not_disposable(value):
    result = check_email(value.strip().lower())
    if result.action == "block":
        raise ValidationError(
            "Disposable or throwaway email addresses are not allowed.",
            code="disposable",
        )

Attach it to a form field in forms.py, and it runs during that form's validation:

from django import forms

from .validators import validate_not_disposable


class SignupForm(forms.Form):
    email = forms.EmailField(validators=[validate_not_disposable])

You can attach the same validator to a model field, but with an important caveat: model field validators run during form validation and full_clean(), not on Model.save(). So a ModelForm or an explicit full_clean() call enforces the rule, while MyUser.objects.create(email=...) does not. This is the same reason Model.save() is the wrong place to put the check. Enforce at the form, serializer, or adapter, where validation actually runs, and treat the model validator as a convenience, not a guarantee.

How do you block disposable emails in Django REST Framework?

If signups come through an API or a single-page app, the check lives in a DRF serializer. A validate_<field> method is the direct analog of a form's clean_<field>, and it reuses the same helper. Put it in serializers.py:

from rest_framework import serializers

from .emailcheck import check_email


class SignupSerializer(serializers.Serializer):
    email = serializers.EmailField()

    def validate_email(self, value):
        email = value.strip().lower()
        result = check_email(email)

        if result.action == "block":
            raise serializers.ValidationError(
                "Disposable or throwaway email addresses are not allowed."
            )

        return email

When the serializer is validated in your view, a disposable address returns a clean 400 response with the field error, before you create anything. As with the form, block on result.action so relay addresses are allowed by default, or block on result.disposable if you want to apply policy in code and handle relays yourself.

How do you make the check fail-open in Django?

Fail-open means that if the detection check is slow, unreachable, or errors, the signup still succeeds. This is the rule that keeps a third-party dependency from ever costing you a real customer, and it is worth treating as non-negotiable. You never want a detection outage to become a signup outage. In this stack it holds at three layers.

  • Your wrapper. In the raw helper, a timeout or connection error and any non-200 response return the permissive ALLOW_ON_FAILURE value, and each is logged. Because the recommended action in that value is "allow," your clean_email block condition is simply skipped and the user proceeds.
  • The SDK. If you use the official client, this is already done for you. With fail_open true (the default), check() returns a safe checked=False, disposable=None, action="allow" result on a timeout, connection problem, rate limit, or server error, and logs a warning under the isitdisposable logger. Genuine setup mistakes, a missing or malformed key, or a request missing both an email and a domain, still raise, so you catch them in development. Set Client(fail_open=False) only where you want failures as exceptions, such as a background job that will retry.
  • The platform. The API fails open on its own side too. If your account is over quota or the subscription is inactive, it still returns a 200 response with checked=false and action="allow" rather than an error, and it sheds load the same safe way when a region is saturated. So even a billing lapse degrades into "allow," not a broken form.

The failure contract in the raw helper, restated, is just an allow:

_ALLOW_ON_FAILURE = EmailCheck(
    checked=False, disposable=False, relay=False, action="allow"
)

When every layer fails open, a problem anywhere in the chain degrades quietly into "allow" instead of a broken form. That is the difference between a check you can safely put in front of every signup and one you cannot. Set a tight timeout, treat any failure as allow, and route the isitdisposable logger somewhere you will see it, so you notice a check that is degrading without your users ever feeling it.

How should you handle relay and alias addresses in Django?

You should allow them. Relay and alias addresses are not disposable, and blocking them means turning away real, reachable, often paying customers. This is the most common way a well-intentioned disposable filter backfires.

A relay address forwards to a real, permanent inbox the person controls. Apple Hide My Email, Firefox Relay, SimpleLogin, addy.io, DuckDuckGo Email Protection, and Proton aliases all put a privacy layer in front of a mailbox someone actually reads. These users tend to be exactly the kind you want: Apple Hide My Email users are iCloud Plus subscribers, and Proton and SimpleLogin users are privacy-conscious people who frequently pay for software. Their addresses are deliverable and durable, so they do not expire and spike your bounces the way disposables do.

The code above already handles this correctly, and this is the payoff for blocking on action rather than on a raw signal. The detection service returns relay as its own field, separate from disposable, and the default enforcement policy maps relay to "allow." So when your form or serializer blocks only on result.action == "block", relay addresses pass through untouched with no extra work. If you instead decide policy in code by blocking on result.disposable, you take on the job of letting relays through, because they are a distinct signal. Either approach works, but honoring action is the one that is correct by default. A detection source that collapses relays into a single "bad address" flag would force you to choose between blocking real customers and letting throwaways through; keeping them separate avoids the tradeoff entirely.

How do you block disposable emails with django-allauth?

If you use django-allauth for signup, put the check in a custom account adapter rather than a one-off form. The adapter's clean_email hook is called across all of allauth's signup flows, so one override covers regular signup and social email collection alike. Put it in adapter.py:

from allauth.account.adapter import DefaultAccountAdapter
from django.core.exceptions import ValidationError

from .emailcheck import check_email


class AccountAdapter(DefaultAccountAdapter):
    def clean_email(self, email):
        email = super().clean_email(email)
        result = check_email(email.strip().lower())

        if result.action == "block":
            raise ValidationError(
                "Disposable or throwaway email addresses are not allowed."
            )

        return email

Point allauth at it in settings.py:

ACCOUNT_ADAPTER = "yourapp.adapter.AccountAdapter"

The reason to use the adapter rather than a custom signup form is coverage: the adapter sits under every allauth entry point, so you cannot forget to apply the rule on one of them. Call super().clean_email(email) first so allauth's own normalization and uniqueness checks still run, then apply the disposable check on the cleaned value. As everywhere else, blocking on action allows relays by default.

Static list or detection API in Django?

Your check_email helper can be backed two ways at the data level: a static list of disposable domains bundled into your project, or a live detection API (whether you reach it through the SDK or a raw call). The code around it, the form, the serializer, the adapter, the fail-open contract, does not change. Only where the verdict comes from does.

Consideration Static list in your project Detection API
Freshness Frozen at your last update Refreshed daily on the provider side
MX validation None, it is a text match on the domain Included, catches new domains on known infrastructure
Relay classification You build and maintain it yourself Returned as a separate relay signal
Maintenance Yours, indefinitely The provider's
Failure mode Local, cannot go down, but never gets fresher Fails open (200 with checked=false), so signups keep working

A static list is a reasonable choice for a hobby project, an offline cleanup, or an environment that cannot call an external service. Its weakness is that it is frozen the moment you commit it, and it gives you neither Mail Exchanger (MX) validation nor relay classification without building both yourself. A live API trades that maintenance for a network call, and returns fresher coverage plus the relay signal your relay handling depends on. I compare the two approaches in depth, including when a free list is genuinely the right call, in Free disposable email lists vs a live detection API. For a production signup form, the API is usually the better fit, which is why the helper in this guide is written against one.

What are common mistakes when blocking disposable emails in Django?

Most broken implementations fail in one of a handful of predictable ways. Each has a one-line fix, and every fix appears in the code above.

Mistake Why it bites Fix
Client-only validation Anyone can skip the browser and post directly to your endpoint Make the server the authority; treat any client hint as cosmetic
Blocking relay addresses You turn away real, often paying customers Block on action, which allows relays by default, or allow relay explicitly
No fail-open A slow or down check blocks real signups Use the SDK's default, or wrap requests with a timeout and allow on failure
Secret key committed in settings.py It leaks through source control to everyone with repo access Read it from the environment; keep sk_ keys out of the repo
Enforcing in Model.save() Validators do not run on save(), so the rule is silently skipped Enforce in the form, serializer, or adapter, where validation runs
Putting the check in middleware Wrong layer, adds a network call to every request Check in the form, serializer, or adapter that owns the signup
Sync Client in an async view A blocking network call stalls the event loop Use AsyncClient and await in async views

The key-handling and layering mistakes are the ones I see most in Django code. Your isitdisposable secret key is an sk_ key that belongs only on the server, so read it from the environment into settings and never commit the value; if you ever need something in the browser, that is what a pk_ publishable key or the drop-in snippet is for. And remember that Django only runs validators where validation happens: forms, serializers, and full_clean(). A check hidden in Model.save() or bolted onto middleware is either skipped or misplaced.

The rest of the mistakes reduce to a single principle: the server decides, any client hint only informs, and the check must never be able to break the form. Hold to that and a disposable email block in Django is a small, reliable piece of your signup flow.

What should you use to block disposable emails in Django?

Use a server-side check backed by a detection source that is fresh, validates Mail Exchanger records rather than matching a static file, and classifies relay and alias services separately so you can allow them. Wire it into a form, serializer, or adapter, keep it fail-open, and you are done. Those are the requirements the code in this guide is built to satisfy, whether you use the SDK or call the API yourself.

If you want a source that meets them out of the box: isitdisposable.com maintains a live dataset of more than 205,000 domains drawn from six upstream sources, validated against live MX records and refreshed daily, with Domain Name System (DNS) level detection and no mailbox probing. You can integrate it three ways: the official Python SDK shown above (pip install isitdisposable), a single API call with requests, or a drop-in JavaScript snippet for your forms. It returns relay as a signal separate from disposable and advises allowing relays, and it fails open by design, returning a safe "allow" rather than an error when it cannot respond, so your signups keep working. The full field reference is at the docs, the package is on PyPI, and there is a free plan with 250 lookups per month plus a 14-day full-access trial with no credit card, which is enough to wire it into a Django signup flow and test it against your own traffic.

To recap the whole approach: put the authoritative check on the server, before account creation, in the form, serializer, or adapter that validates the email, and make it fail open so it can never break a signup. Block on the recommended action, and let relay and alias addresses through, since they forward to real inboxes. Add a client-side hint if you want the polish, but remember it enforces nothing. Do that, with the SDK or a few lines of requests, and you have a disposable email block in Django that protects your ESP standing and your data without ever getting in a real customer's way.

About the author

Richelo Killian

Founder

Founder of isitdisposable.com and the SenderWorx email tool suite. Builds email infrastructure and anti-abuse tooling.