Skip to content
davthecoder
Introducing Churust: A Secure, Ktor-Style Web Framework for Rust
tech

Introducing Churust: A Secure, Ktor-Style Web Framework for Rust

By David Cruz Anaya, Independent Senior Mobile Engineer (Android, Kotlin Multiplatform, Rust)

Updated 28 July 2026
RustKtorWeb FrameworkBackendtokiohyperOpen SourceHTTP
Share:

The first time you open a new web framework, you should not have to relearn what a server is.

If you already know Ktor, you know the feeling I wanted from Churust: create an application, install the capabilities you need, describe your routes in one readable block, and get on with building the product. If you do not know Ktor, the same API is still small enough to learn in an afternoon.

That is Churust: an open-source Rust web framework designed to be simple, secure, robust, and easy to learn.

It gives you Ktor’s developer experience on Rust’s async stack. The application engine runs on tokio, HTTP is handled by hyper, and optional TLS is provided by rustls. Churust does not try to replace those proven foundations. It gives them a cohesive, friendly API.

And yes, the name is exactly what it sounds like: churro plus Rust.

Key Takeaways

  • Churust is deliberately shaped like Ktor: a server builder, a nested routing DSL, install(plugin), and a fixed request pipeline.
  • A working server needs one dependency. #[churust::main] uses Churust’s re-exported tokio runtime.
  • Typed extractors make handlers short and explicit, while the full Call remains available whenever you want lower-level control.
  • Security is part of the default configuration: headers, body and connection limits, timeouts, strict path handling, safe cookie defaults, and panic isolation are already on.
  • JSON, logging, CORS, authentication, compression, rate limiting, templates, sessions, Redis, WebSockets, static files, multipart, an HTTP client, OpenAPI, TLS, HTTP/2, and HTTP/3 are all part of the first-party feature set.
  • Churust 0.3 is backed by a large regression suite, strict CI, weekly dependency checks, public design documents, a detailed changelog, and a private vulnerability-reporting process.
  • The full user guide covers quick start, fundamentals, every plugin, and recipes — separate from the API docs on docs.rs.

Why I built Churust

I write a lot of Kotlin, and I write Rust where its safety and performance genuinely pay off. That combination has taken me from Rust-powered mobile libraries to servers that need to speak to them.

Every time I started one of those services, I found myself missing Ktor.

Not Kotlin itself. The part I missed was Ktor’s shape:

  • Configuration reads like configuration.
  • Routes live together and are easy to scan.
  • Cross-cutting behaviour is installed as a plugin.
  • Plugins run in predictable phases.
  • Common server capabilities feel like one framework instead of a shopping list.

Rust already has an excellent networking ecosystem. What I wanted was a framework that made that ecosystem feel immediately familiar to a Kotlin developer, without hiding Rust’s type system or pretending async work is simpler than it is.

So I built Churust.

The goal is not to make Rust look like Kotlin. The goal is to remove the unnecessary learning curve around the server itself. You still get Rust’s ownership model, enums, traits, explicit errors, and compile-time guarantees. You just do not need to invent your application’s basic shape before writing its first route.

Your first Churust server

Start with one dependency:

[dependencies]
churust = "0.3"

Then write the server:

use churust::prelude::*;

#[churust::main]
async fn main() -> std::io::Result<()> {
    Churust::server()
        .port(8080)
        .routing(|r| {
            r.get("/", |_call: Call| async {
                "Hello from Churust 🌀"
            });

            r.get("/users/{id}", |Path(id): Path<u64>| async move {
                format!("user #{id}")
            });
        })
        .start()
        .await
}

Run it and call both routes:

cargo run
curl http://localhost:8080/
curl http://localhost:8080/users/42

There is no separate tokio dependency in that project. Churust re-exports the runtime it uses, and #[churust::main] expands against that re-export. You can add tokio yourself if you need extra runtime features, but you do not need to solve a version-matching problem just to print “Hello.”

That small detail captures the philosophy of the framework: make the first step obvious, then let the application grow without changing its mental model.

If Ktor feels familiar, Churust will too

Here is a small Ktor route:

