Mert Özen Further With Every Line
Backend ASP.NET Core MVC and Razor Pages — #07

Server-Side and Client-Side Validation: DataAnnotations, ModelState, Unobtrusive Validation and Custom Attributes

Mert Özen Nov 8, 2026 12 min 3 views
Server-Side and Client-Side Validation: DataAnnotations, ModelState, Unobtrusive Validation and Custom Attributes

Validation in MVC has two layers: DataAnnotations and ModelState are mandatory on the server, while jQuery Unobtrusive Validation driven by data-val-* is only user experience. We try IValidatableObject, AddModelError, [Remote], a custom attribute with IClientModelValidator and Turkish messages in a real browser, and show how under a Turkish culture a price of "1.5" is silently saved as 15 and how type="number" with __Invariant fixes it.

Version note: The code in this post was built and run with .NET 10 (SDK 10.0.402), Microsoft.EntityFrameworkCore.Sqlite 10.0.14, jQuery Validation 1.21.0 and jQuery Validation Unobtrusive 4.0.0.

Guess First

Bookstore's admin panel runs on a server with a Turkish culture. An editor opens the edit page of a book priced at 265,50 TL and presses "Save" without touching anything. The form does not submit; under the price field it says "The field Price must be a number." The editor changes the comma to a dot, types 265.50, and this time the form goes through. Which price is written to the database? If none, what does the user see?

Server Side: DataAnnotations and ModelState

The foundations of validation were covered in the Web API (Application Programming Interface) series post on Model Validation: DataAnnotations attributes, ModelState and FluentValidation. There, [ApiController] turned an invalid model into a 400 on its own. MVC (Model-View-Controller) has no such automatic behavior, and it should not; on an invalid form we want to show the user the same page with error messages. That is why every POST action checks ModelState.IsValid itself. This post is the view side of the same topic.

The book form's model is the BookFormViewModel from the previous two posts; in this post two attributes and one interface were added:

public class BookFormViewModel : IValidatableObject
{
    public int? Id { get; set; }

    [Required]
    [StringLength(120)]
    public string Title { get; set; } = "";

    [Required]
    [Display(Name = "ISBN")]
    [RegularExpression(@"^\d{13}$", ErrorMessage = "ISBN must be 13 digits.")]
    [Remote(action: "CheckIsbn", controller: "Books", areaName: "Admin", AdditionalFields = nameof(Id))]
    public string Isbn { get; set; } = "";

    [Range(1, 10000)]
    public decimal Price { get; set; }

    [Range(0, 1000)]
    public int Stock { get; set; }

    [Display(Name = "Published on")]
    [DataType(DataType.Date)]
    [NotInFuture]
    public DateOnly PublishedOn { get; set; } = DateOnly.FromDateTime(DateTime.Today);

    public Genre Genre { get; set; }

    [Display(Name = "Author")]
    public int AuthorId { get; set; }

    public IEnumerable<SelectListItem> Authors { get; set; } = [];

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        // A rule that looks at several fields: the error belongs to the whole form, not one field
        if (Genre == Genre.Programming && Price < 100)
        {
            yield return new ValidationResult("Programming books cost at least 100 TL.");
        }
    }
}

Two lines from the real response to a form submitted with an empty title: the input got the input-validation-error class, and the message span changed from field-validation-valid to field-validation-error and got its text. Styling these two classes in CSS (Cascading Style Sheets) is all you need:

<input class="form-control input-validation-error" type="text" data-val="true" ... id="Title" name="Title" value="" />
<span class="text-danger field-validation-error" data-valmsg-for="Title" data-valmsg-replace="true">The Title field is required.</span>

IValidatableObject.Validate is for rules that look at more than one field. When we give the ValidationResult no field name, the error enters ModelState with an empty key and appears only in the validation summary. The top of the form has <div asp-validation-summary="ModelOnly">; ModelOnly does not repeat field errors and prints only form-wide errors like this one (All prints both). When I tried to save a programming book at 50 TL:

<div class="text-danger validation-summary-errors"><ul><li>Programming books cost at least 100 TL.</li>
</ul></div>

A subtlety: when I also left the title empty in the same request, this message did not appear. Validate runs only if all property-level attributes pass. The user fixes the field errors first and sees the form-wide rule only on the second attempt.

Rules that need to ask the database do not fit in an attribute. The action asks "is this ISBN (International Standard Book Number) already registered?" and binds the error to the relevant field:

[HttpPost]
public async Task<IActionResult> Create(BookFormViewModel model, CancellationToken cancellationToken)
{
    if (ModelState.IsValid && await books.IsbnExistsAsync(model.Isbn, null, cancellationToken))
    {
        ModelState.AddModelError(nameof(model.Isbn), "This ISBN is already registered.");
    }

    if (!ModelState.IsValid)
    {
        model.Authors = await GetAuthorItemsAsync(cancellationToken);
        return View(model);
    }

    int id = await books.CreateAsync(model, cancellationToken);
    TempData["StatusMessage"] = $"Book #{id} \"{model.Title}\" was created as a draft.";
    return RedirectToAction(nameof(Index));
}

