# How to validate an email address in Flask

_2026-10-12 · Boundstone (https://boundstone.io/blog/validate-email-flask)_


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:

```python
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:

```bash
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:

```python
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](/blog/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](/blog/validate-email-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:

```python
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:

```json
{
  "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](/tools/email-validator) 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.
