Skip to content
Shiny.NET

Content Digests

Shiny.AspNet.ContentDigest adds RFC 9530 integrity fields to an ASP.NET Core app: it verifies Content-Digest on request bodies as they stream, and answers Want-Content-Digest / Want-Repr-Digest on responses. sha-256 and sha-512 are supported — the two the RFC registers as active. It is trim and Native AOT clean.

GitHub GitHub stars for shinyorg/aspnet
Downloads NuGet downloads for Shiny.AspNet.ContentDigest
Frameworks
ASP.NET
Terminal window
dotnet add package Shiny.AspNet.ContentDigest
using Shiny.AspNet.ContentDigest;
builder.Services.AddContentDigest();
var app = builder.Build();
app.UseContentDigest(); // after routing; before UseRequestDecompression / UseResponseCompression

TLS already protects the bytes in transit, so what is this for? The hops TLS does not cover. A service is usually reached through something — a load balancer, an API gateway, a reverse proxy, a tunnel — and each of those terminates TLS and re-frames the message. A digest is end to end: the sender computes it and the recipient checks it, whatever sat in the middle. It also catches the upload a flaky connection truncated and a buggy client never noticed.

POST /upload HTTP/1.1
Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
{"hello": "world"}

ASP.NET Core has no built-in RFC 9530 support; this package fills that gap.

On by default once UseContentDigest() is in the pipeline. A request carrying Content-Digest is hashed as it is read — an upload headed for disk is never held in memory just to be checked — and held to every supported algorithm the header names.

The request… Response
matches Passes through untouched
does not match 400 problem details, with Want-Content-Digest saying what the server checks
names only algorithms the server does not know 400 — or passes unverified with RejectUnsupportedAlgorithms = false
has a malformed header 400
has a body but no header, on an endpoint that requires one 400

Rejections are written with Results.Problem, so a registered IProblemDetailsService (AddProblemDetails()) shapes them like the rest of your errors.

The verdict exists only once the body has been read to the end, so the read that finishes it throws a BadHttpRequestException instead of returning the last bytes. A handler that deserializes the body therefore fails before it acts. One that streams to disk sees the failure in place of a clean end, and should discard what it wrote. A handler that catches the failure and answers anyway is overruled: while a request is being verified its response is held (up to MaxBufferedResponseBytes), and the 400 replaces it.

Require a digest on an endpoint that must not accept a truncated upload:

app.MapPost("/firmware", Upload).RequireContentDigest();

A client asks with Want-Content-Digest (or Want-Repr-Digest), giving each algorithm a preference from 0 to 10; the most preferred supported one is used.

GET /report HTTP/1.1
Want-Content-Digest: sha-512=10, sha-256=3
Setting Behaviour
DigestEmission.WhenRequested Only when the client asked. The default
DigestEmission.Always Every response, in DefaultAlgorithm unless the client prefers another
DigestEmission.Never Never, even when asked
builder.Services.AddContentDigest(o => o.ResponseDigest = DigestEmission.Always);
app.MapGet("/snapshot", Handler).WithContentDigest(); // this endpoint always
app.MapGet("/live", Handler).DisableContentDigest(); // this endpoint never

A digest cannot be known until after the last byte, and a header goes out before the first. So:

Response Placement
Small responses up to MaxBufferedResponseBytes (64KB) — are held until complete, hashed, and sent with the digest as an ordinary header, and a Content-Length as a side effect.
Larger or streamed responses past the limit, or once the handler turns buffering off (IHttpResponseBodyFeature.DisableBuffering(), as server-sent events do) — stream as they are written, hashed on the way past, and the digest follows as a trailer, announced in the Trailer header.

Response.StartAsync() alone does not switch to streaming: ASP.NET’s own Response.WriteAsync(string) calls it before every first write, so while a body is held the headers simply go out with it. Call DisableBuffering() first when the headers really must be on the wire before the body.

Protocol (Kestrel) Streamed response digest
HTTP/2, HTTP/3 Always, as a trailer
HTTP/1.1 None — Kestrel does not send response trailers on HTTP/1.1. Raise MaxBufferedResponseBytes if HTTP/1.1 clients need digests of larger bodies
HTTP/1.0 None

The body is hashed through Response.Body, Response.BodyWriter and SendFileAsync alike (a sent file goes through the hash rather than the zero-copy path). A digest the handler set itself is left alone. No digest is sent on HEAD, 1xx, 204 or 304 responses, or on an upgrade.

Repr-Digest covers the whole representation rather than this message’s content. For a full 200 they are the same bytes, and a Want-Repr-Digest is answered with it. For a 206 range they are not — the server only has the part — so no Repr-Digest is computed; set one yourself for a file whose digest you already know:

ctx.Response.Headers[DigestFields.ReprDigest] = await DigestFields.ComputeAsync(file, DigestFields.Sha256);

Per-endpoint settings are endpoint metadata. On MVC controllers and actions, and on minimal-API handlers, use the attributes:

[ApiController, Route("api/sync")]
public class SyncController : ControllerBase
{
[HttpPost("batch")] [ContentDigest(RequireRequestDigest = true, AlwaysEmitResponseDigest = false)]
public IActionResult Batch(Batch batch) => …;
[HttpGet("snapshot")] [ContentDigest] // always digest the response
public Snapshot Get() => …;
[HttpGet("live")] [DisableContentDigest]
public Live Live() => …;
}
app.MapGet("/snapshot", [ContentDigest] () => snapshot);
app.MapGet("/meta", Handler).WithMetadata(new ContentDigestAttribute());

On an action an attribute replaces the controller’s settings rather than adding to them. The convention methods — RequireContentDigest(), WithContentDigest(), DisableContentDigest() — work on any IEndpointConventionBuilder, including a MapGroup, and build on each other:

var signed = app.MapGroup("/signed").WithContentDigest();
signed.MapPost("/upload", Upload).RequireContentDigest(); // digest in and out
signed.MapGet("/live", Live).DisableContentDigest(); // neither
Property Default Notes
VerifyRequests true Check Content-Digest on request bodies
RequireRequestDigest false Server-wide; usually set per endpoint with RequireContentDigest()
RejectUnsupportedAlgorithms true A digest the client thinks was checked and was not is worse than none
ResponseDigest WhenRequested
DefaultAlgorithm sha-256 Or sha-512
MaxBufferedResponseBytes 64KB Header below, trailer above

UseContentDigest(o => …) configures a copy of the registered options for that pipeline only; UseContentDigest(options) takes options built elsewhere.

DigestFields has the pieces: Compute, ComputeAsync, Format, TryParse, SelectPreferred, and the header names.

var value = DigestFields.Compute(body); // "sha-256=:…:"

Endpoints without metadata — and requests no endpoint matched, such as UseStaticFiles() — get the server-wide options, as long as UseContentDigest() is registered ahead of them.

claude plugin marketplace add shinyorg/skills
claude plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

copilot plugin marketplace add https://github.com/shinyorg/skills
copilot plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

View shiny-aspnet Skill