fun Application.module() {
    routing {
        get("/users/{id}") {
            val id = call.parameters["id"]?.toLongOrNull()
                ?: return@get call.respond(HttpStatusCode.BadRequest)

            call.respondText("user #$id")
        }
    }
}

And here is the Churust version:

Churust::server()
    .routing(|r| {
        r.get("/users/{id}", |Path(id): Path<u64>| async move {
            format!("user #{id}")
        });
    })

The syntax is Rust, but the structure is already in the right place in your head. The route is inside routing, the path parameter belongs to the handler, and invalid input is rejected before the handler body runs.

Plugins follow the same idea:

Churust::server()
    .install(CallLogging::new())
    .install(ContentNegotiation::new())
    .install(Cors::new().allow_origin("https://app.example.com"))
    .install(Compression::new())
    .install(RateLimit::per_minute(120))
    .routing(|r| {
        // Your routes
    })

If you have used install(ContentNegotiation) or install(Authentication) in Ktor, there is very little to translate. If you have not, the builder still reads from top to bottom: configure the server, install behaviour, declare routes, start.

A real JSON API

Hello World examples are useful, but a framework earns its place when the application needs state, JSON, authentication, logging, CORS, compression, and rate limiting at the same time.

Enable the complete plugin set:

[dependencies]
churust = { version = "0.3", features = ["full"] }
serde = { version = "1", features = ["derive"] }

Then build the API:

use churust::prelude::*;
use serde::{Deserialize, Serialize};
use std::sync::Mutex;

#[derive(Clone, Serialize)]
struct Note {
    id: u64,
    text: String,
}

#[derive(Deserialize)]
struct NewNote {
    text: String,
}

#[derive(Clone)]
struct AdminUser {
    name: String,
}

struct Store {
    notes: Mutex<Vec<Note>>,
}

#[churust::main]
async fn main() -> std::io::Result<()> {
    let admin_token =
        std::env::var("ADMIN_TOKEN").expect("ADMIN_TOKEN must be set");

    Churust::server()
        .state(Store {
            notes: Mutex::new(Vec::new()),
        })
        .install(CallLogging::new())
        .install(ContentNegotiation::new())
        .install(Cors::new().allow_origin("https://app.example.com"))
        .install(Compression::new())
        .install(RateLimit::per_minute(120).burst(20))
        .install(Auth::bearer(move |token: String| {
            let expected = admin_token.clone();
            async move {
                churust::secure_compare(
                    token.as_bytes(),
                    expected.as_bytes(),
                )
                .then(|| AdminUser {
                    name: "admin".into(),
                })
            }
        }))
        .routing(|r| {
            r.get("/notes", |store: State<Store>| async move {
                Json(store.notes.lock().unwrap().clone())
            });

            r.post(
                "/notes",
                |Principal(admin): Principal<AdminUser>,
                 store: State<Store>,
                 Json(input): Json<NewNote>| async move {
                    let mut notes = store.notes.lock().unwrap();
                    let note = Note {
                        id: notes.len() as u64 + 1,
                        text: input.text,
                    };
                    notes.push(note.clone());

                    println!("{} created note #{}", admin.name, note.id);
                    (StatusCode::CREATED, Json(note))
                },
            );
        })
        .start()
        .await
}

There is a lot happening here, but very little framework ceremony:

  • .state(Store) registers typed application state.
  • State<Store> retrieves it in a handler.
  • Json<NewNote> validates and deserializes the request body.
  • Returning Json(note) serializes the response.
  • Principal<AdminUser> makes the route private. Without a valid principal, the handler never runs.
  • secure_compare avoids a timing-sensitive comparison for a request-supplied secret.
  • Logging, CORS, compression, and rate limiting stay outside the business logic.

That last point matters. Security checks and operational behaviour should be difficult to forget and easy to find.

The complete feature set

Churust keeps its core lean through Cargo features, but the important capabilities are first-party and versioned together. Default features are empty, so you only compile what the service uses.

