← Blog

How to validate an email address in Flask

The framework takes you further than you'd guess — and stops exactly one step short of the checks that catch throwaway signups.

Contents

The instinct, when a signup form needs an email field, is to paste in a regex you found and move on. Resist it. You can validate email in Flask without a regex at all, because Flask-WTF already does it through WTForms — syntax by default, and MX records if you ask. What the framework does not check is whether the address is a throwaway or a shared inbox, because WTForms has no rule for that. This post covers both halves: how far Flask takes you, and the one call that closes the gap.

What Flask-WTF checks out of the box

Flask-WTF wraps WTForms, and WTForms ships an Email validator. Add it to a field and you are done with syntax:

from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms.validators import DataRequired, Email

class SignupForm(FlaskForm):
    email = StringField("Email", validators=[DataRequired(), Email()])

Email() does not roll its own regex. Under the hood it calls the email_validator package, which parses the address against the actual specifications — length limits, quoting, internationalized domains, the parts a hand-rolled pattern gets wrong. Install it or the validator refuses to run:

pip install Flask-WTF "WTForms[email]"

By default this is a syntax check and nothing more. It confirms the address is well-formed. It does not confirm the domain exists, receives mail, or belongs to a human. For a lot of forms, well-formed is genuinely all you need — say so to yourself before reaching for anything heavier.

Turn on MX with check_deliverability

When you do want more, email_validator can resolve the domain's mail records, and WTForms exposes that through a flag:

email = StringField("Email", validators=[
    DataRequired(),
    Email(check_deliverability=True),
])

With check_deliverability=True, validation now looks up the domain's MX records and rejects the address if the domain cannot receive mail — a typo'd gmial.com, a domain that never existed. (For the mechanics of what that lookup actually resolves, see what is an MX record.)

This is a real step up, and it is also where WTForms stops. It will tell you user@mailinator.com is well-formed with a valid MX. It will not tell you Mailinator is a disposable domain, or that info@ is a role account nobody reads. Those are not syntax questions and not DNS questions, so there is no flag to flip.

The gap: disposable and role addresses

Two categories walk straight through a syntax-plus-MX check:

  • Disposable addresses — temporary inboxes from throwaway providers. They parse, they have MX, they accept your confirmation email, and they are gone in an hour.
  • Role accounts — admin@, sales@, support@. Real, deliverable, and attached to a distribution list rather than a person. They inflate signups and drag down every engagement metric they touch.

Catching these means checking the address against maintained lists, which is what Boundstone's email endpoint does. (Not on Flask, or want the plain-Python version first? See validate an email address in Python.)

Adding the check with a custom validator

WTForms lets a form define a validate_<fieldname> method that runs alongside the field's validators. Name it after the field and raise ValidationError to fail:

import os
import requests
from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms.validators import DataRequired, Email, ValidationError

BOUNDSTONE_KEY = os.environ["BOUNDSTONE_API_KEY"]  # server-side secret, never in a template

class SignupForm(FlaskForm):
    email = StringField("Email", validators=[
        DataRequired(),
        Email(check_deliverability=True),
    ])

    def validate_email(self, field):
        if field.errors:
            return  # syntax or MX already failed — don't spend a credit
        try:
            resp = requests.post(
                "https://api.boundstone.io/v1/verify/email",
                headers={"Authorization": f"Bearer {BOUNDSTONE_KEY}"},
                json={"email": field.data},
                timeout=5,
            )
            resp.raise_for_status()
        except requests.RequestException:
            return  # fail open: an API hiccup shouldn't block a real signup
        result = resp.json()
        if result["disposable"]:
            raise ValidationError("That looks like a disposable address. Use a permanent one.")
        if result["role_account"]:
            raise ValidationError("Use a personal address, not a shared inbox like info@ or sales@.")

