Security Headers and Input Handling

Add the response headers that matter for an API, cap request sizes, keep SQL parameterised and encode output for where it is rendered.

Syntaxcontext.Response.OnStarting(() => { context.Response.Headers.XContentTypeOptions = "nosniff"; context.Response.Headers.ContentSecurityPolicy = "default-src 'none'; frame-ancestors 'none'"; return Task.CompletedTask; });

ASP.NET Core sends almost no security headers on its own. Kestrel adds Server: Kestrel, which you remove with AddServerHeader = false, and no security headers at all. For a JSON API the useful set is small and the same for every response, so a few lines of middleware are enough; a header library is optional.

Headers for a JSON API

HeaderValueWhy
X-Content-Type-OptionsnosniffStops a browser rendering a JSON or upload response as HTML
Content-Security-Policydefault-src 'none'; frame-ancestors 'none'If a response is ever opened as a page, it can load and run nothing and cannot be framed
X-Frame-OptionsDENYFraming protection for older browsers
Referrer-Policyno-referrerURLs with ids or tokens do not leak to other sites
Cache-Controlno-store on personal dataShared caches and proxies do not keep customer records
Strict-Transport-SecurityFrom UseHstsCovered with HTTPS

Set them in Response.OnStarting, not before next(). When an endpoint throws, UseExceptionHandler clears the response, headers included, before writing the ProblemDetails body; headers set up front vanish from exactly the error responses an attacker probes, while an OnStarting callback still runs.

Request size

Kestrel accepts bodies up to 30,000,000 bytes (about 28.6 MB) by default; the form reader's own 128 MB multipart limit never comes into play because Kestrel's limit is hit first. A JSON API that takes orders needs kilobytes. Lower Limits.MaxRequestBodySize globally and raise it only on the endpoints that need it with RequestSizeLimitAttribute metadata; an oversized body gets 413 when it is read. Deep JSON nesting and huge arrays are separate limits, covered with JSON strictness.

SQL injection

The rule is short: values always travel as parameters, and identifiers such as a sort column or direction, which cannot be parameters, come from an allow-list of known names, never from request text. Raw SQL in EF Core covers the FromSql versus FromSqlRaw trap and the EF1002 analyzer, and the Dapper lesson covers parameter objects and allow-listed sorting.

Output encoding

Encoding belongs where data is rendered, because only the renderer knows the context. ASP.NET Core's JSON options use UnsafeRelaxedJsonEscaping, so a product named <script> is returned verbatim, unlike plain JsonSerializer, which escapes it. That is safe for application/json with nosniff; the front end must still render it as text. When the API itself produces HTML (emails, PDFs from templates) or CSV, encode for that format: HtmlEncoder.Default for HTML, and neutralise cells starting with =, +, - or @ so spreadsheets do not run them as formulas.

Production failure modes

  • Error responses without headers, because they were set before next().
  • A 30 MB JSON body accepted by an endpoint that expects 2 KB, buffered into memory by model binding.
  • A report endpoint building ORDER BY from the query string.
  • Logging whole request bodies, which captures passwords and card numbers.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Make every response from the orders API, including 500 ProblemDetails bodies, carry nosniff and a deny-all Content-Security-Policy.
  • Cap JSON bodies at 64 KB globally while the product CSV import endpoint alone accepts 5 MB.
  • Export customer names to CSV for finance with formula characters neutralised, so a name of =HYPERLINK(...) is shown as text in the spreadsheet.

More examples

Body size limits on controllers

The global Kestrel limit is the floor every endpoint gets; <code>RequestSizeLimit</code> overrides it per action, lower or higher. A multipart upload has a second ceiling, <code>MultipartBodyLengthLimit</code> (about 128 MB by default), so raise both together on the upload action. The limit is checked when the body is read, and model binding reads it before the action runs, so an oversized order gets 413 without reaching <code>Create</code>.

Example · csharp
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(kestrel => kestrel.Limits.MaxRequestBodySize = 64 * 1024);
builder.Services.AddControllers();

var app = builder.Build();
app.MapControllers();
app.Run();