Feature What it gives you
Core Server engine, routing, typed extractors, call-style handlers, responses, errors, cookies, signed sessions, identity, typed state, layered configuration, security defaults, HTTP/1.1, HTTP/2, streaming bodies, graceful shutdown, and TestClient.
json Json<T> requests and responses plus the ContentNegotiation plugin.
logging CallLogging, request IDs, x-request-id, and W3C traceparent continuation.
cors Restrictive CORS configuration, preflight handling, and response headers.
auth Bearer, Basic, and JWT authentication plus Principal<P> authorization.
ratelimit GCRA rate limiting with exact Retry-After, peer-based keys, custom keys, and route-scoped use.
compression Brotli, gzip, and deflate response compression, including streamed responses.
templates minijinja templates parsed at startup and HTML-escaped regardless of filename.
full The seven installable plugins above: JSON, logging, CORS, auth, rate limiting, compression, and templates.
redis Revocable server-side sessions through RedisStore.
client A pooled outbound HTTP client with request timeouts, bounded response bodies, and controlled redirects.
client-tls HTTPS support for the outbound client.
openapi OpenAPI 3.1 generated from the router, with explicit operation descriptions and drift checks.
ws WebSocket upgrades, messages, and bidirectional sockets.
fs Secure static-file serving, conditional requests, byte ranges, and opt-in directory listings.
multipart Buffered Multipart and incremental MultipartStream uploads.
tls HTTPS through rustls.
http3 HTTP/3 over QUIC on a separate listener, including Alt-Svc advertisement from the TCP server.
tower in churust-core An adapter for using a tower::Service as Churust middleware.

The core also handles the small pieces that otherwise spread across every service. HEAD and OPTIONS are automatic, and 405 Method Not Allowed uses the same sorted Allow value as OPTIONS. on_error gives the application one place to render its own 4xx and 5xx pages, including router failures. Custom application errors can implement IntoError, which lets ? work in a handler without exposing the error’s Display text to the client. The peer address is available for audit logs and trusted-proxy-aware policies.

All fourteen Churust crates release in lockstep with the same version. In normal application code, you depend on the umbrella churust crate and select features there. That keeps the framework, plugins, client, and integrations aligned in one lockfile.

For a feature-rich HTTPS service, the dependency can still be one line:

churust = {
    version = "0.3",
    features = [
        "full",
        "redis",
        "client-tls",
        "openapi",
        "ws",
        "fs",
        "multipart",
        "tls",
        "http3",
    ],
}

Typed handlers without losing control

Churust supports two handler styles, and they work together.

The high-level style uses extractors:

r.get(
    "/teams/{team_id}/members/{member_id}",
    |Path((team_id, member_id)): Path<(u64, u64)>,
     Query(filters): Query<MemberFilters>,
     store: State<Store>| async move {
        // team_id, member_id, filters, and store are already typed
    },
);

Request-head extractors such as Path<T>, Query<T>, headers, state, bearer tokens, and principals can appear in any position. A body-consuming extractor such as Json<T>, Form<T>, String, Payload, Multipart, or the full Call must come last.

That rule exists because a request body is a one-shot stream, and Churust enforces it at compile time. Version 0.3 also adds custom compiler diagnostics so an invalid handler signature explains the rule instead of leaving you with an opaque trait error.

Optional input is explicit:

r.get("/search", |query: Option<Query<Filters>>| async move {
    match query {
        Some(Query(filters)) => format!("filtering by {}", filters.tag),
        None => "showing everything".to_string(),
    }
});

Absent input becomes None. Malformed input remains an error. A broken query does not quietly turn into an unfiltered request.

And when extractors are not the right tool, take the complete call:

r.post("/uppercase", |mut call: Call| async move {
    let body = call.receive_text().await?;
    Ok::<_, Error>(Response::text(body.to_uppercase()))
});

The convenient path is short, and the escape hatch is always there.

Plugins run in a predictable pipeline

Churust uses five named phases:

Setup → Monitoring → Plugins → Call → Fallback

A plugin registers its middleware in the phase where it belongs. Logging stays in Monitoring; CORS, authentication, JSON, compression, and similar behaviour live in Plugins.

