Skip to main content
ANVISoftware Solutions
Lesson 5 of 14Intermediate16 min

Input Validation

By the end of this lesson

Validate at trust boundaries and never rely on the client.

Validation is the decision about whether to accept a piece of input at all. It belongs at a trust boundary: the line where data your code did not produce enters code that is about to rely on it.

Input is wider than a form field, and listing it properly changes how much of an application you look at. A request body, a query string, a route value, a header, a cookie, an uploaded file's name and its contents, a message pulled off a queue, a response from another team's service, a spreadsheet a supplier emailed over. None of it was written by your code.

The rule follows from that. If it crossed the boundary, validate it — not because the sender is assumed hostile, but because your code is about to depend on assumptions the sender never agreed to. Most of what validation catches is ordinary mistakes: a quantity typed twice, a date in the wrong format, a feed whose column order changed last night.

Trust boundaries in the orders application. Each one is a place validation belongs:

  • The browser to the API — every request body, query string, route value and header
  • An uploaded CSV to the bulk order importer — the size, the declared type, the file name and every parsed field
  • A supplier's pricing feed to the import job — a third party you do not control, whose format can change without telling you
  • A queue message to the fulfilment worker — written by another service, possibly an older version of it that predates your newest field
  • Configuration to startup code — a missing or malformed connection setting should fail loudly at boot, not halfway through a request at three in the morning
  • The API to the database — a boundary too, though the answer there is parameterised queries rather than validation. That has its own lesson next

Two ways to write a rule. They sound symmetrical, and they are not:

 Allow-listDeny-list
The rule saysAccept only what matches this descriptionReject anything matching these known-bad patterns
What you must know to write itWhat valid input looks like — something you do defineEvery bad input there is — something nobody can enumerate
Default for input nobody anticipatedRejected, because it was never allowedAccepted, because no rule covered it
When a new bad pattern appearsAlready handled. Nothing to doAccepted until someone adds a rule for it
Where it fitsNearly all validation: formats, ranges, option sets, file typesRare cases where valid input is genuinely open-ended, and only as a secondary filter
How it failsRejects something legitimate you did not think of. Annoying, visible, quickly fixedAccepts something harmful you did not think of. Quiet, and found much later
CreateOrderRequest.cs — validating at the edge
C#
public sealed record CreateOrderRequest(
    string CustomerReference,
    int EmployeeId,
    string Currency,
    DateOnly RequestedDeliveryDate,
    IReadOnlyList<OrderLineRequest> Lines);

public sealed record OrderLineRequest(string Sku, int Quantity);

public static class CreateOrderValidator
{
    private static readonly string[] AllowedCurrencies = ["GBP", "EUR", "USD"];

    // Our SKUs are three letters, a dash, then four digits. Anchored at both ends.
    private static readonly Regex SkuPattern = new(
        "^[A-Z]{3}-[0-9]{4}$",
        RegexOptions.Compiled,
        TimeSpan.FromMilliseconds(100));

    public static List<string> Validate(CreateOrderRequest request, DateOnly today)
    {
        var errors = new List<string>();

        if (string.IsNullOrWhiteSpace(request.CustomerReference) ||
            request.CustomerReference.Length > 40)
        {
            errors.Add("Customer reference is required and must be 40 characters or fewer.");
        }

        if (request.EmployeeId <= 0)
        {
            errors.Add("Employee id must be a positive number.");
        }

        if (!AllowedCurrencies.Contains(request.Currency))
        {
            errors.Add("Currency must be one of GBP, EUR or USD.");
        }

        if (request.RequestedDeliveryDate < today ||
            request.RequestedDeliveryDate > today.AddYears(1))
        {
            errors.Add("Requested delivery date must be within the next year.");
        }

        if (request.Lines.Count is 0 or > 200)
        {
            errors.Add("An order needs between 1 and 200 lines.");
        }

        foreach (var line in request.Lines)
        {
            if (!SkuPattern.IsMatch(line.Sku))
            {
                errors.Add("One of the order lines has a SKU in an unrecognised format.");
            }

            if (line.Quantity is < 1 or > 1000)
            {
                errors.Add("Quantity on each line must be between 1 and 1000.");
            }
        }

        return errors;
    }
}
  • The request is a record with typed, non-nullable properties, so model binding rejects a body that is missing them or has the wrong shape before this method runs. Type is the first and cheapest validation you get, and it is free.
  • Every check here is one of four questions — type, length, format, range — and together they describe what valid looks like rather than listing what invalid looks like.
  • AllowedCurrencies is an allow-list. Three known values, everything else refused. A rule of that shape cannot be surprised by a value nobody anticipated.
  • The SKU pattern is anchored with ^ and $. An unanchored pattern matches anywhere in the string, which happily accepts a valid SKU buried inside something longer. This is a quiet and very common mistake.
  • The pattern has a match timeout. A regular expression that backtracks badly on an awkward input can burn a surprising amount of CPU, and a timeout puts a ceiling on what one request can cost you.
  • Errors are collected rather than thrown one at a time, so a user can fix a form in a single pass. The messages describe the rule without echoing the submitted value back — useful discipline, because an error message is one more place data gets rendered.
  • In an ASP.NET Core application you would usually reach for DataAnnotations attributes or a library such as FluentValidation rather than a hand-written method. The shape of the rules is the same; what matters is that they run on the server.