public record CreateOrder(string CustomerId, string Sku, int Quantity);

[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    // An order is a few hundred bytes; 16 KB leaves room and rejects anything absurd with 413.
    [HttpPost]
    [RequestSizeLimit(16 * 1024)]
    public IActionResult Create(CreateOrder order) => Created($"/api/orders/1001", order);

    // The invoice attachment upload is the one place that needs megabytes.
    [HttpPost("{id:int}/attachments")]
    [RequestSizeLimit(10 * 1024 * 1024)]
    [RequestFormLimits(MultipartBodyLengthLimit = 10 * 1024 * 1024)]
    public IActionResult Attach(int id, IFormFile file) =>
        file.Length == 0 ? BadRequest() : Ok(new { id, file.FileName, file.Length });
}

Security headers as a middleware class

A class keeps the policy in one tested place instead of an inline lambda in <code>Program.cs</code>. The static callback with <code>HttpContext</code> as state avoids a closure allocation per request. A deny-all CSP breaks an HTML documentation page, so the one HTML route gets its own policy rather than the whole API being weakened. <code>Cache-Control</code> is only defaulted, so endpoints that set their own caching keep it.

Example · csharp
public sealed class SecurityHeadersMiddleware(RequestDelegate next)
{
    public Task InvokeAsync(HttpContext context)
    {
        context.Response.OnStarting(static state =>
        {
            var ctx = (HttpContext)state;
            var headers = ctx.Response.Headers;
            headers.XContentTypeOptions = "nosniff";
            headers.XFrameOptions = "DENY";
            headers["Referrer-Policy"] = "no-referrer";

            // The API reference page is real HTML with scripts; everything else is JSON.
            headers.ContentSecurityPolicy = ctx.Request.Path.StartsWithSegments("/docs")
                ? "default-src 'self'; frame-ancestors 'none'"
                : "default-src 'none'; frame-ancestors 'none'";

            if (ctx.User.Identity?.IsAuthenticated == true && !headers.ContainsKey("Cache-Control"))
            {
                headers.CacheControl = "no-store"; // personal data stays out of shared caches
            }
            return Task.CompletedTask;
        }, context);

        return next(context);
    }
}

public static class SecurityHeadersExtensions
{
    // Register first, before UseExceptionHandler, so error responses get the headers too.
    public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app) =>
        app.UseMiddleware<SecurityHeadersMiddleware>();
}

Encoding output for HTML and CSV

A product name like <code>&lt;img src=x onerror=...&gt;</code> is harmless in JSON but becomes script in an HTML email unless it goes through <code>HtmlEncoder</code>. CSV has its own injection: a cell starting with <code>=</code> is run as a formula by spreadsheet software, so a customer name of <code>=HYPERLINK(...)</code> becomes a phishing link in the finance team's export. Each output format needs its own encoder.

Example · csharp
using System.Text;
using System.Text.Encodings.Web;

public record InvoiceLine(string Product, int Quantity, decimal UnitPrice);

public static class InvoiceRendering
{
    // HTML email body: every value from the database is encoded for HTML.
    public static string ToHtml(string customerName, IEnumerable<InvoiceLine> lines)
    {
        var html = new StringBuilder();
        html.Append("<p>Dear ").Append(HtmlEncoder.Default.Encode(customerName)).Append(",</p><table>");
        foreach (var line in lines)
        {
            html.Append("<tr><td>").Append(HtmlEncoder.Default.Encode(line.Product))
                .Append("</td><td>").Append(line.Quantity)
                .Append("</td><td>").Append(line.UnitPrice.ToString("0.00"))
                .Append("</td></tr>");
        }
        return html.Append("</table>").ToString();
    }

    // CSV export: quote every cell and defuse spreadsheet formulas.
    public static string ToCsvCell(string value)
    {
        if (value.Length > 0 && "=+-@\t\r".Contains(value[0]))
        {
            value = "'" + value; // shown as text, not executed, when opened in a spreadsheet
        }
        return "\"" + value.Replace("\"", "\"\"") + "\"";
    }
}

Discussion

  • Be the first to comment on this lesson.