HTTP Middlewares with Spin 4.3
Middleware is one of those things every HTTP API needs, and most of us end up re-implementing it in every project. With Spin 4.3, middleware becomes what it really should be: a self-contained WebAssembly Component that we write once and put in front of other HTTP components through spin.toml. In this article, I’ll show you how to protect an API with an authentication middleware, and how to compress its responses on the fly with a second middleware, without the API knowing about either of them.
What You Need To Follow Along
Everything in this article is written in Rust, so the list of requirements is short:
- Spin CLI (version
4.3.0or newer) - Rust with the
wasm32-wasip2target (rustup target add wasm32-wasip2)
What We’ll Build
For the sake of this article, we’ll build a Spin application with an API that exposes two endpoints: GET /api/, which returns a small JSON object, and GET /api/posts, which returns a larger JSON list. We’ll put two middlewares in front of it:
- An inbound
authmiddleware that checks theAuthorizationheader for a secret token and rejects everything else with a401. - An outbound
gzipmiddleware that compresses the response body on its way back to the caller.
The API itself won’t know anything about either of them.
How Middleware Composition Works
A middleware is a Wasm Component that sits in front of another HTTP component. It can inspect the request, answer it right away, or hand it to the next element in the pipeline and work with the response it gets back. It never knows what sits behind it: Spin decides that based on spin.toml, which is why one middleware fits in front of many different components.
For our application, a request flows from the caller into auth, then into gzip, and finally into the API. The response travels the same path backwards, which is where gzip does its work.
There’s one requirement for the component behind the middleware: it has to export wasi:http/handler@0.3, the HTTP interface of WASIp3. Today, the Spin toolchains for Rust, Go and Python produce WASIp3 components (the templates without the -p2 suffix). The JavaScript/TypeScript toolchain still produces WASIp2 components, so those can’t be wrapped by middleware yet. That’s why our API is written in Rust, too.
Creating The Spin Application
Creating a new application with Spin is as easy as executing spin new. We start with the http-empty template, which gives us an application without any components, and accept defaults for the remaining template parameters using the -a flag:
spin new -E akamai-functions -t http-empty -a middlewares-in-spin
# move into the middlewares-in-spin folder
cd middlewares-in-spin
While we’re at it, let’s define the secret token as an application variable in spin.toml. We mark it as secret and provide a random default value for local development:
[variables]
auth_token = { default = "6f1c2e9a-4b7d-4c3e-9a1f-2d8e5b0c7a64", secret = true }
gzip_min_size = { default = "1024" }
The second variable, gzip_min_size, is the size threshold for our compression middleware.
Both variables are meant for the middlewares, but there’s one twist: middleware can’t own capabilities. It inherits them from the component it sits in front of, so the API component is where we’ll map the variables later.
Implementing The API
Next, we add the API component using the http-rust template. We tell Spin to route everything below /api/ to it, and add serde_json for building JSON responses:
spin add -t http-rust -v http-path="/api/..." -a api
cd api
cargo add serde_json
cd ..
Replace the contents of api/src/lib.rs with the following. GET /api/ returns a small JSON object, GET /api/posts passes a larger JSON list from JSONPlaceholder through as is:
use spin_sdk::http::body::IncomingBodyExt;
use spin_sdk::http::{EmptyBody, IntoResponse, Method, Request, Response};
use spin_sdk::http_service;
use std::time::{SystemTime, UNIX_EPOCH};
#[http_service]
async fn handle_api(req: Request) -> anyhow::Result<impl IntoResponse> {
let body = match (req.method(), req.uri().path()) {
(&Method::GET, "/api" | "/api/") => serde_json::json!({
"message": "Hello from Rust on Spin",
"at": SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(),
})
.to_string(),
(&Method::GET, "/api/posts") => {
let upstream = Request::get("https://jsonplaceholder.typicode.com/posts")
.body(EmptyBody::new())?;
let res = spin_sdk::http::send(upstream).await?;
let posts = res.into_body().bytes().await?;
String::from_utf8(posts.to_vec())?
}
_ => return Ok(Response::builder().status(404).body(String::new())?),
};
Ok(Response::builder()
.status(200)
.header("content-type", "application/json")
.header("content-length", body.len())
.body(body)?)
}
Nothing about authentication or compression in here, and that’s the point.
The /api/posts route makes an outbound request, so we have to allow the host on the API component in spin.toml:
[component.api]
# existing settings...
allowed_outbound_hosts = ["https://jsonplaceholder.typicode.com"]
Let’s build and run the application:
spin build
spin up
In a second terminal, send a request to the API:
curl -i localhost:3000/api/
You should get back a 200 along with the JSON object.
The Inbound Middleware: Authentication
The auth middleware checks the Authorization header of every incoming request. If the header is missing or doesn’t match Bearer <token>, it stops processing and answers with a plain 401; otherwise, it forwards the request down the chain.
Creating The Component
A middleware is a normal Spin HTTP component with one extra API: a way to hand the request to the next element in the pipeline. So we scaffold it with the regular template and enable the http-middleware feature of the Spin SDK:
spin add -t http-rust -a auth
cd auth
cargo add spin-sdk -F http-middleware
cd ..
spin add also generated a [[trigger.http]] block for auth in spin.toml. Delete it: the middleware should never be reachable on its own route.
Implementing The Component
Replace the contents of auth/src/lib.rs with the following:
use spin_sdk::http::{box_body, BoxBody, EmptyBody, Request, Response};
use spin_sdk::http_service;
#[http_service]
async fn handle_auth(req: Request) -> anyhow::Result<Response<BoxBody>> {
let token = spin_sdk::variables::get("auth_token").await?;
let expected = format!("Bearer {token}");
let presented = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
if !constant_time_eq(presented.as_bytes(), expected.as_bytes()) {
return Ok(Response::builder()
.status(401)
.body(box_body(EmptyBody::new()))?);
}
// pass the request along the pipeline
let res = spin_sdk::http::next(req).await?;
Ok(res.map(box_body))
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0, |acc, (x, y)| acc | (x ^ y)) == 0
}
We compare the tokens in constant time, so response times don’t leak how much of the token a caller guessed. On a mismatch we never call next, which means the API never sees the request. Otherwise, next(req).await hands the request down the pipeline and gives us its response. The two branches return different body types (an empty body versus whatever comes back from next), so we wrap both with box_body and return a Response<BoxBody>.
Wiring It Up
Middleware is attached to a trigger, not to a component. We add auth to the trigger of our API and let it inherit the variables of the API component, which is where we map auth_token:
[[trigger.http]]
route = "/api/..."
component = "api"
dependencies.middleware = [
{ component = "auth", inherit_configuration = ["variables"] },
]
[component.api]
# existing settings...
variables = { auth_token = "{{ auth_token }}" }
Let’s be honest about one edge here: because the variable is mapped into the API component, the API could technically read the token, too. Middleware inheriting capabilities from the component it protects is how Spin 4.3 works today, so keep that in mind when you decide which secrets go where.
Testing It
Build and run the application again:
spin build
spin up
A request without the token is now rejected:
curl -i localhost:3000/api/
# HTTP/1.1 401 Unauthorized
Providing the token gets us through to the API, and we receive the 200 along with the JSON object:
curl -i -H 'Authorization: Bearer 6f1c2e9a-4b7d-4c3e-9a1f-2d8e5b0c7a64' \
localhost:3000/api/
The Outbound Middleware: Compression
The gzip middleware works on the way back. It forwards the request, takes the response, and gzips the body while it streams to the caller. It also sets Content-Encoding: gzip, so clients that understand gzip know what they’re getting.
Creating The Component
Same recipe as before: the regular template, the http-middleware feature, plus async-compression for streaming gzip:
spin add -t http-rust -a gzip
cd gzip
cargo add spin-sdk -F http-middleware
cargo add async-compression -F futures-io,gzip
cargo add futures
cd ..
Again, delete the [[trigger.http]] block that spin add generated for gzip.
Implementing The Component
Replace the contents of gzip/src/lib.rs with the following:
use async_compression::futures::bufread::GzipEncoder;
use futures::{AsyncReadExt, SinkExt, TryStreamExt};
use spin_sdk::http::body::IncomingBodyExt;
use spin_sdk::http::{box_body, BoxBody, Request, Response};
use spin_sdk::http_service;
#[http_service]
async fn handle_gzip(req: Request) -> anyhow::Result<Response<BoxBody>> {
let min_size: usize = spin_sdk::variables::get("gzip_min_size")
.await?
.parse()
.unwrap_or(1024);
let accepts_gzip = req
.headers()
.get("accept-encoding")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.contains("gzip"));
// pass the request along the pipeline
let res = spin_sdk::http::next(req).await?;
let too_small = res
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<usize>().ok())
.is_some_and(|len| len < min_size);
if !accepts_gzip || too_small {
return Ok(res.map(box_body));
}
let (mut parts, body) = res.into_parts();
// read the response body as a stream and gzip it chunk by chunk
let reader = body.stream().map_err(std::io::Error::other).into_async_read();
let mut encoder = GzipEncoder::new(reader);
let (mut tx, compressed) = spin_sdk::http::body::stream::<Vec<u8>>();
spin_sdk::wasip3::spawn(async move {
let mut buf = vec![0u8; 8 * 1024];
loop {
match encoder.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
if tx.send(buf[..n].to_vec()).await.is_err() {
break;
}
}
}
}
});
parts.headers.remove("content-length");
parts.headers.insert("content-encoding", "gzip".parse()?);
parts.headers.insert("vary", "accept-encoding".parse()?);
Ok(Response::from_parts(parts, box_body(compressed)))
}
We only compress when the caller asks for it via Accept-Encoding, and the body is never buffered as a whole. We read the response body as a stream via IncomingBodyExt::stream(), and GzipEncoder compresses it as it’s pulled. On the other side, spin_sdk::http::body::stream() gives us a sender and a streaming body. A task started with spin_sdk::wasip3::spawn reads compressed data from the encoder in 8 KiB chunks and pushes them into the sender, while the response is already on its way to the caller.
Because the compressed size isn’t known upfront, we edit the headers on parts: we drop Content-Length, set Content-Encoding: gzip, and add Vary: Accept-Encoding, so caches keep both variants apart. As with auth, both branches return different body types, hence box_body.
We also skip tiny responses: below roughly 1 KB, gzip’s own overhead eats most of the savings and can even make a response bigger. The threshold comes from the gzip_min_size variable, so you can tune it without touching code. If the API doesn’t send a Content-Length, we compress regardless.
Wiring It Up
Add gzip to the middleware list of our trigger, right after auth. The list is a pipeline: each middleware wraps everything after it, so requests pass through it front to back and responses travel back to front. With auth first, rejected requests never reach gzip, and the 401 goes out uncompressed. Only responses from the API pass back through gzip:
[[trigger.http]]
route = "/api/..."
component = "api"
dependencies.middleware = [
{ component = "auth", inherit_configuration = ["variables"] },
{ component = "gzip", inherit_configuration = ["variables"] },
]
[component.api]
# existing settings...
variables = { auth_token = "{{ auth_token }}", gzip_min_size = "{{ gzip_min_size }}" }
Testing It
Build and run the application one last time:
spin build
spin up
By default, curl sends no Accept-Encoding header, so this request still returns the plain JSON object:
curl -i -H 'Authorization: Bearer 6f1c2e9a-4b7d-4c3e-9a1f-2d8e5b0c7a64' \
localhost:3000/api/
Now we tell the API that we understand gzip, but we don’t use --compressed, so curl won’t decompress for us. The response carries content-encoding: gzip, and running the raw body through gunzip proves it’s the same JSON:
curl -s -D - -o body.gz \
-H 'Accept-Encoding: gzip' \
-H 'Authorization: Bearer 6f1c2e9a-4b7d-4c3e-9a1f-2d8e5b0c7a64' \
localhost:3000/api/posts
gunzip -c body.gz
The small JSON object on /api/ stays below the threshold, so it’s returned uncompressed even with Accept-Encoding: gzip. To see what compression buys us on /api/posts, compare the downloaded bytes with and without gzip:
TOKEN='Authorization: Bearer 6f1c2e9a-4b7d-4c3e-9a1f-2d8e5b0c7a64'
curl -s -o /dev/null -w '%{size_download}\n' -H "$TOKEN" localhost:3000/api/posts
curl -s -o /dev/null -w '%{size_download}\n' -H "$TOKEN" \
-H 'Accept-Encoding: gzip' localhost:3000/api/posts
In our run, /api/posts came in at 27,520 bytes without gzip and 7,085 bytes with gzip, roughly 74% smaller.
Reusing The Middlewares
Here’s where middleware as components really pays off. Say we add a second component, reports, on its own route. Protecting and compressing it takes no code at all, just one more trigger:
[[trigger.http]]
route = "/reports/..."
component = "reports"
dependencies.middleware = [
{ component = "auth", inherit_configuration = ["variables"] },
{ component = "gzip", inherit_configuration = ["variables"] },
]
[component.reports]
# existing settings...
variables = { auth_token = "{{ auth_token }}", gzip_min_size = "{{ gzip_min_size }}" }
The reports component doesn’t have to be written in Rust: a Python or Go component works just as well, as long as it targets WASIp3. The middlewares don’t care.
Recap
With Spin 4.3, middleware is just another Spin HTTP component:
- Familiar tooling: middlewares are scaffolded with the regular templates and use one extra SDK API to call the next element in the pipeline.
- Invisible to the API: the API doesn’t know about either middleware. Middleware wraps any component that exports
wasi:http/handler@0.3(WASIp3), which the Rust, Go and Python toolchains produce today; JavaScript/TypeScript isn’t there yet. - Capabilities are inherited: middleware can’t own capabilities, so
authandgzipinherit their variables from the API component viainherit_configuration. - Composition on the trigger:
dependencies.middlewaredecides what sits in front of a component, and in which order.
The API stays focused on its job, while cross-cutting concerns live in components you can put in front of any other WASIp3 component.