That means execution order is a property of the framework, not an accident of which .install(...) line appears first. The model is deterministic, easy to explain, and familiar to Ktor developers.

Middleware can also be scoped to part of the route tree:

.routing(|r| {
    r.get("/health", |_call: Call| async { "ok" });

    r.route("/api", |api| {
        api.intercept(RateLimit::per_minute(60));
        api.get("/profile", |user: Principal<User>| async move {
            Json(user.0)
        });
    });
})

Route guards can distinguish routes by host, header, or a custom predicate, and per-route body limits let an upload endpoint accept more than the rest of the application without raising the global ceiling.

Secure by default means real defaults

I do not want “secure by default” to be a slogan. I want it to describe what the server does when you forget to configure something.

This is the default posture in Churust 0.3:

Default Value Why it matters
Security headers X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer Reduces MIME sniffing, clickjacking, and referrer leakage.
Request body limit 1 MiB Prevents an unbounded body from becoming an easy memory-exhaustion path.
Request timeout 30 seconds Stops a request from holding resources forever.
Header-read timeout 10 seconds Bounds slow-loris clients, including peers that connect and send nothing.
Header count 100 Bounds hostile or accidental header growth.
Path depth 64 segments Prevents pathological router work.
Concurrent connections 25,000 Puts a deliberate ceiling on tasks and file descriptors.
TLS handshakes 256 with a 10-second deadline Separately limits the more expensive handshake path.
Idle keep-alive 75 seconds Closes connections that go quiet.
WebSocket limits 1 MiB per frame, 4 MiB per message Bounds fragmented-message reassembly.
Path handling PathPolicy::Strict Refuses repeated-slash aliases such as //admin.
Cookies HttpOnly, SameSite=Lax, Path=/ Safer behaviour without repeating the same flags everywhere.
Handler panic Isolated as a 500 response One bad request does not crash the server.
Version banner Not sent Avoids unnecessary implementation disclosure.

Those protections also apply to transport-generated errors such as 400, 408, and 413, not only responses that reached a route.

Churust also refuses ambiguous request framing. A request containing both Transfer-Encoding and Content-Length is rejected and its connection is closed instead of guessing which length is correct. Duplicate routes and missing static roots fail during startup. Static-file paths are checked against traversal and symlink escapes. Directory listings are off unless you enable them, and filenames are escaped when you do.

TLS is opt-in because a framework should never pretend plaintext is encrypted. Once you enable the tls feature and provide a certificate and key, rustls handles the connection and ALPN negotiates HTTP/2 where the client supports it.

Authentication and sessions that are hard to forget

Authentication inserts your principal into the request. Authorization happens when a handler asks for it:

r.get(
    "/account",
    |Principal(user): Principal<User>| async move {
        Json(user)
    },
);

If authentication did not produce a User, that handler does not run. The requirement is visible in the function signature instead of hidden inside the body.

For session-backed applications, signed cookie sessions are available in core:

let session_key =
    std::env::var("SESSION_KEY").expect("SESSION_KEY must be set");

Churust::server()
    .install(
        Sessions::cookie(session_key)
            .secure(true)
            .max_age(60 * 60 * 24),
    )
    .install(Identities::new())

Cookie sessions use HMAC-SHA256 signatures and constant-time verification. They are signed, not encrypted, so they are a good fit for small, non-secret session state.

When you need server-side state and immediate revocation, switch the store to Redis:

let store = RedisStore::connect("redis://127.0.0.1/").await?;

let app = Churust::server()
    .install(Sessions::with_store(store.ttl(3600)))
    .install(Identities::new())
    .build();

The browser keeps an opaque identifier while the session lives in Redis. Logout deletes the server-side record, and a failed revocation is reported as an error instead of returning a successful logout while the session remains valid.

The identity layer includes login, logout, session rotation, an absolute login deadline, and an idle-visit deadline.

Streaming, files, WebSockets, and modern HTTP

Churust supports buffered bodies when they are convenient and streaming bodies when size matters.

Payload reads a request incrementally. MultipartStream does the same for file uploads, so memory usage does not grow with the entire upload. Streaming does not bypass the body limit; raise the limit deliberately on the upload route.

