Backend Development with .NET
Session 12
Input Validation
Eng. Seif Mansour  ·  Andalusia Academy
Week 6  ·  2 hours
Session Goals

By the end of this session, you will be able to:

  • Explain why server-side validation is mandatory, regardless of what the frontend does
  • Validate request data using built-in DataAnnotations attributes
  • Write richer, testable validation rules using FluentValidation
  • Return consistent validation error responses in the ProblemDetails format
  • Understand what sanitization is, when it applies, and how it differs from validation
  • Prevent over-posting and mass assignment vulnerabilities in request DTOs
Session Agenda
Time Segment Type Duration
0:00Why validation belongs on the serverTheory10 min
0:10DataAnnotations — the basicsTheory + Demo20 min
0:30FluentValidation — richer rulesDemo30 min
1:00Sanitization & dangerous inputsTheory15 min
1:15Lab — validate the task creation endpointLab35 min
1:50Wrap-upDiscussion10 min
Why Validation Belongs on the Server
Frontend validation improves the user experience. Server-side validation enforces the contract. A motivated attacker will bypass every browser check using a raw HTTP request — the server is the only place that cannot be bypassed.
In this section
  • The client-server trust boundary
  • What happens without server validation
  • The principle: never trust the client
The Client-Server Trust Boundary
  • Frontend validation is UX — it catches mistakes early and gives instant feedback, reducing round trips and frustration
  • Server-side validation is security — it is the only check that cannot be bypassed by a client, a proxy, or a script
  • Bypassing frontend validation is trivial — Postman, curl, and browser developer tools all allow sending arbitrary HTTP requests with no React code involved
  • The backend cannot know who the caller is — it might be your React app, a mobile app, a third-party integration, or a malicious actor; all look identical at the HTTP level
  • Invalid data that reaches the database is expensive to fix — constraint violations crash requests; corrupt data silently degrades the application for all users
Rule
Every field that a client can supply must be validated on the server before it is used, stored, or passed to any downstream system — regardless of what the frontend already validated.
What "Never Trust the Client" Means in Practice

These are real scenarios that happen in production when server validation is missing:

  • A title field with no length limit is exploited to store 10 MB of text, degrading database performance for all users
  • A past due date is accepted and causes broken business logic in scheduling queries
  • A negative price value is stored, creating a billing error that issues refunds instead of charges
  • An HTML payload in a text field is rendered unescaped and executes in another user's browser
From the React developer's perspective
You have used APIs where a bad request returned a clear error message telling you exactly which field was wrong and why. That response came from server-side validation. Without it, bad data would silently enter the system and cause unpredictable failures later.
ASP.NET Core makes this easy
The [ApiController] attribute, DataAnnotations, and FluentValidation together make server validation declarative — you describe the rules, the framework enforces them automatically before your action code runs.
Key Principle
"Frontend validation is UX.
Server-side validation is security."
A client can be replaced, modified, or bypassed entirely. The server is the single source of truth for what data is acceptable. Every rule that matters for correctness or safety must be enforced there — the frontend is a courtesy, not a contract.
DataAnnotations
DataAnnotations are attributes from the System.ComponentModel.DataAnnotations namespace that you place directly on your DTO properties. When combined with [ApiController], ASP.NET Core validates the model automatically and returns 400 before your action code runs.
In this section
  • Common built-in annotation attributes
  • Annotating a request DTO
  • How [ApiController] triggers automatic validation
  • The validation error response shape
Common DataAnnotation Attributes
Attribute What it validates ErrorMessage parameter
[Required]Value is not null or emptyYes
[MaxLength(n)]String length does not exceed nYes
[MinLength(n)]String length is at least nYes
[Range(min, max)]Numeric value within boundsYes
[EmailAddress]Basic email format checkYes
[RegularExpression]Value matches a regex patternYes
[StringLength(max)]Max and optional min lengthYes
Custom attributes
You can create custom validation attributes by subclassing ValidationAttribute and overriding IsValid. This keeps the annotation style but allows arbitrary logic — useful for rules like "date must be in the future."
Annotating a Request DTO
Models/CreateTaskRequest.cs
using System.ComponentModel.DataAnnotations;

