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 theemail_validatorpackage. 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_emailmethod callingPOST /v1/verify/emailfills the gap. Guard it behindfield.errorsand keep yourbs_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.