[FromHeader]
Bind request headers such as Idempotency-Key, If-Match and X-Correlation-Id to parameters, and handle missing, repeated and proxy-stripped headers.
[FromHeader(Name = "Idempotency-Key")] string idempotencyKey- Added in .NET ASP.NET Core 1.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$Nameoptional | string?= null (the parameter name) | The header name, for example "Idempotency-Key". Needed for any header whose name is not a valid C# identifier, which is nearly all of them. |
Return value
n/aBinds the parameter from a request header. Header names are matched case-insensitively. Headers are never inferred: the attribute is always required.
Errors
No exception. A missing header for a required parameter is a 400 (model-state error in MVC, BadHttpRequestException in minimal APIs).
Changelog
- PHP ASP.NET Core 6.0Minimal APIs honour [FromHeader].
Headers carry the metadata of a request: idempotency keys, concurrency tokens, correlation ids, tenant hints. [FromHeader] binds one to a parameter so the handler declares what it needs instead of digging in Request.Headers. Unlike route, query and body values, headers are never inferred; without the attribute a parameter called idempotencyKey would be read from the query string.
Required or optional
| Parameter | Header missing in a controller | Header missing in a minimal API |
|---|---|---|
string key (nullable context on) | 400, error keyed by the header name | 400 |
string? key | null | null |
string[] values | empty array | empty array |
A repeated header (X-Tag: a twice) binds every value into a string[] or StringValues; MVC also splits a comma-separated value (X-Tag: a, b) into two elements, which the query string never does. Typed headers bind too: [FromHeader(Name = "X-Tenant-Id")] Guid tenantId parses with the invariant culture and fails with 400 when the value is not a GUID.
When to use a header
Put values in headers when they describe how to process the request rather than what the resource is: Idempotency-Key on POST, If-Match with an ETag on PUT, Accept-Language. Identity belongs in Authorization and is handled by authentication, not bound into handlers. Values that select a resource belong in the path.
Production failure modes
- Proxies drop headers. nginx ignores headers with underscores by default (
underscores_in_headers off), soX_Tenant_Idworks locally and arrives missing in production. Use hyphens. - Clients spoof headers. Anything a client sends can be forged. Never bind
X-User-IdorX-Forwarded-Forand trust it; let forwarded-headers middleware with known proxies handle the latter. - CORS. A browser client may only send a custom header that the CORS policy lists in
WithHeaders, and can only read custom response headers listed inWithExposedHeaders. - Logging. Headers often contain secrets. Bind the ones you need and never log the whole header collection.
Example
When to use it
- Return the same payment for a retried POST /api/payments that repeats its Idempotency-Key header
- Refuse a product price update with 412 when the If-Match ETag no longer matches the stored version
- Parse an X-Tenant-Id header straight into a Guid and reject malformed values with 400 before the handler runs
Frequently asked questions
Why is my header parameter always null?
Either [FromHeader] is missing (headers are never inferred, so the value was looked up in the query string) or a proxy removed the header. Check the raw request at the API with logging before blaming the binding.
Are header names case-sensitive?
No. HTTP header names are case-insensitive and binding matches them that way; HTTP/2 even sends them in lowercase. Values are compared exactly unless you normalise them.
Can I read the Authorization header with [FromHeader]?
You can, but do not authenticate that way. Use an authentication handler so [Authorize], policies and HttpContext.User work, and the token never reaches handler code or logs.
More examples
If-Match for optimistic concurrency
The client sends back the ETag it read. If someone else changed the price in between, the versions differ and the update is refused with 412 instead of silently overwriting their change. Requiring the header (428) stops old clients from bypassing the check.
using System.Collections.Concurrent;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var prices = new ConcurrentDictionary<string, (decimal Price, int Version)>();
prices["MUG-WHITE"] = (12.50m, 3);
app.MapPut("/products/{sku}/price", (string sku, [FromHeader(Name = "If-Match")] string? ifMatch,
PriceChange change, HttpResponse response) =>
{
if (!prices.TryGetValue(sku, out var current)) return Results.NotFound();
if (ifMatch is null) return Results.StatusCode(StatusCodes.Status428PreconditionRequired);
if (ifMatch != $"\"{current.Version}\"") return Results.StatusCode(StatusCodes.Status412PreconditionFailed);
var next = (change.Price, current.Version + 1);
prices[sku] = next;
response.Headers.ETag = $"\"{next.Item2}\"";
return Results.NoContent();
});
app.Run();
public record PriceChange(decimal Price);
Header properties on a controller model
In MVC, binding attributes on properties override the source of the containing parameter, so <code>[FromQuery] RequestContext</code> still reads these two properties from headers. Minimal APIs do the same with <code>[AsParameters]</code>. Grouping headers this way keeps action signatures short when several actions need them.
using Microsoft.AspNetCore.Mvc;
namespace Shop.Api;
public class RequestContext
{
[FromHeader(Name = "X-Correlation-Id")]
public string? CorrelationId { get; set; }
[FromHeader(Name = "Accept-Language")]
public string? Language { get; set; }
}
[ApiController]
[Route("api/quotes")]
public class QuotesController(ILogger<QuotesController> logger) : ControllerBase
{
[HttpGet("{sku}")]
public IActionResult Get(string sku, [FromQuery] RequestContext context)
{
using var scope = logger.BeginScope(new Dictionary<string, object?> { ["CorrelationId"] = context.CorrelationId });
logger.LogInformation("Quote requested for {Sku}", sku);
return Ok(new { sku, price = 19.99m, language = context.Language ?? "en" });
}
}
Let underscores through nginx (or rename the header)
The cleaner fix is a hyphenated header name such as <code>X-Tenant-Id</code>, which every proxy and load balancer passes through. Turning on <code>underscores_in_headers</code> works for nginx but other hops (cloud load balancers, API gateways) may have their own rules.
# nginx.conf (server block in front of the API)
# Default: underscores_in_headers off; X_Tenant_Id is dropped silently.
server {
listen 443 ssl;
server_name api.example.com;
underscores_in_headers on; # only if renaming the header is impossible
location / {
proxy_pass http://orders-api:8080;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Discussion