public class CreateTaskRequest
{
    [Required(ErrorMessage = "Title is required.")]
    [MaxLength(200, ErrorMessage = "Title cannot exceed 200 characters.")]
    public string Title { get; set; } = "";

    [MaxLength(2000, ErrorMessage = "Description cannot exceed 2000 characters.")]
    public string? Description { get; set; }

    [Range(1, 5, ErrorMessage = "Priority must be between 1 and 5.")]
    public int Priority { get; set; } = 1;

    public DateTime? DueDate { get; set; }
}

Attributes stack — a property can carry multiple annotations. Each rule is checked independently. All violations are collected and returned together in the 400 response body, not one at a time.

How [ApiController] Triggers Automatic Validation

Without [ApiController], you would check ModelState.IsValid manually in every action. With it, ASP.NET Core intercepts the request before the action runs:

  • If model state is valid, the action executes normally
  • If model state is invalid, the framework returns 400 Bad Request immediately — your action code never runs
  • The response body is a ValidationProblemDetails object containing all errors, grouped by field name
  • No manual if (!ModelState.IsValid) guard needed in any action
Controllers/TasksController.cs
[ApiController]
[Route("api/tasks")]
public class TasksController : ControllerBase
{
    [HttpPost]
    public IActionResult Create(
        [FromBody] CreateTaskRequest req)
    {
        // Reaching here means req is valid.
        // [ApiController] already returned 400
        // if any annotation rule was violated.
        var task = _service.Create(req);
        return CreatedAtAction(
            nameof(GetById),
            new { id = task.Id },
            task);
    }
}
Validation Error Response Shape

ASP.NET Core returns a ValidationProblemDetails object — an extension of the RFC 7807 ProblemDetails format with an added errors dictionary:

  • type — a URI identifying the error class
  • title — human-readable summary
  • status — 400
  • errors — dictionary keyed by field name, each value is an array of error messages for that field
Consistent with Session 05
This is the same ProblemDetails envelope established in Session 05, so all error responses — validation or otherwise — share the same top-level shape.
HTTP 400 Response Body
{
  "type": "https://tools.ietf.org/html/rfc7807",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Title": [
      "Title is required."
    ],
    "Priority": [
      "Priority must be between 1 and 5."
    ]
  }
}
FluentValidation
DataAnnotations work well for simple constraints but become unwieldy for conditional rules, cross-property checks, and async database lookups. FluentValidation expresses rules as code, keeps validators in dedicated classes, and is straightforward to unit-test.
In this section
  • Why FluentValidation over DataAnnotations
  • Installing the package
  • Writing a validator class
  • Registering validators in the DI container
  • Conditional rules and custom predicates
Why FluentValidation Over DataAnnotations
  • Complex rules are readable — conditional logic (.When), cross-property checks, and chained predicates read like English sentences rather than attribute soup on a model class
  • Validators are testable in isolation — a CreateTaskRequestValidator is a plain class; you can write unit tests against it without an HTTP request or a controller
  • Rules live in one place — validation logic is not scattered across DTO properties, controller actions, and service methods
  • Rich built-in rulesNotEmpty, MaximumLength, GreaterThan, Matches (regex), Must (custom predicate), and async variants for database checks
  • Error messages are centralized.WithMessage() at the rule level; no need to repeat error strings in attribute constructors across multiple DTOs
Installing FluentValidation

One NuGet package adds FluentValidation with built-in ASP.NET Core integration:

dotnet add package FluentValidation.AspNetCore

The FluentValidation.AspNetCore package includes:

  • FluentValidation — the core library with all rule builders
  • ASP.NET Core integration — hooks validators into the model binding pipeline so validation errors appear in ModelState automatically

Register all validators from the current assembly in Program.cs:

