BuildHTTP Frameworks
JVM and .NET HTTP Frameworks
Protect Spring Boot and ASP.NET Core routes through a local Chio sidecar over HTTP.
What it shows
world.chio:chio-spring-boot: aChioFilterregistered as aFilterRegistrationBeanat highest precedence.Backbay.Chiofor ASP.NET Core:AddChioProtection()plusUseChioProtection().- Both bind to a local
chio api protectsidecar over HTTP. No FFI, no embedded kernel. - Request bodies remain readable by the controller after Chio hashing. Receipt ids appear on the response header path for governed routes.
Binding pattern
Prerequisites
- Spring Boot: a JDK 17+ (the example uses Kotlin 2.3 + Spring Boot 3.2). The Gradle wrapper at
sdks/jvm/gradlewhandles the rest. - .NET: the .NET 8 SDK. The example pulls
ChioMiddleware.csprojas a project reference; the package id that csproj declares isBackbay.Chio.Middleware. - A
chiobinary built from this checkout, not one onPATH. Both smokes callensure_chio_bin(examples/_shared/hello-http-common.sh:81-96), which uses$CHIO_BINwhen it is set and executable, falls back totarget/debug/chio, and runscargo build --bin chiowhen that is missing. So: the source checkout plus either a Rust toolchain orCHIO_BINpointing at a binary you already have. The smokes then start a localchio trust serveandchio api protectfor you.
Run them
cd examples/hello-spring-boot # or hello-dotnet
./run.sh
# Full smoke (sidecar + trust + deny + allow)
./smoke.sh$ ./smoke.shhello-spring-boot smoke passed artifacts: <chio-source>/examples/hello-spring-boot/.artifacts/20260905T114158Z hello receipt: 981b4a71d31e000c0eeae33085c49700a39e9145065c0ea8316df2d7c5c842a8 deny receipt: 2b3a2d3cb5e305cb6401e55de78e5aa8d1f73f5b58f9ddaecd12b97bf8c9af96 allow receipt: f54ec1c724523f5d75b656090e43eb08d2a90d50fcc54ef994861ab3b024d197
Three receipt ids for three calls, the refused one included, plus the run directory the artifacts landed in. Receipt ids are 64-character lowercase hex, so yours differ. hello-dotnet prints the same five lines with hello-dotnet smoke passed on the first. It is not captured here: the machine that built this page has no .NET SDK, so every .NET claim below is read out of the source, and the code blocks that carry an arc path in their header are that source rather than a paraphrase of it.
Default ports:
| Example | Env var | Default |
|---|---|---|
hello-spring-boot | SERVER_PORT, which is Spring's own binding for server.port | 8080 |
hello-dotnet | HELLO_DOTNET_PORT | 8019 |
Spring Boot
The integration is one bean. Build a ChioFilter with the sidecar URL, wrap it in a FilterRegistrationBean, and give it Ordered.HIGHEST_PRECEDENCE so it runs before any business filter.
Build files
The settings file includes the SDK tree as a composite build, which is what resolves the world.chio coordinate in the dependency block below.
includeBuild("../../sdks/jvm")dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:3.2.2"))
implementation("world.chio:chio-spring-boot:0.1.0")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
testImplementation(platform("org.springframework.boot:spring-boot-dependencies:3.2.2"))
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
}Application
The example splits into two files. The application class carries the Chio wiring and nothing else:
package example.hello
import world.chio.ChioFilter
import world.chio.ChioFilterConfig
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.core.Ordered
@SpringBootApplication
class HelloSpringBootApplication {
@Bean
fun chioFilterRegistration(): FilterRegistrationBean<ChioFilter> {
val filter = ChioFilter(
ChioFilterConfig(
sidecarUrl = System.getenv("CHIO_SIDECAR_URL") ?: "http://127.0.0.1:9090",
),
)
return FilterRegistrationBean<ChioFilter>().apply {
setFilter(filter)
addUrlPatterns("/hello", "/echo")
order = Ordered.HIGHEST_PRECEDENCE
}
}
}
fun main(args: Array<String>) {
runApplication<HelloSpringBootApplication>(*args)
}ChioFilterConfig has no skip or exclude list, so the registration governs exactly the two URL patterns it names. Registering on /hello and /echo is what keeps /healthz out of the sidecar path.
The controller is a separate file with no Chio import in it at all, which is the point: the filter runs before it and the handler code is unchanged.
package example.hello
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
@RestController
class HelloController {
@GetMapping("/healthz")
fun healthz(): Map<String, String> = mapOf("status" to "ok")
@GetMapping("/hello")
fun hello(): Map<String, String> = mapOf("message" to "hello from spring-boot")
@PostMapping("/echo", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun echo(@RequestBody payload: Any?): EchoResponse = parseEchoPayload(payload)
@ExceptionHandler(EchoPayloadError::class)
fun echoPayloadError(error: EchoPayloadError): ResponseEntity<Map<String, String>> =
ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(mapOf("error" to (error.message ?: "invalid echo payload")))
}
data class EchoResponse(
val message: String,
val count: Int,
)
class EchoPayloadError(message: String) : RuntimeException(message)
internal fun parseEchoPayload(payload: Any?): EchoResponse {
if (payload !is Map<*, *>) {
throw EchoPayloadError("body must be a JSON object")
}
val allowedKeys = setOf("message", "count")
val extraKeys =
payload.keys
.map { it.toString() }
.filter { it !in allowedKeys }
.sorted()
if (extraKeys.isNotEmpty()) {
throw EchoPayloadError("unexpected fields: ${extraKeys.joinToString(", ")}")
}
val message = payload["message"]
if (message !is String || message.isEmpty()) {
throw EchoPayloadError("message must be a non-empty string")
}
val count = payload["count"] ?: 1
if (count !is Int || count < 1) {
throw EchoPayloadError("count must be an integer greater than or equal to 1")
}
return EchoResponse(
message = message,
count = count,
)
}ChioFilter wraps the servlet request to cache the body bytes for replay, so the @RequestBody binding on echo still works after the filter has hashed the bytes. The receipt id is set on the response header path; controllers do not need to thread it through manually. Note that echo binds Any? and validates in parseEchoPayload rather than binding a data class: an unexpected key, a non-string message, or a count below 1 raises EchoPayloadError, and the @ExceptionHandler turns that into a 400. That is the application refusing a malformed body, which is a different thing from the 403 the sidecar returns further up.
ASP.NET Core
Register the service, then gate the middleware so it covers every route except the readiness probe. The example uses ASP.NET's minimal-API style.
Project file
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../sdks/dotnet/ChioMiddleware/src/ChioMiddleware.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Remove="tests/**/*.cs" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>HelloChio.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>The second ItemGroup is not optional. Microsoft.NET.Sdk.Web globs **/*.cs, so without <Compile Remove="tests/**/*.cs" /> the test file compiles into the web app and the build fails.
Program
using Backbay.Chio;
namespace HelloDotnet;
internal static class HelloApp
{
internal static WebApplication Create(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddChioProtection();
var app = builder.Build();
app.MapGet("/healthz", HelloEndpoints.Health);
app.UseWhen(
context => RequiresChioProtection(context.Request.Path),
branch => branch.UseChioProtection());
app.MapHelloEndpoints();
return app;
}
internal static bool RequiresChioProtection(PathString path) =>
!path.Equals("/healthz", StringComparison.OrdinalIgnoreCase);
}
internal static class HelloEndpoints
{
internal static IEndpointRouteBuilder MapHelloEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/hello", Hello);
app.MapPost("/echo", Echo);
return app;
}
internal static IResult Health() => Results.Json(new HealthResponse("ok"));
internal static IResult Hello() => Results.Json(new HelloResponse("hello from dotnet"));
internal static IResult Echo(EchoRequest payload)
{
if (!EchoContract.TryCreateResponse(payload, out var response, out var error))
{
return Results.BadRequest(error);
}
return Results.Json(response);
}
}
internal static class EchoContract
{
internal static bool TryCreateResponse(
EchoRequest payload,
out EchoResponse? response,
out EchoErrorResponse? error)
{
if (string.IsNullOrWhiteSpace(payload.Message))
{
response = null;
error = new EchoErrorResponse(
"invalid_echo_request",
"message must contain at least one non-whitespace character");
return false;
}
if (payload.Count < 1)
{
response = null;
error = new EchoErrorResponse(
"invalid_echo_request",
"count must be greater than or equal to 1");
return false;
}
response = new EchoResponse(payload.Message, payload.Count);
error = null;
return true;
}
}
internal sealed record HealthResponse(string Status);
internal sealed record HelloResponse(string Message);
internal sealed record EchoRequest(string Message, int Count = 1);
internal sealed record EchoResponse(string Message, int Count);
internal sealed record EchoErrorResponse(string Error, string Message);Notes: app.UseWhen(...) runs UseChioProtection() only on paths where RequiresChioProtection returns true, which excludes /healthz, and /healthz is mapped before the branch so the readiness probe never depends on the sidecar. Body bytes are buffered with EnableBuffering()-style semantics so the model binder can re-read them. The sidecar URL comes from CHIO_SIDECAR_URL, defaulting to http://127.0.0.1:9090 (sdks/dotnet/ChioMiddleware/src/ChioMiddlewareExtensions.cs:28-29), or from a delegate passed to AddChioProtection; AddChioProtection binds no IConfiguration section, so appsettings.json is not read out of the box. EchoContract is the .NET twin of the Spring controller's parseEchoPayload: a blank Message or a Count below 1 comes back 400 from the app, not 403 from the sidecar.
./run.sh
# starts dotnet on http://127.0.0.1:8019 (override with HELLO_DOTNET_PORT)Critical wiring
One bean (Spring) and one middleware call (.NET). These are the lines that flip on Chio enforcement.
@Bean
fun chioFilterRegistration(): FilterRegistrationBean<ChioFilter> {
val filter = ChioFilter(
ChioFilterConfig(
sidecarUrl = System.getenv("CHIO_SIDECAR_URL") ?: "http://127.0.0.1:9090",
),
)
return FilterRegistrationBean<ChioFilter>().apply {
setFilter(filter)
addUrlPatterns("/hello", "/echo")
order = Ordered.HIGHEST_PRECEDENCE
}
}internal static WebApplication Create(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddChioProtection();
var app = builder.Build();
app.MapGet("/healthz", HelloEndpoints.Health);
app.UseWhen(
context => RequiresChioProtection(context.Request.Path),
branch => branch.UseChioProtection());
app.MapHelloEndpoints();
return app;
}Smoke assertions
Both smokes drive the same three-call pattern: GET allow, POST deny without capability, POST allow with capability. Each call is checked by its own inline Python block. The middle one is the interesting one, and it is the same in both files:
python3 - "${ARTIFACT_ROOT}/deny.json" <<'PY'
import json
import sys
from pathlib import Path
body = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
assert body["error"] == "chio_access_denied", body
assert body["receipt_id"], body
PYThe other two blocks have the same shape. hello-spring-boot/smoke.sh:75-82 asserts body["message"] == "hello from spring-boot" on the GET, and :115-123 asserts body["message"] == "hello" and body["count"] == 2 on the POST that carries a capability. hello-dotnet/smoke.sh is the same file line for line, differing only in the runtime name and in exporting HELLO_DOTNET_PORT where the JVM one exports SERVER_PORT.
Both then reconcile the three ids against the sidecar receipt store (hello-spring-boot/smoke.sh:157-178): no id may be empty, all three must be present in http_receipts, and the three method/route/verdict/status tuples must be exactly ("GET", "/hello", "allow", 200), ("POST", "/echo", "deny", 403) and ("POST", "/echo", "allow", 200).
What the caller sees
Two runtimes, two HTTP stacks, one contract: the receipt header on an allow, and on a refusal a 403 whose body the application never saw. The three exchanges below are one captured run of hello-spring-boot/smoke.sh.
The allowed GET
$ cat hello.headers hello.jsonHTTP/1.1 200
X-Chio-Receipt-Id: 981b4a71d31e000c0eeae33085c49700a39e9145065c0ea8316df2d7c5c842a8
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 05 Sep 2026 11:42:26 GMT
{"message":"hello from spring-boot"}The refused POST
The middle call posts to /echo with no capability token. The sidecar answers before the controller or the endpoint delegate runs, so neither the body nor the status below is something the application wrote:
$ cat deny.headers deny.jsonHTTP/1.1 403
Content-Type: application/json;charset=ISO-8859-1
Transfer-Encoding: chunked
Date: Sat, 05 Sep 2026 11:42:26 GMT
{"error":"chio_access_denied","message":"side-effect route requires a capability token","receipt_id":"2b3a2d3cb5e305cb6401e55de78e5aa8d1f73f5b58f9ddaecd12b97bf8c9af96","suggestion":"provide a valid capability token in the X-Chio-Capability header or chio_capability query parameter"}The 403 carries no X-Chio-Receipt-Id: the id is in the body, and it resolves in the receipt store like the two allows. The charset=ISO-8859-1 is the servlet container's default on an error response, not something Chio sets.
The same POST with a capability
$ cat allow.headers allow.jsonHTTP/1.1 200
X-Chio-Receipt-Id: f54ec1c724523f5d75b656090e43eb08d2a90d50fcc54ef994861ab3b024d197
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 05 Sep 2026 11:42:26 GMT
{"message":"hello","count":2}hello-dotnet answers all three with the same status codes, the same deny keys and the same X-Chio-Receipt-Id header (sdks/dotnet/ChioMiddleware/src/ChioMiddlewareExtensions.cs:210), with hello from dotnet in place of hello from spring-boot. Kestrel and the servlet container disagree about status-line spelling and about which headers they volunteer, and agree about everything Chio contributes. The .NET side has no transcript here because the machine that built this page has no .NET SDK, and an uncaptured transcript is worth less than an absent one.
Inspect after
cd .artifacts/$(ls -t .artifacts | head -1)
# The two allows carry X-Chio-Receipt-Id; the 403 does not, so this
# prints two lines, not three.
grep -i x-chio-receipt-id hello.headers deny.headers allow.headers
# 3 persisted receipts in the sidecar SQLite store
wc -l receipts.ndjson # expect: 3
jq -r '.verdict.verdict' receipts.ndjson | sort | uniq -c
# expect: 2 allow, 1 deny
# The deny id lives in the body, and resolves in the store like the allows
jq -r '.receipt_id' deny.json
jq -r 'select(.verdict.verdict=="deny") | .id' receipts.ndjson
# Direct SQLite peek
sqlite3 state/sidecar-receipts.sqlite3 \
"select id, json_extract(receipt_json, '$.method') as method, json_extract(receipt_json, '$.route_pattern') as route_pattern from http_receipts order by rowid;"When this fits
chio api protect in front of it instead. See OpenAPI Sidecar.JVM vs .NET, in one table
| Aspect | Spring Boot | ASP.NET Core |
|---|---|---|
| SDK package | world.chio:chio-spring-boot | Backbay.Chio.Middleware, namespace Backbay.Chio |
| Pipeline shape | Servlet filter via FilterRegistrationBean | ASP.NET middleware |
| Order | Ordered.HIGHEST_PRECEDENCE | UseWhen placement in HelloApp.cs |
| Body reuse | Wrapped servlet request | Stream buffering on HttpRequest |
| Sidecar URL | CHIO_SIDECAR_URL, else http://127.0.0.1:9090 | The same, and the same default |
Why HTTP and not FFI
Both runtimes have mature FFI surfaces (JNI, P/Invoke), but the sidecar shape wins on three counts:
- No native build per platform: the Java archive and the .NET assembly stay pure managed code; the sidecar binary handles platform-specific concerns.
- Shared state: the sidecar holds policy, kernel state, and receipts. Multiple managed processes can share one sidecar.
- Crash isolation: a bug in the kernel cannot bring down the JVM or the .NET host.
The cost is one localhost round trip per evaluated request.
Next
- HTTP Framework Middleware
- Protect an API: the zero-code reverse-proxy alternative.
- Go and C++ HTTP Frameworks, Node HTTP Frameworks, Python HTTP Frameworks
- One contract, every stack: the registration line for all six languages, side by side, plus the policy and the sidecar command they share.
- Examples Overview