Skip to content
Start with 100 free verification credits
Qualisend
All articles
Engineering / May 8, 2026

Validate an email address in ASP.NET Core

7 minutes read

Qualisend team
Code window titled Program.cs validating an email in ASP.NET Core through three narrowing layers — syntax, MX, mailbox — to a green deliverable badge.

To validate an email address in ASP.NET Core, most apps lean on the built-in [EmailAddress] data annotation, let model binding run it, and check ModelState.IsValid. That's a solid first move — the attribute ships with the framework and rejects garbage without a hand-rolled regex — but it answers only the first of the three questions real validation asks. Is the address shaped correctly? Can its domain receive mail? Does the mailbox actually exist? ASP.NET Core answers the first out of the box, needs one well-known NuGet package for the second, and leaves the third to a network problem worth handing off. This guide builds the check as three layers wired into the framework's own validation pipeline and shows exactly where [EmailAddress] stops. It's the web-framework companion to the C# email validation guide, which covers the same primitives outside the request pipeline.

The short answer#

Use [EmailAddress] (or FluentValidation's .EmailAddress()) for syntax, DnsClient.NET for the MX lookup, and a verification API for the SMTP mailbox check — cheapest first, short-circuiting as soon as one layer is decisive. The DNS and API layers are async I/O, so they don't fit a synchronous ValidationAttribute; the idiomatic home for them is a FluentValidation validator with services injected through DI. Don't open raw SMTP sockets from your app to probe mailboxes yourself: outbound port 25 is blocked on most hosts, and the answer depends on sending-IP reputation and greylisting you don't want to reimplement. Each layer rules addresses out more cheaply than the last; only the final one can rule an address in. If the pipeline is new to you, what email verification is covers the terms first.

Layer 1: format validation with DataAnnotations#

The built-in layer-one check is the [EmailAddress] attribute from System.ComponentModel.DataAnnotations. Put it on your request model next to [Required], and model binding runs it on every bind:

using System.ComponentModel.DataAnnotations;

public class SignupRequest
{
    [Required]
    [EmailAddress]
    public string Email { get; init; } = string.Empty;
}

In an MVC or API controller marked [ApiController], a failed attribute short-circuits into a 400 ValidationProblemDetails response before your action body runs — you never have to inspect ModelState by hand:

using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("signup")]
public class SignupController : ControllerBase
{
    [HttpPost]
    public IActionResult Register(SignupRequest request)
    {
        // With [ApiController], an invalid [EmailAddress] already returned 400.
        // Reaching here means the string is well-formed.
        return Ok();
    }
}

Know exactly what this buys you. EmailAddressAttribute checks shape — in fact it only requires a single @ with something either side — and nothing more. It never resolves DNS, never opens a socket, and has no idea whether the domain exists. definitely-fake@gmail.com passes. info@company-that-folded.com passes. typo@gmial.com passes. All three are undeliverable, and no attribute that only reads the string will ever tell you so — the same reason regex email validation fails: shape and deliverability are different questions, one a fact about the string and the other a fact about the internet.

Why the next two layers can't be attributes#

Here's the catch that shapes the rest of this guide: DataAnnotations validation is synchronous. ValidationAttribute.IsValid returns a ValidationResult, not a Task, so there's no clean way to await a DNS query or an HTTP call inside one. Blocking on .Result or .GetAwaiter().GetResult() to force an async call into a sync attribute invites thread-pool starvation and deadlocks under load — exactly what you don't want on a signup path.

That's why the idiomatic ASP.NET Core answer for the async layers is FluentValidation: its MustAsync rules are first-class Task-returning validators, and it resolves dependencies through the same DI container as the rest of your app, so a validator can take an HttpClient or a DNS service in its constructor. You can keep [EmailAddress] for layer one and add FluentValidation for two and three, or — as below — let FluentValidation's own .EmailAddress() cover syntax too and keep all three layers in one place.

Layer 2: can the domain receive mail?#

A domain with no mail route can't accept mail for anyone, so one lookup eliminates dead domains, misspelled company names, and invented TLDs. The catch: ASP.NET Core has no built-in MX resolver. System.Net.Dns resolves A and AAAA records but not MX, so the standard approach is DnsClient.NET — a widely-used NuGet package. Wrap it behind a small interface so the validator depends on an abstraction, not the library:

using DnsClient; // dotnet add package DnsClient
using System.Linq;