Program.cs
using FluentValidation;
using FluentValidation.AspNetCore;

builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<
    CreateTaskRequestValidator>();
AddValidatorsFromAssemblyContaining
This scans the assembly for every class that inherits from AbstractValidator<T> and registers them all with the DI container. You do not need to register each validator individually.
Writing a Validator Class
Validators/CreateTaskRequestValidator.cs
using FluentValidation;

public class CreateTaskRequestValidator : AbstractValidator<CreateTaskRequest>
{
    public CreateTaskRequestValidator()
    {
        RuleFor(x => x.Title)
            .NotEmpty().WithMessage("Title is required.")
            .MaximumLength(200).WithMessage("Title cannot exceed 200 characters.");

        RuleFor(x => x.Priority)
            .InclusiveBetween(1, 5)
            .WithMessage("Priority must be between 1 and 5.");

        RuleFor(x => x.DueDate)
            .GreaterThan(DateTime.UtcNow)
            .When(x => x.DueDate.HasValue)
            .WithMessage("Due date must be in the future.");
    }
}

Each RuleFor call targets one property. Rules chain with . — all chained rules apply to the same property in order. .When() makes the preceding rule conditional.

Conditional Rules and Custom Predicates

Two mechanisms handle rules that cannot be expressed with built-in methods:

  • .When(predicate) — applies the preceding rule only when the predicate is true. Useful for optional fields that have constraints when present
  • .Must(predicate) — runs a custom boolean function against the value. Receives the entire model as a second parameter if you need cross-property logic
  • .MustAsync(predicate) — the async variant, for rules that query the database (e.g., checking uniqueness)
Keep validators focused
Async database queries in validators are powerful but add latency. Use them only when a rule genuinely requires a round trip — uniqueness checks, for instance.
Validators/CreateTaskRequestValidator.cs
// Conditional: only validate if the field has a value
RuleFor(x => x.Description)
    .MaximumLength(2000)
    .WithMessage("Description cannot exceed 2000 characters.")
    .When(x => x.Description is not null);

// Custom predicate: reject HTML in the title
RuleFor(x => x.Title)
    .Must(title => !title.Contains('<'))
    .WithMessage("Title must not contain HTML tags.");

// Cross-property rule: end date after start date
RuleFor(x => x.EndDate)
    .GreaterThan(x => x.StartDate)
    .When(x => x.EndDate.HasValue && x.StartDate.HasValue)
    .WithMessage("End date must be after start date.");
Validator for the Update Request
Validators/UpdateTaskRequestValidator.cs
using FluentValidation;

public class UpdateTaskRequestValidator : AbstractValidator<UpdateTaskRequest>
{
    public UpdateTaskRequestValidator()
    {
        RuleFor(x => x.Title)
            .NotEmpty().WithMessage("Title is required.")
            .MaximumLength(200).WithMessage("Title cannot exceed 200 characters.")
            .Must(t => !t.Contains('<')).WithMessage("Title must not contain HTML tags.");

        RuleFor(x => x.Description)
            .MaximumLength(2000)
            .WithMessage("Description cannot exceed 2000 characters.")
            .When(x => x.Description is not null);

        RuleFor(x => x.DueDate)
            .GreaterThan(DateTime.UtcNow)
            .When(x => x.DueDate.HasValue)
            .WithMessage("Due date must be in the future.");
    }
}

Each request DTO gets its own validator class. This keeps rules separate and makes unit-testing each set of rules straightforward without coupling them to the controller.

Sanitization & Dangerous Inputs
Validation rejects inputs that break your rules. Sanitization cleans inputs that could become dangerous when used in a different context — rendered as HTML, interpolated into a query, or passed to an operating system command. Both are necessary, but they serve different purposes.
In this section
  • Validation vs. sanitization — definitions and when each applies
  • HTML encoding and XSS prevention
  • SQL injection and EF Core's built-in protection
  • Over-posting and mass assignment vulnerabilities
  • File upload validation