Two things worth keeping. The if field.errors guard means you only call the API for addresses that already passed syntax and MX — no credit spent on garbage. And the key lives in an environment variable: a bs_live_ key is a server-side secret, so it belongs in your Flask process, never in a template, a hidden form field, or client-side JavaScript.

Prefer to keep the form dumb? Drop the method and make the same call in your view after form.validate_on_submit() returns true. Same result, different seam.

What the response tells you — and what it doesn't

A verified address comes back like this:

{
  "valid_syntax": true,
  "domain": "example.com",
  "mx_found": true,
  "disposable": false,
  "role_account": false,
  "free_provider": false,
  "checks": {
    "performed": ["syntax", "mx", "disposable_list", "role_list"],
    "not_performed": ["smtp_mailbox", "catch_all"]
  }
}

disposable and role_account are the two booleans your validator acts on. free_provider flags Gmail, Outlook, and the like — useful signal, not a reason to reject. And the checks object is the point of the whole thing: performed lists exactly what produced this answer — ["syntax", "mx", "disposable_list", "role_list"] — and not_performed names what it did not — ["smtp_mailbox", "catch_all"].

That second array is deliberate. Boundstone does not open an SMTP connection to the mailbox and does not probe for catch-all domains. Those checks are unreliable, they get your sending IP throttled, and a service that quietly performs them hands you a confidence it cannot back. A valid that names its own limits is a valid you can act on.

You can run the endpoint against a few addresses with no key and no form at the free email validator tool before you wire anything up.

The short version

  • Email() in Flask-WTF checks syntax via the email_validator package. Often enough — decide before adding more.
  • Email(check_deliverability=True) adds an MX lookup and rejects domains that can't receive mail.
  • Neither checks disposable or role addresses; WTForms has no rule for those.
  • A validate_email method calling POST /v1/verify/email fills the gap. Guard it behind field.errors and keep your bs_live_ key server-side.
  • Read checks.not_performed — it tells you what a "valid" is not claiming.

Boundstone's free tier is 250 verifications a month, no card, and the credits never expire — enough to protect a real signup form without a conversation with finance.

Frequently asked questions

How do I validate an email address in Flask?

In Flask you typically validate email in layers. Start with syntax validation (a regex or a library like email-validator or WTForms) to confirm the format is well-formed, then optionally check whether the domain actually has an MX record and whether the address is disposable or a role account. A single API call can do all of this from your route: Boundstone's email endpoint checks syntax, MX records, a disposable-domain list, and role accounts, returning plain booleans you can branch on. The free tier is 250 checks a month with no card and credits that never expire, so you can wire it into a signup form without upfront cost.

Does validating an email's format prove the address is real and can receive mail?

No. Confirming the syntax is well-formed, and even that the domain has an MX record, only proves the address is correctly formatted and the domain is set up to accept mail; it does not prove that the specific mailbox exists or will accept your message. Boundstone makes this distinction explicit: every response lists the checks it performed (syntax, MX, disposable-list, role-list) and the ones it did not (SMTP mailbox verification and catch-all detection are reported as not_performed). That way a 'valid' result is an honest signal you can reason about, never a delivery guarantee that quietly overpromises.

How can I block disposable or fake email addresses at signup in Flask?

Format checking alone won't stop throwaway addresses, so add a disposable-domain check and flag role accounts like info@ or support@ inside your Flask signup handler. Boundstone returns disposable, role_account, and free_provider booleans alongside mx_found (whether the domain can receive mail at all), which you can use to reject or hold suspicious signups. It does not perform SMTP mailbox verification or catch-all detection, so treat these as strong, fast filtering signals rather than proof a mailbox is live, which is usually exactly what you want for a non-intrusive signup gate.

Thomas Tsui

Founder of Boundstone — building phone, email and IP validation you can actually verify.

One honest API for email, phone and IP — every response lists what it checked and what it didn't claim to. Free tier: 250 credits/month, no card, credits never expire.

More from Boundstone — API documentation · Benchmark methodology · The benchmark series · Buyer's checklist