public interface IMailRouteChecker
{
    Task<bool> HasMailRouteAsync(string email, CancellationToken ct = default);
}

public sealed class DnsMailRouteChecker : IMailRouteChecker
{
    private readonly ILookupClient _dns;

    public DnsMailRouteChecker(ILookupClient dns) => _dns = dns;

    public async Task<bool> HasMailRouteAsync(string email, CancellationToken ct = default)
    {
        var domain = email[(email.LastIndexOf('@') + 1)..];
        try
        {
            var response = await _dns.QueryAsync(domain, QueryType.MX, cancellationToken: ct);
            return response.Answers.MxRecords().Any();
        }
        catch (DnsResponseException)
        {
            // No reachable resolver, SERVFAIL, and similar — treat as "check later".
            return false;
        }
    }
}

Split the domain off at the last @ with a range expression so you never mis-parse an address that legally contains more than one. Register a single LookupClient as a singleton — it's thread-safe and caches responses, so a per-request instance just throws that cache away. Some domains accept mail on an A record with no MX; if you want to honour that edge case, fall back to QueryType.A when the MX set is empty.

Layer 3: does the mailbox actually exist?#

Layers one and two can only rule an address out. A domain can publish perfect MX records and still have no mailbox at the address you're holding — noreply-9f2x@gmail.com is valid syntax on a live mail route, and it's still a mailbox that was never created. Confirming a specific mailbox means the SMTP delivery conversation: connect to the mail host, issue RCPT TO, read the reply, and disconnect before sending anything. How email verification works walks that full pipeline, catch-all domains and all.

In principle you can script this in C# with a TcpClient and raw SMTP commands. In practice you shouldn't run it from your app server: most cloud providers block outbound port 25, the answer depends on the reputation of the IP you connect from, and receiving servers greylist and rate-limit unfamiliar senders. This is the layer worth delegating to infrastructure built for it.

Qualisend's verify endpoint runs the whole pipeline from reputation-managed infrastructure and returns a verdict in real time. Model it as a typed HttpClient so the base URL, timeout, and key live in one place, and deserialize the response envelope with System.Text.Json:

using System.Net.Http.Json;
using System.Text.Json.Serialization;

public interface IEmailVerifier
{
    Task<bool> IsDeliverableAsync(string email, CancellationToken ct = default);
}

public sealed class QualisendVerifier : IEmailVerifier
{
    private readonly HttpClient _http;

    public QualisendVerifier(HttpClient http) => _http = http;

    public async Task<bool> IsDeliverableAsync(string email, CancellationToken ct = default)
    {
        using var response = await _http.PostAsJsonAsync("verify", new { email }, ct);

        // On our own outage or a rate limit, don't block a real signup.
        if (!response.IsSuccessStatusCode)
            return true;

        var body = await response.Content.ReadFromJsonAsync<VerifyResponse>(ct);
        return body?.Result.Status != "undeliverable";
    }
}

public record VerifyResponse(
    [property: JsonPropertyName("result")] VerifyResult Result);

public record VerifyResult(
    [property: JsonPropertyName("status")] string Status,
    [property: JsonPropertyName("score")] int Score,
    [property: JsonPropertyName("reason")] string? Reason,
    [property: JsonPropertyName("sub_flags")] IReadOnlyDictionary<string, bool> SubFlags);

The response comes back inside a result envelope:

{
  "result": {
    "status": "deliverable",
    "score": 95,
    "reason": null,
    "sub_flags": { "role": false, "disposable": false, "free": false, "catch_all": false }
  }
}

status is your headline verdict — deliverable, risky, undeliverable, or unknown. The helper above hard-fails only undeliverable and lets everything else through, but score, reason, and sub_flags (role, disposable, free, catch-all) are there so you can apply your own policy — hold risky for review, flag disposable at signup. POST https://api.qualisend.com/v1/verify is a placeholder shape; check the API reference on /developers for the exact endpoint, the scoped-key format, and every field. Don't invent fields from the sample above — read them from the docs.

Putting the layers together to validate an email address in ASP.NET Core#

Now compose all three in a FluentValidation validator. Chain the rules cheapest first and set CascadeMode.Stop so it bails at the first failing layer — the async DNS and API rules only run on addresses that already passed syntax:

using FluentValidation;