Four questions to ask about every field. Most validation gaps are a missing answer to one of them:

Type
Is it the kind of data this field is for — an integer, a date, a decimal, one of an enum's values? Let model binding answer this. Accepting everything as a string and parsing it by hand later is where the errors collect.
Length
Is it within a sensible bound? Limits stop a field being used to push an unreasonable amount of data into your storage, your logs or your hashing function. Derive them from the business rule and the column width rather than picking a round number.
Format
Does it match the shape the field requires — a SKU pattern, an email address, a postcode, an ISO date? Anchor your patterns. Take care not to make the rule narrower than reality: real names contain apostrophes, hyphens, spaces and accented characters, and real postcodes vary by country.
Range
Is the value possible? A quantity of zero, a negative price, a delivery date in 1970, a discount of 300 per cent. Range checks catch honest mistakes at least as often as anything else, which is why they pay for themselves quickly.

Summary

  • Validate at trust boundaries: anywhere data your code did not produce enters code that relies on it
  • Input includes bodies, query strings, headers, cookies, files, queue messages, third-party responses and configuration
  • Allow-lists beat deny-lists, because you can describe what is valid and cannot enumerate everything that is not
  • Check type, length, format and range, and keep client-side validation as a convenience rather than a control
  • Validation decides whether to accept; encoding decides how to render safely. They are separate jobs at opposite ends

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Validate the bulk importer

The bulk order importer accepts a CSV upload from an operations employee. Each row has a SKU, a quantity, a customer reference and a requested delivery date.

Write down every check you would apply before a single row reaches the database, grouped by trust boundary. Count the file itself as one boundary and each row as another.

Show solution

At the file boundary: a maximum size, checked before you read the whole thing into memory; a maximum row count; the actual content being parseable as CSV rather than trusting the extension or declared content type; a stored file name you generated rather than the one supplied; and a header row matching the columns you expect, so a changed export format fails immediately instead of silently shifting every value one column left.

At the row boundary: the same four questions per field. Type — quantity parses as an integer, date parses as a date. Length — customer reference within your column width. Format — SKU against the anchored pattern. Range — quantity between one and your per-line maximum, date not in the past and not absurdly far ahead.

Two decisions worth making deliberately rather than by accident. First, whether one bad row fails the whole file or is reported and skipped; operations staff usually want the second, with a report of what was rejected and why. Second, whether the import is transactional, so a failure halfway through does not leave half an order in the database.

The reason to separate the boundaries is that they fail differently. A file-level problem means somebody uploaded the wrong thing. A row-level problem means the file is broadly right and a few lines need attention.

Think about it

The global angle bracket filter

A colleague proposes middleware that strips angle brackets from every incoming string, as a single safeguard covering the whole application.

Give two reasons that is the wrong shape of fix, and say what you would do instead.

Show solution

First, it corrupts legitimate data. An order note reading 'fits gaps < 5cm' becomes 'fits gaps 5cm', which changes the meaning of a business record. Nobody notices until a customer queries a delivery, and by then the original text is gone — the middleware did not keep it.

Second, it addresses one character in one context and leaves the others alone. The same value may end up in an HTML attribute, in a URL, inside JavaScript, in a CSV export or in a SQL statement, and each of those has different characters that matter. Removing angle brackets does nothing for any of them, while creating a strong impression that the problem has been handled application-wide.

Instead: validate each field against what that field should contain, store exactly what the user sent, and encode at each point of output for the destination it is going to. The next two lessons cover the two destinations that come up most.

There is one honest caveat. If a field is genuinely meant to hold rich text and be rendered as markup, filtering is unavoidable — but it is a maintained HTML sanitising library with a small allow-list of elements, applied to that one field, not middleware applied to everything.

Saved in this browser only.