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.