dotnet-input-validation
dotnet-input-validation
Comprehensive input validation patterns for .NET APIs. Covers the .NET 10 built-in validation system, FluentValidation for complex business rules, Data Annotations for simple models, endpoint filters for Minimal API integration, ProblemDetails error responses, and security-focused validation techniques.
Scope
- .NET 10 built-in validation (AddValidation, ValidatableType, source generators)
- FluentValidation validators, DI registration, endpoint filters
- Data Annotations attributes, custom ValidationAttribute, IValidatableObject
- Endpoint filters for validation as a cross-cutting concern
- ProblemDetails error responses (RFC 9457)
- Security-focused validation (ReDoS prevention, allowlist, file upload)
Out of scope
- Blazor form validation (EditForm, DataAnnotationsValidator) -- see [skill:dotnet-blazor-components]
- OWASP injection prevention principles -- see [skill:dotnet-security-owasp]
- Architectural patterns for validation placement -- see [skill:dotnet-architecture-patterns]
- Options pattern ValidateDataAnnotations -- see [skill:dotnet-csharp-configuration]
Cross-references: [skill:dotnet-security-owasp] for OWASP injection prevention, [skill:dotnet-architecture-patterns] for architectural validation strategy, [skill:dotnet-minimal-apis] for Minimal API pipeline integration, [skill:dotnet-csharp-configuration] for Options pattern validation.
Validation Framework Decision Tree
Choose the validation framework based on project requirements:
- .NET 10 Built-in Validation (
AddValidation) -- default for new .NET 10+ projects. Source-generator-based, AOT-compatible, auto-discovers types from Minimal API handlers. Best for: greenfield projects targeting .NET 10+. - FluentValidation -- when validation rules are complex (cross-property, conditional, database-dependent). Rich fluent API with testable validator classes. Best for: complex business rules, domain validation.
- Data Annotations -- when models need simple declarative validation (
[Required],[Range]). Widely understood, works with MVC model binding andIValidatableObjectfor cross-property checks. Best for: simple DTOs, shared models. - MiniValidation -- lightweight Data Annotations runner without MVC model binding overhead. Best for: micro-services with simple validation (see [skill:dotnet-architecture-patterns] for details).
General guidance: prefer .NET 10 built-in validation for new projects. Use FluentValidation when rules outgrow annotations. Do not mix multiple frameworks in the same request DTO -- pick one per model type and stay consistent.
.NET 10 Built-in Validation
.NET 10 introduces Microsoft.Extensions.Validation with source-generator-based validation that integrates directly
into the Minimal API pipeline. It auto-discovers validatable types from endpoint handler parameters and runs validation
via an endpoint filter.
Setup
// <PackageReference Include="Microsoft.Extensions.Validation" Version="10.*" />
builder.Services.AddValidation();
var app = builder.Build();
// Validation runs automatically via endpoint filter for Minimal API handlers
```text
`AddValidation()` scans for types annotated with `[ValidatableType]` and generates validation logic at compile time
using source generators, ensuring Native AOT compatibility.
### Defining Validatable Types
```csharp
[ValidatableType]
public partial class CreateProductRequest
{
[Required]
[StringLength(200, MinimumLength = 1)]
public required string Name { get; set; }
[Range(0.01, 1_000_000)]
public decimal Price { get; set; }
[Required]
[RegularExpression(@"^[A-Z]{2,4}-\d{4,8}$", ErrorMessage = "SKU format: AA-0000")]
public required string Sku { get; set; }
}
```text
The `partial` keyword is required because the source generator emits validation logic into the same type. The
`[ValidatableType]` attribute triggers code generation at compile time -- no reflection at runtime.
### How It Works
1. Source generator discovers `[ValidatableType]` classes and emits `IValidatableObject`-like validation logic.
2. `AddValidation()` registers an endpoint filter that inspects Minimal API handler parameters.
3. When a request arrives, the filter validates parameters before the handler executes.
4. On failure, returns a `ValidationProblem` response automatically.
**Gotcha:** `AddValidation()` integrates with Minimal APIs via endpoint filters. MVC controllers use their own model
validation pipeline and do not participate in this filter-based system. For controllers, Data Annotations and
`ModelState.IsValid` remain the standard approach.
---
## FluentValidation
FluentValidation provides a fluent API for building strongly-typed validation rules. It excels at complex business
validation with cross-property rules, conditional logic, and database-dependent checks.
### Validator Definition
```csharp
// <PackageReference Include="FluentValidation" Version="11.*" />
// <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.*" />
public sealed class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerId)
.NotEmpty()
.MaximumLength(50);
RuleFor(x => x.OrderDate)
.LessThanOrEqualTo(DateOnly.FromDateTime(DateTime.UtcNow))
.WithMessage("Order date cannot be in the future");
RuleFor(x => x.Lines)
.NotEmpty()
.WithMessage("Order must have at least one line item");
RuleForEach(x => x.Lines)
.ChildRules(line =>
{
line.RuleFor(l => l.ProductId).NotEmpty();
line.RuleFor(l => l.Quantity).GreaterThan(0);
line.RuleFor(l => l.UnitPrice).GreaterThan(0);
});
// Conditional rule
When(x => x.ShippingMethod == ShippingMethod.Express, () =>
{
RuleFor(x => x.ShippingAddress)
.NotNull()
.WithMessage("Express shipping requires an address");
});
}
}
```text
### DI Registration with Assembly Scanning
```csharp
// Registers all AbstractValidator<T> implementations from the assembly
builder.Services.AddValidatorsFromAssemblyContaining<Program>(ServiceLifetime.Scoped);
```csharp
### Manual Validation Pattern (Recommended)
FluentValidation's ASP.NET pipeline auto-validation is deprecated. Use manual validation in endpoint handlers or
endpoint filters instead:
```csharp
app.MapPost("/api/orders", async (
CreateOrderRequest request,
IValidator<CreateOrderRequest> validator,
AppDbContext db) =>
{
var result = await validator.ValidateAsync(request);
if (!result.IsValid)
{
return TypedResults.ValidationProblem(result.ToDictionary());
}
var order = new Order { CustomerId = request.CustomerId };
db.Orders.Add(order);
await db.SaveChangesAsync();
return TypedResults.Created($"/api/orders/{order.Id}", order);
});
```text
### FluentValidation Endpoint Filter
For reusable validation across multiple endpoints, create a generic endpoint filter (see also
[skill:dotnet-minimal-apis] for filter pipeline details):
```csharp
public sealed class FluentValidationFilter<T>(IValidator<T> validator) : IEndpointFilter
where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var argument = context.Arguments.OfType<T>().FirstOrDefault();
if (argument is null)
return TypedResults.BadRequest("Request body is required");
var result = await validator.ValidateAsync(argument);
if (!result.IsValid)
return TypedResults.ValidationProblem(result.ToDictionary());
return await next(context);
}
}
// Apply to endpoints
products.MapPost("/", CreateProduct)
.AddEndpointFilter<FluentValidationFilter<CreateProductDto>>();
```text
**Gotcha:** Do not use the deprecated `FluentValidation.AspNetCore` auto-validation pipeline. It was removed in
FluentValidation 11. Use manual validation or endpoint filters as shown above.
---
## Data Annotations
Data Annotations provide declarative validation through attributes. They work with MVC model binding, Minimal API
binding, and the .NET 10 `AddValidation()` source generator.
### Standard Attributes
```csharp
public sealed class UpdateProductDto
{
[Required(ErrorMessage = "Product name is required")]
[StringLength(200, MinimumLength = 1)]
public required string Name { get; set; }
[Range(0.01, 1_000_000, ErrorMessage = "Price must be between {1} and {2}")]
public decimal Price { get; set; }
[RegularExpression(@"^[A-Z]{2,4}-\d{4,8}$")]
public string? Sku { get; set; }
[EmailAddress]
public string? ContactEmail { get; set; }
[Url]
public string? WebsiteUrl { get; set; }
[Phone]
public string? SupportPhone { get; set; }
}
```text
### Custom ValidationAttribute
```csharp
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
public sealed class FutureDateAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext context)
{
if (value is DateOnly date && date <= DateOnly.FromDateTime(DateTime.UtcNow))
{
return new ValidationResult(
ErrorMessage ?? "Date must be in the future",
new[] { context.MemberName! });
}
return ValidationResult.Success;
}
}
// Usage
public sealed class CreateEventDto
{
[Required]
[StringLength(200)]
public required string Title { get; set; }
[FutureDate(ErrorMessage = "Event date must be in the future")]
public DateOnly EventDate { get; set; }
}
```text
### IValidatableObject for Cross-Property Validation
```csharp
public sealed class DateRangeDto : IValidatableObject
{
[Required]
public DateOnly StartDate { get; set; }
[Required]
public DateOnly EndDate { get; set; }
[Range(1, 365)]
public int MaxDays { get; set; } = 30;
public IEnumerable<ValidationResult> Validate(ValidationContext context)
{
if (EndDate < StartDate)
{
yield return new ValidationResult(
"End date must be after start date",
new[] { nameof(EndDate) });
}
if ((EndDate.ToDateTime(TimeOnly.MinValue) - StartDate.ToDateTime(TimeOnly.MinValue)).Days > MaxDays)
{
yield return new ValidationResult(
$"Date range cannot exceed {MaxDays} days",
new[] { nameof(StartDate), nameof(EndDate) });
}
}
}
```text
**Gotcha:** Options pattern classes must use `{ get; set; }` not `{ get; init; }` because the configuration binder needs
to mutate properties after construction. Validation attributes on `init`-only properties work for request DTOs but fail
for options classes bound via `IConfiguration`. See [skill:dotnet-csharp-configuration] for Options pattern validation.
---
## Endpoint Filters for Validation
Endpoint filters integrate validation into the Minimal API request pipeline as a cross-cutting concern. Filters execute
before the handler, enabling centralized validation logic.
### Generic Data Annotations Filter
```csharp
public sealed class DataAnnotationsValidationFilter<T> : IEndpointFilter
where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var argument = context.Arguments.OfType<T>().FirstOrDefault();
if (argument is null)
return TypedResults.BadRequest("Request body is required");
var validationResults = new List<ValidationResult>();
var validationContext = new ValidationContext(argument);
if (!Validator.TryValidateObject(argument, validationContext, validationResults, validateAllProperties: true))
{
var errors = validationResults
.Where(r => r.MemberNames.Any())
.GroupBy(r => r.MemberNames.First())
.ToDictionary(
g => g.Key,
g => g.Select(r => r.ErrorMessage ?? "Validation failed").ToArray());
return TypedResults.ValidationProblem(errors);
}
return await next(context);
}
}
// Apply to endpoints or route groups
products.MapPost("/", CreateProduct)
.AddEndpointFilter<DataAnnotationsValidationFilter<CreateProductDto>>();
products.MapPut("/{id:int}", UpdateProduct)
.AddEndpointFilter<DataAnnotationsValidationFilter<UpdateProductDto>>();
```text
### Combining with Route Groups
Apply validation filters at the route group level for consistent validation across all endpoints in a group (see
[skill:dotnet-minimal-apis] for route group patterns):
```csharp
var orders = app.MapGroup("/api/orders")
.AddEndpointFilter<FluentValidationFilter<CreateOrderRequest>>();
```csharp
**Gotcha:** Filter execution order matters -- first-registered filter is outermost. Register validation filters after
logging but before authorization enrichment so that invalid requests are rejected early without unnecessary processing.
---
## Error Responses
Use the ProblemDetails standard (RFC 9457) for consistent API error responses. ASP.NET Core has built-in support via
`TypedResults.ValidationProblem()` and `IProblemDetailsService`.
### ValidationProblem Response
```csharp
// Returns HTTP 400 with RFC 9457-compliant body
app.MapPost("/api/products", async (CreateProductDto dto, IValidator<CreateProductDto> validator) =>
{
var result = await validator.ValidateAsync(dto);
if (!result.IsValid)
{
// Produces: { "type": "...", "title": "...", "status": 400, "errors": { ... } }
return TypedResults.ValidationProblem(result.ToDictionary());
}
// ... create product
return TypedResults.Created($"/api/products/{product.Id}", product);
});
```text
### Customizing ProblemDetails
```csharp
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] =
context.HttpContext.TraceIdentifier;
context.ProblemDetails.Extensions["instance"] =
context.HttpContext.Request.Path.Value;
};
});
var app = builder.Build();
app.UseStatusCodePages();
app.UseExceptionHandler();
```text
### IProblemDetailsService for Global Error Handling
```csharp
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler(exceptionApp =>
{
exceptionApp.Run(async context =>
{
var problemDetailsService = context.RequestServices
.GetRequiredService<IProblemDetailsService>();
await problemDetailsService.WriteAsync(new ProblemDetailsContext
{
HttpContext = context,
ProblemDetails =
{
Title = "An unexpected error occurred",
Status = StatusCodes.Status500InternalServerError,
Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1"
}
});
});
});
```text
**Gotcha:** `ConfigureHttpJsonOptions` applies to Minimal APIs only, not MVC controllers. Validation error formatting
(e.g., camelCase property names in the `errors` dictionary) may differ between Minimal APIs and MVC if JSON options are
not configured consistently. For MVC controllers, configure via `builder.Services.AddControllers().AddJsonOptions(...)`.
---
## Security-Focused Validation
Input validation is a first line of defense against injection and abuse. These patterns complement the OWASP security
principles in [skill:dotnet-security-owasp] with practical validation techniques.
### ReDoS Prevention
Regular expressions with backtracking can be exploited to cause catastrophic performance degradation (Regular Expression
Denial of Service). Always apply timeouts or use source-generated regex.
```csharp
// PREFERRED: [GeneratedRegex] -- compiled at build time, AOT-compatible (.NET 7+).
// Combine with RegexOptions.NonBacktracking or a timeout for ReDoS safety.
public static partial class InputPatterns
{
[GeneratedRegex(@"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$",
RegexOptions.None, matchTimeoutMilliseconds: 1000)]
public static partial Regex EmailPattern();
[GeneratedRegex(@"^[A-Z]{2,4}-\d{4,8}$")]
public static partial Regex SkuPattern();
}
// Usage in validation
if (!InputPatterns.EmailPattern().IsMatch(input))
{
return TypedResults.ValidationProblem(
new Dictionary<string, string[]>
{
["email"] = ["Invalid email format"]
});
}
```text
```csharp
// FALLBACK: Regex with explicit timeout (when source generation is not available)
var pattern = new Regex(
@"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$",
RegexOptions.Compiled,
matchTimeout: TimeSpan.FromSeconds(1));
try
{
if (!pattern.IsMatch(input))
return TypedResults.BadRequest("Invalid format");
}
catch (RegexMatchTimeoutException)
{
return TypedResults.BadRequest("Input validation timed out");
}
```text
### Allowlist vs Denylist
Always prefer allowlist validation over denylist. Denylists are inherently incomplete because new attack vectors bypass
them.
```csharp
// CORRECT: Allowlist -- only permit known-good values
private static readonly FrozenSet<string> AllowedFileExtensions =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ ".jpg", ".jpeg", ".png", ".gif", ".webp" }
.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
public static bool IsAllowedExtension(string filename) =>
AllowedFileExtensions.Contains(Path.GetExtension(filename));
// WRONG: Denylist -- attackers find extensions not in the list
// private static readonly string[] BlockedExtensions = [".exe", ".bat", ".cmd", ".ps1"];
```powershell
```csharp
// Allowlist for input characters
public static bool IsValidUsername(string username) =>
username.Length is >= 3 and <= 50
&& username.All(c => char.IsLetterOrDigit(c) || c is '_' or '-');
```text
### Max Length Enforcement
Enforce maximum length on all user-controlled string inputs to prevent memory exhaustion and buffer-based attacks:
```csharp
[ValidatableType]
public partial class SearchRequest
{
[Required]
[StringLength(200)] // Always set max length on search inputs
public required string Query { get; set; }
[Range(1, 100)]
public int PageSize { get; set; } = 20;
}
```text
Also enforce at the Kestrel level for defense in depth:
```csharp
// Configure BEFORE builder.Build()
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB
options.Limits.MaxRequestHeadersTotalSize = 32 * 1024; // 32 KB
});
```text
### File Upload Validation
Validate file uploads by content type, size, and extension -- never trust the client-provided `Content-Type` header
alone:
```csharp
app.MapPost("/api/uploads", async (IFormFile file) =>
{
// 1. Validate extension (allowlist)
var extension = Path.GetExtension(file.FileName);
if (!AllowedFileExtensions.Contains(extension))
return TypedResults.ValidationProblem(
new Dictionary<string, string[]>
{
["file"] = [$"File type '{extension}' is not allowed"]
});
// 2. Validate file size
const long maxSize = 5 * 1024 * 1024; // 5 MB
if (file.Length > maxSize)
return TypedResults.ValidationProblem(
new Dictionary<string, string[]>
{
["file"] = [$"File size exceeds {maxSize / (1024 * 1024)} MB limit"]
});
// 3. Validate content by reading magic bytes (not Content-Type header)
using var stream = file.OpenReadStream();
const int headerSize = 12; // Need 12 bytes for WebP (RIFF + WEBP)
var header = new byte[headerSize];
// Guard against files shorter than header size
int bytesRead = await stream.ReadAtLeastAsync(header, headerSize, throwOnEndOfStream: false);
if (bytesRead < headerSize)
return TypedResults.ValidationProblem(
new Dictionary<string, string[]>
{
["file"] = ["File is too small to be a valid image"]
});
stream.Position = 0;
if (!IsValidImageHeader(header))
return TypedResults.ValidationProblem(
new Dictionary<string, string[]>
{
["file"] = ["File content does not match an allowed image format"]
});
// 4. Save with a generated filename (never use the original)
var safeName = $"{Guid.NewGuid()}{extension}";
var path = Path.Combine("uploads", safeName);
using var output = File.Create(path);
await stream.CopyToAsync(output);
return TypedResults.Ok(new { FileName = safeName });
})
.DisableAntiforgery(); // Only if using JWT/bearer auth, not cookie auth
static bool IsValidImageHeader(ReadOnlySpan<byte> header) =>
header[..2].SequenceEqual(new byte[] { 0xFF, 0xD8 }) // JPEG
|| header[..8].SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }) // PNG
|| header[..4].SequenceEqual("GIF8"u8) // GIF
|| (header[..4].SequenceEqual("RIFF"u8) && header[8..12].SequenceEqual("WEBP"u8)); // WebP
```text
For OWASP injection prevention beyond input validation (SQL injection, XSS, command injection), see
[skill:dotnet-security-owasp].
---
## Agent Gotchas
1. **Do not use FluentValidation auto-validation pipeline** -- it was deprecated and removed in FluentValidation 11. Use
manual validation or endpoint filters with `IValidator<T>` instead.
2. **Do not mix validation frameworks on the same DTO** -- pick one (Data Annotations OR FluentValidation OR .NET 10
built-in) per model type. Mixing causes confusing partial validation.
3. **Do not use `Regex` without a timeout or `[GeneratedRegex]`** -- unbounded regex matching on user input enables
ReDoS attacks. Always set `matchTimeout` or use source-generated regex.
4. **Do not trust client-provided `Content-Type` headers** -- validate file content by reading magic bytes. Attackers
rename executables with image extensions.
5. **Do not forget `validateAllProperties: true`** -- `Validator.TryValidateObject` without this flag only validates
`[Required]` attributes, silently skipping `[Range]`, `[StringLength]`, and others.
6. **Do not use denylist validation for security** -- denylists are inherently incomplete. Always validate against an
allowlist of known-good values.
7. **Do not omit max length on string inputs** -- unbounded strings enable memory exhaustion. Apply `[StringLength]` or
`[MaxLength]` to every user-controlled string property.
---
## Prerequisites
- .NET 8.0+ (LTS baseline for endpoint filters, ProblemDetails, Data Annotations)
- .NET 10.0 for built-in validation (`AddValidation`, `[ValidatableType]`, `Microsoft.Extensions.Validation`)
- `Microsoft.Extensions.Validation` package for .NET 10 built-in validation
- `FluentValidation` and `FluentValidation.DependencyInjectionExtensions` for FluentValidation patterns
- .NET 7+ for `[GeneratedRegex]` source-generated regular expressions
---
## References
- [Model Validation in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/mvc/models/validation?view=aspnetcore-10.0)
- [Minimal API Filters](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/min-api-filters?view=aspnetcore-10.0)
- [FluentValidation Documentation](https://docs.fluentvalidation.net/en/latest/aspnet.html)
- [ProblemDetails (RFC 9457)](https://www.rfc-editor.org/rfc/rfc9457)
- [Handle Errors in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling?view=aspnetcore-10.0)
- [OWASP Input Validation Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html)
- [.NET Regular Expression Source Generators](https://learn.microsoft.com/en-us/dotnet/standard/base-types/regular-expression-source-generators)