← Blog

How to validate an email address in Spring Boot

Spring's @Email annotation stops at syntax; here's a custom ConstraintValidator that adds the deliverability layer without pretending to do more than it does.

Contents

Spring Boot hands you email validation for free. Put @Email on a field, add @Valid to the controller method, and a malformed address gets bounced with a 400 before your code runs. For a lot of forms that is the whole job, and you should not add a network call to do work a regex already does. The trouble starts when you assume @Email means "this address can receive mail." It does not. It means the string looks roughly like an email. This post keeps the annotation for what it is good at, then bolts on a second, honest layer for the part it never claimed to cover.

What @Email actually checks (and what it doesn't)

@Email comes from Jakarta Bean Validation, and in a standard Spring Boot app the implementation is Hibernate Validator. It is a syntax check. It runs a regex against the local part and the domain and returns true or false. It does not resolve DNS, it does not connect to a mail server, and it is deliberately lenient about the domain.

The lenience surprises people. By default @Email accepts user@localhost and bob@internal — no dot, no top-level domain, no MX record required. That is not a bug. Bean Validation is meant to be fast, deterministic, and offline; a regex that phoned home would violate every one of those expectations. So the annotation stays in its lane.

public record SignupRequest(
    @NotBlank
    @Email(message = "That does not look like an email address")
    String email
) {}

This is worth keeping. It rejects not-an-email and two@@ats.com instantly, on the request thread, with zero external dependencies. What it will happily wave through is carol@mailinator.com (disposable), sales@yourclient.com (a role inbox nobody reads), and dave@domain-with-no-mx.example (nothing will ever be delivered there). Those are deliverability questions, and no syntax check — in any framework — answers them.

The layer past syntax

To know whether a domain can actually accept mail, you have to look up its MX records. That is a DNS question, not a string question. (If the term is new, what is an MX record walks through it.) You also often want to know whether the domain is a known disposable provider and whether the local part is a role account rather than a person.

Boundstone's POST /v1/verify/email does exactly those three things and tells you, in every response, which checks it ran and which it did not:

{
  "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"]
  }
}

Read the not_performed array before you trust the performed one. Boundstone does not open an SMTP session to probe the mailbox, and it does not detect catch-all domains — those checks are unreliable, get you rate-limited or blocked by mail servers, and vendors who claim them are usually guessing. A mx_found: true here means the domain is set up to receive mail, not that this specific inbox exists. That distinction is the honesty contract, and it is the whole reason to trust the fields that are there.

Want to eyeball a single address before you write any code? The keyless email validator tool runs the same check in the browser.

A custom constraint that calls the API

Wire the deliverability layer in as its own annotation so it composes with @Email instead of replacing it. First the marker:

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DeliverableEmailValidator.class)
public @interface ValidEmail {
    String message() default "Email domain cannot receive mail";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

Then the validator. RestClient (Spring Framework 6.1+, Spring Boot 3.2+) is the synchronous HTTP client that fits a blocking validation call; use WebClient instead if your stack is reactive.

@Component
public class DeliverableEmailValidator
        implements ConstraintValidator<ValidEmail, String> {

    private final RestClient client;

    public DeliverableEmailValidator(
            @Value("${boundstone.api-key}") String apiKey) {
        this.client = RestClient.builder()
            .baseUrl("https://api.boundstone.io")
            .defaultHeader("Authorization", "Bearer " + apiKey)
            .build();
    }

    @Override
    public boolean isValid(String email, ConstraintValidatorContext ctx) {
        if (email == null || email.isBlank()) {
            return true; // @NotBlank / @Email own emptiness and syntax
        }
        EmailResult r = client.post()
            .uri("/v1/verify/email")
            .body(Map.of("email", email))
            .retrieve()
            .body(EmailResult.class);

        if (r == null) return true; // transport failure: fail open, your call
        return r.mxFound() && !r.disposable() && !r.roleAccount();
    }

    public record EmailResult(
        @JsonProperty("mx_found")      boolean mxFound,
        boolean disposable,
        @JsonProperty("role_account")  boolean roleAccount,
        @JsonProperty("free_provider") boolean freeProvider
    ) {}
}

Note free_provider is a returned flag, not a check in performed — Gmail and Outlook are perfectly deliverable, so decide per use case whether a free provider is a reason to reject at all. Keep the key server-side: read it from boundstone.api-key=${BOUNDSTONE_API_KEY} in application.properties, never hard-code it, and never let a bs_live_ key reach a browser or a client-side config.

Apply both annotations and let each do its half:

public record SignupRequest(
    @NotBlank @Email @ValidEmail String email
) {}

Where to put the network call (a caveat)

Bean Validation was designed to be pure and fast, and a validator that makes an HTTP request is neither. Every validated request now depends on a third-party call, so budget a timeout on the RestClient and decide deliberately whether a Boundstone outage should reject signups (fail closed) or wave them through (fail open — what the code above does). If that coupling bothers you, move the same call into your service layer after the controller returns, where retries, caching, and async handling are easier to reason about. The deeper validate an email in Java walkthrough covers those framework-agnostic patterns.

The short version

Keep @Email — it is a fast, offline syntax gate, and it is honest about being nothing more. It accepts user@localhost, so do not read it as "this email works." Add a @ValidEmail ConstraintValidator that calls POST /v1/verify/email for the MX, disposable, and role checks syntax can never do, and read the not_performed array so you know a valid is really valid. Boundstone's free tier is 250 credits a month, no card, and the credits never expire.

Frequently asked questions

How do I validate an email address in Spring Boot?

Spring Boot's Jakarta Bean Validation gives you the @Email annotation, which you put on a field and trigger with @Valid on your controller method; you can tighten it with a regex or a custom ConstraintValidator. That only confirms the string is well-formed, not that the domain can receive mail or that the mailbox exists. To go further you call a verification API from your service layer. Boundstone's email endpoint, for example, checks syntax plus MX records, disposable-domain lists and role-account lists, and its response spells out exactly what it did and did not check.

Does the @Email annotation confirm an email address is real or can receive mail?

No. @Email and any regex you add only validate the format, so user@example.com passes even if the domain has no mail server or the mailbox never existed. A stronger signal is checking that the domain publishes MX records, which shows a mail server is configured to accept its mail. Boundstone runs that MX check for you, but even a valid result only means the address is well-formed and the domain can receive mail; it does not perform SMTP mailbox verification or catch-all detection, so it is not proof the specific inbox exists.

How can I detect disposable or role-based email addresses in a Spring Boot signup form?

Bean Validation cannot do this alone because it has no knowledge of throwaway domains or shared inbox names, so you call an external verification service from your service layer and act on its response. Boundstone's email endpoint returns disposable and role_account flags, alongside free_provider and mx_found, which you can read in your Spring code to reject or step-up a suspicious signup. These are list-and-format based checks, and the response also reports what it does not do, such as SMTP mailbox and catch-all checks, so you can decide how much trust to place in a pass.

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