Model Binding and Validation

This section documents ASP.NET MVC 5.3.x, ASP.NET Web API 2.2, ASP.NET Web Pages 3, OWIN/Katana, SignalR 2, and ASP.NET Identity 2 — all running on .NET Framework 4.8.1 — the System.Web-hosted MVC framework, its routing, Razor views, HTML helpers, model binding, filters, and the OWIN-based authentication/Identity stack — as described by the official documentation at Microsoft Learn (plus Web API, Web Pages, SignalR, and Identity), which are the reference these pages are written and verified against.

This is not ASP.NET Core MVC. ASP.NET MVC 5 is the pre-Core, System.Web-hosted MVC framework; it is functionally frozen and receives only security fixes. For the current, cross-platform MVC framework see MVC Controllers and Views under ASP.NET Core (Blazor).

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page documents ASP.NET MVC 5.3.x model binding on .NET Framework 4.8.1 — not ASP.NET Core’s model binding, which shares the same conceptual model but a different type set (see Model Binding and Validation under ASP.NET Core).

Binding sources and precedence

DefaultModelBinder fills action parameters and complex objects by checking value providers in order — route data, then the query string, then posted form values (and uploaded files via a distinct HttpPostedFileBase/HttpFileCollectionBase path) — the first provider with a matching key wins:

[HttpPost]
public ActionResult Search(string q, int page = 1)
{
    // "q" and "page" are pulled from route data, then querystring, then form -- whichever supplies them first
}

Prefixes and complex/collection binding

A prefix scopes binding to a sub-object, useful when a form posts multiple objects or when field names don’t match property names directly:

@Html.TextBoxFor(m => m.ShippingAddress.Street)     <!-- generates name="ShippingAddress.Street" -->
public ActionResult Save([Bind(Prefix = "ShippingAddress")] Address address) { ... }

Collections bind from indexed or unindexed name patterns MVC’s binder understands natively:

<input name="Items[0].Name" /> <input name="Items[0].Qty" />
<input name="Items[1].Name" /> <input name="Items[1].Qty" />
public ActionResult Save(List<LineItem> items) { ... }   // binds Items[0], Items[1], ... automatically

[Bind(Include/Exclude)] and the over-posting hazard

Binding a request straight onto an EF entity is convenient and dangerous: a client can post any field name, including ones no visible form field exposes (e.g. IsAdmin, AccountBalance) — over-posting / mass assignment. [Bind] restricts which properties the binder is allowed to set:

[HttpPost]
public ActionResult Edit([Bind(Include = "Name,Email,Address")] Customer customer)
{
    // customer.IsAdmin can never be set from this action, regardless of what the client posts
}

Exclude is the inverse (deny-list) and is generally the weaker choice — a newly added sensitive property is bound by default unless the exclude list is remembered and updated. The safer, and now generally preferred, fix is to bind to a dedicated view model that simply has no IsAdmin property at all, then map the validated view model onto the entity explicitly:

public class CustomerEditViewModel { public string Name { get; set; } public string Email { get; set; } }

[HttpPost]
public ActionResult Edit(CustomerEditViewModel model)
{
    if (!ModelState.IsValid) return View(model);
    var customer = _repository.Find(model.Id);
    customer.Name = model.Name;
    customer.Email = model.Email;                 // IsAdmin is simply never touched
    _repository.Update(customer);
    return RedirectToAction("Index");
}

IModelBinder, ModelBinderProvider, IValueProvider

A custom binder handles a type the default binder can’t map cleanly (e.g. a value object parsed from a single string):

public class MoneyModelBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName)?.AttemptedValue;
        return decimal.TryParse(value, NumberStyles.Currency, CultureInfo.CurrentCulture, out var amount)
            ? new Money(amount) : null;
    }
}
// Global.asax.cs: ModelBinders.Binders.Add(typeof(Money), new MoneyModelBinder());

IValueProvider (and ValueProviderFactories.Factories) is the abstraction the binder reads from (route data, form, query string, JSON body); a custom IValueProvider can add a new source (e.g. HTTP headers) without touching every binder that needs it.

DataAnnotations and IValidatableObject

public class RegisterViewModel : IValidatableObject
{
    [Required, StringLength(100, MinimumLength = 2)]
    public string Name { get; set; }

    [Required, RegularExpression(@"^[^@]+@[^@]+\.[^@]+$")]
    public string Email { get; set; }

    [Range(18, 120)]
    public int Age { get; set; }

    [Compare(nameof(Password))]
    public string ConfirmPassword { get; set; }
    public string Password { get; set; }

    [Remote("IsEmailAvailable", "Account")]         // AJAX call back to the server during client validation
    public string EmailForRemoteCheck { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext context)
    {
        if (Age < 21 && Name?.StartsWith("Admin") == true)
            yield return new ValidationResult("Admin accounts require an age of 21+.", new[] { nameof(Age) });
    }
}

IValidatableObject runs after all DataAnnotations attributes pass, for rules that span multiple properties.

ModelState

[HttpPost]
public ActionResult Register(RegisterViewModel model)
{
    if (!ModelState.IsValid) return View(model);      // re-render with validation messages intact

    if (_users.EmailExists(model.Email))
        ModelState.AddModelError(nameof(model.Email), "That email is already registered.");

    if (!ModelState.IsValid) return View(model);
    // ... create the user ...
    return RedirectToAction("Index");
}

ModelState accumulates both binding failures (a non-numeric value posted to an int property) and DataAnnotations failures automatically; AddModelError adds application-level rules on top.

Unobtrusive client validation

MVC 5 renders data-val-* attributes from the same DataAnnotations, and the jquery.validate
jquery.validate.unobtrusive scripts (bundled by the default template — see Bundling and Client-Side Integration) read those attributes to validate in the browser before a form ever posts, without hand-written JavaScript per field:

<input type="email" data-val="true" data-val-regex="..." data-val-required="The Email field is required." ... />
<!-- web.config -->
<appSettings>
  <add key="ClientValidationEnabled" value="true" />
  <add key="UnobtrusiveJavaScriptEnabled" value="true" />
</appSettings>

[AllowHtml] and request validation

ASP.NET’s request validation rejects any posted value that looks like markup (<, <, …​) by default, as a blunt XSS defense. [AllowHtml] opts a specific property out for cases where HTML input is legitimate (a rich-text editor field) — it must be paired with sanitizing that value before ever rendering it back with Html.Raw (see Security Hardening):

public class ArticleViewModel
{
    [AllowHtml]
    public string BodyHtml { get; set; }     // sanitize (e.g. with HtmlSanitizer) before ever calling Html.Raw on this
}

Next: Filters covers the cross-cutting pipeline that runs around every action, including where [ValidateAntiForgeryToken] fits.