Validation vs. Sanitization — Definitions
  • Validation — decide whether an input is acceptable. If not, reject it and return an error. The input never enters the system. Example: a title longer than 200 characters is rejected with a 400 response
  • Sanitization — transform an input so it is safe to use in a specific context. The input is accepted but cleaned before storage or rendering. Example: HTML-encode <script> before saving user-provided description text
  • The strategies are complementary — validate first to reject obviously bad inputs, then sanitize the accepted inputs before using them in a context where they could cause harm
  • Context matters — the same string that is safe in a JSON response might be dangerous when rendered in HTML, inserted into a SQL query, or passed to a shell command
Principle of least surprise
Prefer rejecting bad input over silently transforming it. Sanitization is appropriate when you genuinely need to accept rich content — such as a description field that allows formatting. For simple scalar fields, rejection is always cleaner.
HTML Encoding and XSS Prevention

Cross-Site Scripting (XSS) occurs when user-supplied text containing HTML or JavaScript is rendered unescaped in a browser. The script runs with the privileges of the page.

  • Prevention strategy 1 — reject: use a FluentValidation .Must() rule to reject any input containing < or > characters when HTML is not expected
  • Prevention strategy 2 — encode: when a field genuinely accepts HTML (e.g., a rich-text description), HTML-encode it before storing or returning it
  • JSON responses are safe by default — System.Text.Json escapes special characters automatically
Services/TaskService.cs
using System.Text.Encodings.Web;

public TaskItemDto Create(CreateTaskRequest req)
{
    var task = new TaskItem
    {
        Title = req.Title,
        // Encode any HTML in the description
        // before persisting to the database.
        Description = req.Description is null
            ? null
            : HtmlEncoder.Default.Encode(
                req.Description)
    };

    _context.Tasks.Add(task);
    _context.SaveChanges();
    return _mapper.Map<TaskItemDto>(task);
}
SQL Injection and EF Core's Built-In Protection

SQL injection occurs when user input is concatenated directly into a SQL query string. The input can change the meaning of the query — reading, deleting, or modifying data the caller should not access.

  • EF Core LINQ queries are safe — all parameters are sent as SQL parameters, never interpolated into the query text
  • Raw SQL requires care — when you must use raw SQL, use FromSqlInterpolated (parameterized) rather than FromSqlRaw with string concatenation
  • Never concatenate user input into a SQL string — this applies to any database access, not just EF Core
// Safe: EF Core parameterizes this automatically
var tasks = await _context.Tasks
    .Where(t => t.Title.Contains(searchTerm))
    .ToListAsync();

// Safe: FromSqlInterpolated uses SQL parameters
var tasks = await _context.Tasks
    .FromSqlInterpolated(
        $"SELECT * FROM Tasks WHERE Title LIKE {search}")
    .ToListAsync();

// UNSAFE: string concatenation — never do this
var sql = "SELECT * FROM Tasks WHERE Title LIKE '%"
    + searchTerm + "%'";
var tasks = await _context.Tasks
    .FromSqlRaw(sql)   // vulnerable to injection
    .ToListAsync();
Over-Posting and Mass Assignment
  • Over-posting occurs when a client sends more fields than the endpoint expects. If the server maps the entire request body directly to an entity, the extra fields silently overwrite properties that should not be settable from the outside
  • Classic example — a CreateUserRequest has only Name and Email, but a client sends an extra "IsAdmin": true field that maps onto the entity and elevates their own privileges
  • The fix is to use dedicated request DTOs — never bind directly to an entity class in a controller action. The DTO defines exactly which fields the client is allowed to supply
  • Request DTOs are the validation boundary — only properties on the DTO exist as far as model binding is concerned; any extra fields in the JSON are ignored
