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 | |
| Downloads |
dotnet add package Shiny.AspNet.ContentDigestusing Shiny.AspNet.ContentDigest;
builder.Services.AddContentDigest();
var app = builder.Build();app.UseContentDigest(); // after routing; before UseRequestDecompression / UseResponseCompressionTLS 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.1Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
{"hello": "world"}ASP.NET Core has no built-in RFC 9530 support; this package fills that gap.
Verifying requests
Section titled “Verifying requests”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();Digests on responses
Section titled “Digests on responses”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.1Want-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 alwaysapp.MapGet("/live", Handler).DisableContentDigest(); // this endpoint neverHeader or trailer
Section titled “Header or trailer”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
Section titled “Repr-Digest”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);Attributes and conventions
Section titled “Attributes and conventions”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 outsigned.MapGet("/live", Live).DisableContentDigest(); // neitherOptions
Section titled “Options”| 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.
Computing one yourself
Section titled “Computing one yourself”DigestFields has the pieces: Compute, ComputeAsync, Format, TryParse, SelectPreferred, and
the header names.
var value = DigestFields.Compute(body); // "sha-256=:…:"Static files and fallbacks
Section titled “Static files and fallbacks”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.
AI Coding Assistant
Section titled “AI Coding Assistant”Step 1 — Add the marketplace:
claude plugin marketplace add shinyorg/skillsStep 2 — Install the plugin:
claude plugin install shiny@shinyOne 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.
Step 1 — Add the marketplace:
copilot plugin marketplace add https://github.com/shinyorg/skillsStep 2 — Install the plugin:
copilot plugin install shiny@shinyOne 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.