Starting the check with ModelState.IsValid && is deliberate: if the form is already invalid, we do not go to the database for nothing. The nameof(model.Isbn) key routes the message to the asp-validation-for="Isbn" span. For a form I submitted with an existing ISBN, the response was 200 with:

<span class="text-danger field-validation-error" data-valmsg-for="Isbn" data-valmsg-replace="true">This ISBN is already registered.</span>

Client Side: How Unobtrusive Validation Works

In the 5th post we saw asp-for turn DataAnnotations into data-val-* attributes. Two libraries that ship with the template read these attributes: jQuery Validation runs the rules, and jQuery Validation Unobtrusive turns attributes in the HTML (HyperText Markup Language) such as data-val-required and data-val-range-min into those rules. That is where the name "unobtrusive" comes from: you write not a single line of validation JavaScript on the page. The scripts come from _ValidationScriptsPartial and are added to the Scripts section in the form view:

@section Scripts {
    <partial name="_ValidationScriptsPartial" />
    <script src="~/js/validation.js" asp-append-version="true"></script>
}

The unobtrusive library scans the forms once when the page loads. For a form added to the DOM (Document Object Model) later (such as the htmx responses in the 11th post of this series) you need to call $.validator.unobtrusive.parse(element) by hand.

I submitted the empty form in a real browser and looked at what happened. The error messages appeared instantly:

[
  "Title: The Title field is required.",
  "Isbn: The ISBN field is required.",
  "Price: The field Price must be between 1 and 10000."
]

and the browser's network log had a single request for /Admin/Books/Create: the GET that loaded the page itself. The form never reached the server. That is the entire value of client-side validation: instant feedback for the user and one fewer pointless request. Its security value, however, is zero; we will get to that shortly.

Asking the Server with [Remote]

The client cannot know whether an ISBN is registered. The [Remote] attribute asks an action in the background when the field changes and shows the answer as a validation message. In the model above it sits on Isbn; the action it calls:

[AcceptVerbs("GET", "POST")]
public async Task<IActionResult> CheckIsbn(string isbn, int? id, CancellationToken cancellationToken)
{
    bool exists = await books.IsbnExistsAsync(isbn, id, cancellationToken);
    return exists ? Json($"ISBN {isbn} is already registered.") : Json(true);
}

The contract is simple: true if valid, otherwise the message to show, both as JSON (JavaScript Object Notation). AdditionalFields = nameof(Id) also sends the book's own id on the edit screen, so the book's own ISBN does not count as "registered". For that we added <input asp-for="Id" type="hidden" /> to the form. The generated attributes and the real responses:

data-val-remote="&#x27;ISBN&#x27; is invalid." data-val-remote-additionalfields="*.Isbn,*.Id" data-val-remote-url="/Admin/Books/CheckIsbn"
$ curl -s "http://localhost:17711/Admin/Books/CheckIsbn?Isbn=9780000000028&Id="
"ISBN 9780000000028 is already registered."
$ curl -s "http://localhost:17711/Admin/Books/CheckIsbn?Isbn=9780000000028&Id=2"
true
$ curl -s "http://localhost:17711/Admin/Books/CheckIsbn?Isbn=9780000000099&Id="
true

When I typed a registered value into the ISBN field in the browser, the network log showed GET http://localhost:17711/Admin/Books/CheckIsbn?Isbn=9780000000028&Id= → 200 OK and the server's message appeared under the field. Two things to note: remote validation sends a request on every change, so the action must be cheap (a single query on an indexed column). And remote validation is not a guarantee: two editors can enter the same ISBN at the same time, someone can switch JavaScript off. That is why the IsbnExistsAsync check in the action and the unique index in the database stay in place. Because the global antiforgery filter is on, the remote request goes out as the default GET; if you switch it to POST you need to send a token.

Your Own Attribute and Its Client Counterpart

There is no ready-made attribute for the rule "the publication date cannot be in the future". The server side is a ValidationAttribute; for the client side, the IClientModelValidator interface says which data-val-* attributes to write into the HTML:

using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;

namespace Bookstore.Web.Validation;

[AttributeUsage(AttributeTargets.Property)]
public class NotInFutureAttribute : ValidationAttribute, IClientModelValidator
{
    public NotInFutureAttribute() : base("The field {0} cannot be in the future.")
    {
    }

    public override bool IsValid(object? value) =>
        value is not DateOnly date || date <= DateOnly.FromDateTime(DateTime.Today);