Never bind directly to entity classes
Accepting [FromBody] TaskItem task in a controller action exposes every property on the entity — including ones like CreatedAt, OwnerId, or IsDeleted — to overwriting. Always use a purpose-built request DTO.
Validating File Uploads
  • Validate the extension — maintain an allowlist of permitted extensions (e.g., .jpg, .png, .pdf) and reject anything else. Never use a blocklist — attackers enumerate extensions you forgot to block
  • Validate the MIME type — read the Content-Type header and compare it against the extension. A file named photo.jpg with Content-Type: application/x-executable is not a photo
  • Validate the file size — set MaxRequestBodySize in Kestrel to prevent multi-GB uploads from exhausting server memory before your action code can reject them
  • Never trust the filename — normalize the filename with Path.GetFileName to strip directory traversal sequences before using it in a file path
  • Store outside the web root — uploaded files should not be directly accessible by URL; serve them through a controller action that enforces authorization
Lab — Validate the Task Creation Endpoint

Add FluentValidation to the Task API and verify that the error response shape matches the ProblemDetails format from Session 05.

1 Install FluentValidation.AspNetCore and register AddFluentValidationAutoValidation and AddValidatorsFromAssemblyContaining in Program.cs
2 Create CreateTaskRequestValidator: title not empty, max 200 characters, no HTML tags; due date must be in the future if provided
3 Create UpdateTaskRequestValidator with the same title and due date rules applied to the update DTO
4 Verify the error response shape in Postman: the errors dictionary should be keyed by field name and the status should be 400
5 Test edge cases: empty title, title longer than 200 characters, a past due date, and a title containing <script> tags
Summary
  • Server-side validation is mandatory — the frontend can be bypassed; the server cannot. Every field the client supplies must be validated before it is used or stored
  • DataAnnotations handle simple rules declaratively — attributes on DTO properties are enforced automatically by [ApiController], which returns 400 before the action runs
  • FluentValidation handles complex rules as code — conditional rules, cross-property checks, and custom predicates live in dedicated, unit-testable validator classes
  • Validation rejects; sanitization cleans — encode HTML before rendering, use EF Core parameterized queries to prevent SQL injection, and use dedicated DTOs to prevent over-posting
  • Error responses follow the ProblemDetails format — the errors dictionary groups messages by field name, keeping the API consistent with the error handling established in Session 05
What's Next

Session 13 — Authentication Theory & JWT

  • Sessions 9–12 built a fully persisted, shaped, and validated data layer — the API now enforces what data it accepts. Session 13 introduces authentication: enforcing who is allowed to make requests at all
  • You will learn how stateless authentication works with JSON Web Tokens (JWT) — what they contain, how they are signed, and how the server verifies them without a session store
  • The Task API will gain a /auth/login endpoint that issues a signed JWT, and all task endpoints will be protected with [Authorize]
  • The validators you wrote today become the first line of defence on the login endpoint — rejecting malformed credentials before any authentication logic runs
Before next session
Complete the assignment and make sure all five lab edge cases produce the expected error responses in Postman. You will build on this validated request shape in Session 13 when the login DTO is introduced.
Assignment

Add full FluentValidation coverage to the Task Manager API and verify consistent error responses.

What to build:

  • Write CreateTaskRequestValidator and UpdateTaskRequestValidator covering: non-empty title, max 200 characters, no HTML tags in title, and due date in the future when provided
  • Register both validators and AddFluentValidationAutoValidation in Program.cs — remove any overlapping DataAnnotation attributes from the DTOs to avoid duplicate error messages
  • Confirm the task creation and update endpoints return a 400 with a well-formed ValidationProblemDetails body when any rule is violated

Acceptance criteria:

  • Sending an empty title to POST /api/tasks returns 400 with errors.Title containing the expected message
  • Sending a title of 201 characters returns 400 with a max-length message
  • Sending a title containing <script> returns 400 with the no-HTML-tags message
  • Sending a past due date returns 400 with the future-date message
  • Sending a valid request returns 201 with the created task — no validation errors
Bonus
Write unit tests for both validator classes using xUnit. Each test should instantiate the validator directly, call validator.Validate(request), and assert on result.IsValid and the specific error messages — no HTTP request or controller required.
Questions?
Session 12 — Input Validation