Chio/Docs
LOGIN · JOIN

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: a ChioFilter registered as a FilterRegistrationBean at highest precedence.
  • Backbay.Chio for ASP.NET Core: AddChioProtection() plus UseChioProtection().
  • Both bind to a local chio api protect sidecar 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

The JVM and .NET SDKs do the same thing the Node and Python SDKs do: speak HTTP to a sidecar that holds the kernel, the policy, and the receipt store. There is no JNI/PInvoke step. That keeps the runtime footprint small and avoids platform-specific native loading. See HTTP Framework Middleware.

Prerequisites

  • Spring Boot: a JDK 17+ (the example uses Kotlin 2.3 + Spring Boot 3.2). The Gradle wrapper at sdks/jvm/gradlew handles the rest.
  • .NET: the .NET 8 SDK. The example pulls ChioMiddleware.csproj as a project reference; the package id that csproj declares is Backbay.Chio.Middleware.
  • A chio binary built from this checkout, not one on PATH. Both smokes call ensure_chio_bin (examples/_shared/hello-http-common.sh:81-96), which uses $CHIO_BIN when it is set and executable, falls back to target/debug/chio, and runs cargo build --bin chio when that is missing. So: the source checkout plus either a Rust toolchain or CHIO_BIN pointing at a binary you already have. The smokes then start a local chio trust serve and chio api protect for you.

Run them

bash
cd examples/hello-spring-boot   # or hello-dotnet
./run.sh

# Full smoke (sidecar + trust + deny + allow)
./smoke.sh
hello-spring-boot ./smoke.shtranscript
$ ./smoke.sh
hello-spring-boot smoke passed
artifacts: <chio-source>/examples/hello-spring-boot/.artifacts/20260905T114158Z
hello receipt: 981b4a71d31e000c0eeae33085c49700a39e9145065c0ea8316df2d7c5c842a8
deny receipt: 2b3a2d3cb5e305cb6401e55de78e5aa8d1f73f5b58f9ddaecd12b97bf8c9af96
allow receipt: f54ec1c724523f5d75b656090e43eb08d2a90d50fcc54ef994861ab3b024d197
exit 0

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:

ExampleEnv varDefault
hello-spring-bootSERVER_PORT, which is Spring's own binding for server.port8080
hello-dotnetHELLO_DOTNET_PORT8019

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.

examples/hello-spring-boot/settings.gradle.kts:17kotlin
includeBuild("../../sdks/jvm")
examples/hello-spring-boot/build.gradle.kts:18-28kotlin
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:

examples/hello-spring-boot/src/main/kotlin/example/hello/HelloSpringBootApplication.ktkotlin
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.

examples/hello-spring-boot/src/main/kotlin/example/hello/HelloController.ktkotlin
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

examples/hello-dotnet/HelloChio.csprojxml
<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

examples/hello-dotnet/HelloApp.cscsharp
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.

bash
./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.

examples/hello-spring-boot/src/main/kotlin/example/hello/HelloSpringBootApplication.kt13-26kotlin
@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
    }
}
examples/hello-dotnet/HelloApp.cs7-19csharp
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:

examples/hello-spring-boot/smoke.sh90-98python
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
PY

The 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

hello.headers and hello.json, one smoke runtranscript
$ cat hello.headers hello.json
HTTP/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"}
exit 0

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:

deny.headers and deny.json, same runtranscript
$ cat deny.headers deny.json
HTTP/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"}
exit 0

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

allow.headers and allow.json, same runtranscript
$ cat allow.headers allow.json
HTTP/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}
exit 0

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

bash
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

Use this when: your Spring Boot or ASP.NET service can take an SDK dependency and you want receipts available on the response header path with body bytes replayed for the controller. Don't use this if the service is closed-source or you cannot redeploy: run chio api protect in front of it instead. See OpenAPI Sidecar.

JVM vs .NET, in one table

AspectSpring BootASP.NET Core
SDK packageworld.chio:chio-spring-bootBackbay.Chio.Middleware, namespace Backbay.Chio
Pipeline shapeServlet filter via FilterRegistrationBeanASP.NET middleware
OrderOrdered.HIGHEST_PRECEDENCEUseWhen placement in HelloApp.cs
Body reuseWrapped servlet requestStream buffering on HttpRequest
Sidecar URLCHIO_SIDECAR_URL, else http://127.0.0.1:9090The 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

JVM and .NET HTTP Frameworks · Chio Docs