    public void AddValidation(ClientModelValidationContext context)
    {
        // jQuery Unobtrusive reads these attributes and wires up the "notinfuture" rule
        MergeAttribute(context.Attributes, "data-val", "true");
        MergeAttribute(context.Attributes, "data-val-notinfuture",
            FormatErrorMessage(context.ModelMetadata.GetDisplayName()));
        MergeAttribute(context.Attributes, "data-val-notinfuture-today",
            DateOnly.FromDateTime(DateTime.Today).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
    }

    private static void MergeAttribute(IDictionary<string, string> attributes, string key, string value) =>
        attributes.TryAdd(key, value);
}

The attribute writes the rule's name, its message and one parameter (today's date) into the HTML. Taking "today" from the server is deliberate: the user's time zone or a wrongly set clock should not change the rule. The client side has two parts. addMethod is the rule itself, and adapters.add is the bridge that binds the data-val-notinfuture-* attributes to that rule:

// Client counterpart of the server-side NotInFutureAttribute
$.validator.addMethod("notinfuture", function (value, element, params) {
    // an input type="date" value is always yyyy-MM-dd, so a string comparison is enough
    return this.optional(element) || value <= params.today;
});

$.validator.unobtrusive.adapters.add("notinfuture", ["today"], function (options) {
    options.rules.notinfuture = { today: options.params.today };
    options.messages.notinfuture = options.message;
});

The generated input:

<input class="form-control" type="date" data-val="true" data-val-notinfuture="The field Published on cannot be in the future." data-val-notinfuture-today="2026-11-08" data-val-required="The Published on field is required." id="PublishedOn" name="PublishedOn" value="2026-11-08" /><input name="__Invariant" type="hidden" value="PublishedOn" />

In the browser, when I set the title to "Copy of Rivers", the ISBN to a registered value, the date to 2027-01-01 and the price to 1,5 and validated the fields, three messages appeared at once:

[
  "Isbn: ISBN 9780000000028 is already registered.",
  "Price: The field Price must be a number.",
  "PublishedOn: The field Published on cannot be in the future."
]

When I skipped JavaScript and sent the same date with curl, the server side caught it too: a 200 response with The field Published on cannot be in the future. The two sides apply the same rule in separate languages; changing one and forgetting the other is the price of this approach.

Translating Error Messages into Turkish

There are three separate message sources and each changes in a different way. The first is the messages of DataAnnotations attributes: one by one with [Required(ErrorMessage = "...")], or in bulk with resource files. The second is model binding's own messages: when a value cannot be converted ("abc" is not a number), the attributes never run and the binder produces the message. When I sent abc as the price, the message was The value 'abc' is not valid for Price. ModelBindingMessageProvider changes these (the Turkish strings are the point of this example, so they stay in the code):

builder.Services.AddControllersWithViews(options =>
{
    options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
    options.ModelBindingMessageProvider.SetAttemptedValueIsInvalidAccessor(
        (value, field) => $"'{value}' değeri {field} alanı için geçerli değil.");
    options.ModelBindingMessageProvider.SetValueMustBeANumberAccessor(
        field => $"{field} alanı sayı olmalı.");
});
<span class="text-danger field-validation-error" data-valmsg-for="Price" data-valmsg-replace="true">&#x27;abc&#x27; değeri Price alanı için geçerli değil.</span>
data-val-number="Price alanı sayı olmalı."

The second line is the client side's message: data-val-number is fed by the same provider. The third source is jQuery Validation's own messages; as long as the server sends a message through data-val-*, they never show. Note that the field name is still "Price": you need [Display(Name = "Fiyat")] or localization. Writing messages as fixed Turkish strings is enough for a single-language application; in a multilingual one, .resx files and AddDataAnnotationsLocalization come into play, which is the subject of the 10th post of this series.

The Client-Side Limit of FluentValidation

We also used FluentValidation in the Web API series. In MVC there are two things you need to know. First, the FluentValidation.AspNetCore package that adds automatic validation to the MVC pipeline is no longer maintained or supported; its README recommends calling the validator by hand inside the action with the core FluentValidation package. Second, FluentValidation is a server library. Even that package could only turn simple rules (NotNull/NotEmpty, Matches, InclusiveBetween, Email, EqualTo, MaxLength, MinLength, Length) into data-val-* attributes; rules with When/Unless conditions, Must and custom validators never ran on the client.

My practical choice is this: simple, field-level rules on the form ViewModel stay as DataAnnotations (which bring client-side validation for free), while business rules and checks that need the database live in a service or in a FluentValidation validator called by hand, returning their errors to the form through ModelState.AddModelError. Take care not to define the same rule in two places.

The Culture Trap: "1,5" or "1.5"?

Now the opening question. I started the application with LANG=tr_TR.UTF-8, that is, like a server running under a Turkish culture. On the edit page the price field was rendered like this:

<input class="form-control" type="text" data-val="true" data-val-number="The field Price must be a number." ... id="Price" name="Price" value="265,50" />

The server wrote the value with its own culture: 265,50. jQuery Validation's number rule, however, only recognizes a dot as the decimal separator. When I validated the field in the browser without touching anything, the result was false with the message The field Price must be a number.; changing the value to 265.50 gave true. So the value the server produced itself cannot pass client validation, and the user is forced to turn the comma into a dot.

What happens when the dotted value reaches the server? In the Turkish culture the dot is the thousands separator. I first sent Price=1.5 to the quick edit form that changes only the price, then Price=1,5; after each request's status line I read the book's Id|Price with sqlite3:

HTTP/1.1 302 Found
2|15.0
HTTP/1.1 302 Found
2|1.5

A 1.50 TL book silently became 15 TL. The answer to the guess is the same mechanism: 265.50 was read on the server as 26550. This time [Range(1, 10000)] came to the rescue; the response was 200 with The field Price must be between 1 and 10000. under the field. The user is puzzled, "but I typed 265.50"; and for a value that stays within the range (like 12.50), 1250 would have been saved without anyone noticing.

We saw part of the fix in the 5th post. We make the price input type="number":

<input asp-for="Price" type="number" step="0.01" class="form-control" />
<input type="number" step="0.01" class="form-control" data-val="true" ... id="Price" name="Price" value="265.5" /><input name="__Invariant" type="hidden" value="Price" />

The tag helper now writes the value culture-independently (265.5) and adds the __Invariant field next to it. The browser always sends a number input's value with a dot, even if it displays it with a comma according to the user's locale. And model binding parses the fields listed in __Invariant with the invariant culture. I verified this on the same Turkish server: with Price=1.5 and __Invariant=Price sent together, the database had 1.5; sending 265.5 without __Invariant gave 2655.0. On the edit form, client validation returned true and the record stayed at 265.5. The same applies to date fields; fields rendered as type="date" through [DataType(DataType.Date)] already get this protection.

For a field that has to stay type="text" (for a display with thousands separators, say), both sides must speak the same culture: choosing the request culture deliberately on the server, and overriding jQuery Validation's number method for that culture on the client. How the request culture is chosen is, again, in the 10th post.

In Production

Client-side validation is a user experience feature, not a security measure. We bypassed every client rule in this post with curl and showed that the server caught it too; a rule missing on the server side is a hole, no matter how nicely it works on the client.

Do not leave your servers' culture to an assumption. The developer machine may be Turkish, the container image invariant, the production server English, and the same code reads three different numbers in three environments. The 15 TL bug above is exactly where "works on my machine" comes from. Set the culture explicitly at application start, and take number and date fields through number/date inputs wherever possible.

Measure your [Remote] actions. A request arrives not on every keystroke but on every field change; on a busy form that means far more requests than page views. Rate limiting and a fast query are a must.

Common Pitfalls

Relying only on client-side validation. JavaScript can be switched off, the request can be sent with curl, the remote check is subject to race conditions. Every rule must exist on the server too.

Forgetting to check ModelState.IsValid. MVC has no automatic 400 like [ApiController]; if you do not check, the invalid model silently goes to the service.

Not showing form-wide errors. On a form without asp-validation-summary, errors with an empty key (IValidatableObject, AddModelError(string.Empty, ...)) appear nowhere; the user does not understand why the form was not saved.

Taking culture-sensitive numbers through type="text". The "1.5" versus "1,5" difference shows up as a block on the client and as silent data corruption on the server.

Writing the client counterpart of a custom attribute and forgetting to add the script to the page. data-val-notinfuture sits in the HTML but no adapter reads it; the rule silently does not run on the client and only the server catches it.

A Small Experiment

★☆☆ Write [Range(0, 1000, ErrorMessage = "Stok 0 ile 1000 arasında olmalı.")] on the Stock field. Does the message change both on the client and on the server? Look at the data-val-range attribute.

★★☆ Add a MaxDaysAhead property to NotInFutureAttribute: for pre-orders, the publication date may be at most N days ahead. Carry the parameter to the client as data-val-notinfuture-maxdays and update the adapter.

★★★ Start the application under a Turkish culture and make the price input type="text" again. Override jQuery Validation's $.validator.methods.number method so that it accepts numbers with a comma. Does 1,5 pass on the client, and is it saved as 1.5 on the server? What breaks when you open the same page under an English culture?

We have finished the MVC side of Block A: controllers, Razor, partials and view components, tag helpers, forms and validation. The admin panel works, but every screen needs a controller action, a view and the route rules in between. In the next post we rewrite the same CRUD (Create, Read, Update, Delete) with Razor Pages and look at PageModel, handler methods, page routing and how the file layout changes compared to MVC.