Response bodies can also stay streamed, including when compression is enabled. Static files support:

  • ETag and Last-Modified
  • Conditional requests and 304 Not Modified
  • Byte ranges with 206 Partial Content and correct 416 responses
  • Optional index files
  • Opt-in, escaped directory listings
  • Protection against .., absolute paths, encoded separators, and symlink escapes

WebSockets use a typed upgrade extractor:

use churust::prelude::*;
use churust::ws::{Message, WebSocketUpgrade};

r.get("/echo", |ws: WebSocketUpgrade| async move {
    ws.on_upgrade(|mut socket| async move {
        while let Some(Ok(message)) = socket.recv().await {
            if matches!(message, Message::Close)
                || socket.send(message).await.is_err()
            {
                break;
            }
        }
    })
});

The normal TCP engine supports HTTP/1.1 and HTTP/2. HTTP/3 is available behind http3 and runs over QUIC on its own UDP listener. The same App can serve both transports, and advertise_http3 adds the Alt-Svc header that tells compatible clients where to find it.

Configuration that grows with the service

Small applications can configure everything in Rust. Deployed services can layer a file and environment variables underneath the same builder.

# churust.toml
[server]
host = "0.0.0.0"
port = 8080
max_body_bytes = 1048576
request_timeout_ms = 30000
keep_alive_ms = 75000
max_connections = 25000
max_tls_handshakes = 256
tls_handshake_timeout_ms = 10000
shutdown_timeout_ms = 30000
path_policy = "strict"

[tls]
cert = "cert.pem"
key = "key.pem"

Load it with Churust::from_config(). Environment variables such as CHURUST_SERVER_PORT=9090 override the file, and values set in the Rust builder override both.

The order is:

secure defaults < churust.toml < CHURUST_* environment < Rust builder

That works well locally, in containers, and in environments where deployment configuration should stay outside the binary.

Churust can bind several addresses, use Unix domain sockets, limit concurrent connections and TLS handshakes, tune HTTP/2 streams and headers, and perform a bounded graceful shutdown that drains in-flight work.

The first-party client and OpenAPI support

A server usually calls another server. With the client-tls feature, the Churust client uses pooled connections and supports HTTP and HTTPS:

use churust::client::Client;

let client = Client::new()
    .max_response_bytes(2 * 1024 * 1024)
    .max_redirects(5);

let response = client
    .get("https://api.example.com/health")
    .send()
    .await?;

println!("{}", response.text()?);

The request timeout covers the entire redirect chain, response bodies are bounded, credentials are stripped on cross-origin redirects, and an HTTPS request cannot be silently redirected down to HTTP.

OpenAPI support starts from the router because the router already knows which paths and methods exist:

use churust::openapi::{OpenApi, Operation};

let builder = Churust::server().routing(|r| {
    r.get("/users/{id}", |_call: Call| async { "a user" });
});

let api = OpenApi::new("Users API", "1.0.0")
    .from_routes(builder.routes())
    .operation(
        Method::GET,
        "/users/{id}",
        Operation::new()
            .summary("Fetch one user")
            .response(200, "The user")
            .response(404, "No such user"),
    );

let app = builder
    .routing(|r| api.mount(r, "/openapi.json"))
    .build();

Paths and parameters come from the real router. Descriptions and schemas remain explicit, and drift checks can fail a test when the routes and the document stop agreeing.

Testing is part of the developer experience

You do not need to bind a port to test an application. TestClient runs requests through the complete pipeline in process:

let app = Churust::server()
    .routing(|r| {
        r.get("/ping", |_call: Call| async { "pong" });
    })
    .build();

let client = TestClient::new(app);
let response = client.get("/ping").send().await;

assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.text(), "pong");

That makes route, extractor, plugin, error-page, and security behaviour fast to test without port conflicts or startup sleeps.

Version 0.3 grew the all-features workspace from 219 listed tests in 0.2 to 754 in 0.3. The important part is not the number by itself. Those tests cover the failures that only appear under load or at protocol boundaries: graceful shutdown, idle keep-alive, path normalization, request framing, conditional file requests, compression metadata, Redis revocation, redirects, and more.