public class SignupRequestValidator : AbstractValidator<SignupRequest>
{
    public SignupRequestValidator(IMailRouteChecker dns, IEmailVerifier verifier)
    {
        RuleFor(x => x.Email)
            .Cascade(CascadeMode.Stop)   // bail at the first failing layer
            .NotEmpty()
            .EmailAddress()              // layer 1: syntax
            .MustAsync((email, ct) => dns.HasMailRouteAsync(email, ct))
                .WithMessage("Enter an email on a domain that can receive mail.")
            .MustAsync((email, ct) => verifier.IsDeliverableAsync(email, ct))
                .WithMessage("We couldn't confirm a mailbox at this address.");
    }
}

Wire the services in Program.cs — the LookupClient as a singleton, the verifier as a typed client with the key read from configuration (an environment variable or user-secrets in production, never hard-coded), and the validator by assembly scan:

using DnsClient;
using FluentValidation;
using System.Net.Http.Headers;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<ILookupClient>(new LookupClient());
builder.Services.AddScoped<IMailRouteChecker, DnsMailRouteChecker>();

builder.Services.AddHttpClient<IEmailVerifier, QualisendVerifier>(client =>
{
    client.BaseAddress = new Uri("https://api.qualisend.com/v1/");
    client.Timeout = TimeSpan.FromSeconds(10);
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
        "Bearer", builder.Configuration["Qualisend:ApiKey"]);
});

builder.Services.AddValidatorsFromAssemblyContaining<SignupRequestValidator>();

var app = builder.Build();

app.MapPost("/signup", async (
    SignupRequest request,
    IValidator<SignupRequest> validator,
    CancellationToken ct) =>
{
    var result = await validator.ValidateAsync(request, ct);
    if (!result.IsValid)
        return Results.ValidationProblem(result.ToDictionary());

    // Every layer passed — safe to persist.
    return Results.Ok();
});

app.Run();

Injecting IValidator<SignupRequest> and calling ValidateAsync in the endpoint is the current recommended pattern — it runs the async rules correctly, where the old automatic MVC integration never did. The same validator drops into an MVC controller unchanged; resolve it from the constructor and call ValidateAsync before you touch the database.

That ordering is the whole trick: the syntax rule short-circuits junk for free, the local DNS check rules out dead domains for nothing, and the API is hit only for addresses that cleared both. It's the same three-layer shape as the Node.js and C# guides — the layering, not the framework, is what makes validation reliable. Run this fast pipeline synchronously at signup and reserve deeper, batched verification for cleaning an existing list, where latency doesn't matter and thoroughness does.

Frequently asked questions#

Is the EmailAddress attribute enough to validate an email in ASP.NET Core?#

For syntax, yes — it's the right layer-one check and a better bet than a hand-rolled regex. But EmailAddressAttribute validates shape (really just a single @ with text either side); it never resolves DNS or contacts a mail server, so a ModelState.IsValid pass means "looks like an email," not "will deliver." Pair it with an MX lookup and an SMTP mailbox check before you trust the address.

How do I run async email validation in ASP.NET Core?#

DataAnnotations attributes are synchronous, so a DNS query or an API call doesn't belong in one — blocking on the async call risks deadlocks. Use FluentValidation instead: write MustAsync rules, inject your DNS and verification services into the validator, and call await validator.ValidateAsync(request) from your endpoint or controller. That's the current recommended approach, since the old automatic MVC integration never ran async validators.

Does ASP.NET Core have a built-in way to look up MX records?#

No. System.Net.Dns resolves A and AAAA host records but has no MX support, which is why the standard approach is the DnsClient.NET NuGet package. Query QueryType.MX, register one LookupClient as a singleton, and treat an empty answer set — or a DnsResponseException — as "no mail route" rather than letting it throw.

Should I validate emails at signup or when cleaning a list?#

Both, at different depths. Run syntax and the MX lookup synchronously at signup — they're fast enough to block the request and give instant feedback — and act on the immediate API verdict there too; the serverless signup guide shows the pattern end to end. Reserve deeper, batched verification for cleaning an existing list, where latency doesn't matter.


Ready to add the SMTP layer? The API reference has the exact /verify shape, scoped keys, and copy-paste examples, or drop one address into the free email checker to watch an [EmailAddress]-approved string come back undeliverable.

Your reputation, protected.

Clean your first list in minutes. 100 free credits, no card required.

Get started