What well maintained means for Churust

Maintenance is not a badge. It is the process around the code.

Every push and pull request runs:

  • Formatting checks
  • Clippy with warnings treated as errors
  • Default-feature and all-feature matrices
  • The full workspace tests
  • Plugin-specific tests
  • Example builds
  • Documentation builds with warnings denied
  • A check that every crate is present in the release list

CI pins Rust 1.96, the documented minimum supported Rust version. A separate workflow runs every Monday against the newest semver-compatible dependency versions, so upstream changes are discovered deliberately instead of waiting for the next manual lockfile update.

The changelog explains behaviour changes and the reason behind them. Design documents record the trade-offs. All fourteen crates are released together. Security reports have a private disclosure process with published response targets.

Churust is pre-1.0, so minor releases may include API changes while the public surface settles. That is not an excuse for surprise: changes are documented, tested, and released across the whole workspace together.

Getting started

The step-by-step guide lives at davthecoder.github.io/Churust — quick start, extractors, plugins, recipes, and reference. Use docs.rs/churust when you need the full type API.

For the smallest server:

[dependencies]
churust = "0.3"

For the complete plugin set plus WebSockets, static files, and TLS:

[dependencies]
churust = {
    version = "0.3",
    features = ["full", "ws", "fs", "tls"],
}

The repository includes four runnable examples:

git clone https://github.com/davthecoder/Churust.git
cd Churust

cargo run -p hello
cargo run -p api
cargo run -p chat
cargo run -p static-example

Start with hello, move to api to see plugins and typed authentication, use chat for WebSockets, and open static-example for file serving and streamed responses.

The main links are:

Frequently Asked Questions

Do I need to know Ktor?

No. Ktor knowledge makes the structure instantly familiar, but Churust’s API stands on its own. The prelude, the builder, typed handlers, and four examples cover the path from a first route to a real service.

Is Churust easy to learn if I am new to Rust web development?

That is exactly who the API is designed for. Start with Churust::server(), add routes, then install features as the service needs them. You can use typed extractors most of the time and reach for Call when you need more control.

You still need to learn Rust. Churust does not hide ownership, async functions, or result types. What it removes is the need to learn a fragmented framework vocabulary at the same time.

Is Churust secure?

Churust is designed with a secure default posture and documents the exact limits it enables. It builds on hyper, tokio, and rustls for the protocol, runtime, and TLS layers, then adds framework-level limits and checks around them.

No framework can make an application automatically secure. You still need proper secret storage, authorization rules, dependency updates, TLS configuration, and application-specific validation. Churust makes the common safe choices the starting point instead of an optional hardening guide.

Can I use it for a real service?

Version 0.3 is built for that job: secure defaults, graceful shutdown, streaming, observability, authentication, sessions, modern HTTP, a broad test suite, and a release process that checks every feature.

It is also pre-1.0. Pin the version, read the changelog before upgrading, test your own threat model and traffic patterns, and decide whether the current API stability fits your project.

Does Churust implement HTTP and TLS itself?

No. hyper parses and serves HTTP, tokio runs the async work, and rustls provides optional TLS. Churust owns the application layer: routing, extractors, plugins, configuration, limits, sessions, responses, and developer experience.

Why are features opt-in?

Because an API server does not need templates, a static site does not need JWT, and an internal service may not need WebSockets or HTTP/3. Opt-in features keep compile times, dependencies, and protocol surface aligned with the application you are actually building.

Where this leaves you

Churust is the framework I wanted when moving between Kotlin and Rust: familiar enough to be productive quickly, explicit enough to still feel like Rust, secure before the hardening checklist, and complete enough that a real service does not turn into dependency archaeology.

If you know Ktor, the first application should feel familiar.

If you do not, it should still feel obvious.

Install churust = "0.3", follow the user guide, build one route, and tell me where the experience can become even better. The framework is open source, actively maintained, and ready for developers who want Rust on the server without giving up a clear, approachable API.

Happy coding!

David Cruz
davthecoder.com

Share:

Comments

Loading comments…