Community Perk$300 in Akamai Cloud credits for buildersClaim your credits

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.

The API plus the auth and gzip middlewares, ready to build and runakamai-developers/middlewares-with-spin

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.0 or newer)
  • Rust with the wasm32-wasip2 target (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 auth middleware that checks the Authorization header for a secret token and rejects everything else with a 401.
  • An outbound gzip middleware 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.

How requests travel through the Spin middleware pipeline A request with a valid token passes auth and gzip and reaches the API; the response travels back and gzip compresses it. A request without a token is answered by auth with a 401 and never reaches gzip or the API. Valid token: the API answers, gzip compresses the response No token: auth answers with 401, the API never sees the request spin app: middlewares-in-spin request response caller curl auth middleware gzip middleware api /api/... request response gzipped response 401

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 auth and gzip inherit their variables from the API component via inherit_configuration.
  • Composition on the trigger: dependencies.middleware decides 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.

Thorsten Hans
Thorsten Hans
Sr. Developer Advocate

Thorsten Hans is a Senior Developer Advocate at Akamai, Docker Captain, and Wasm enthusiast. He thrives on pushing the limits of WebAssembly and Edge Computing to help developers build high-performance distributed systems. Thorsten shares his technical deep-dives via his blog and on global stages to shape the cloud's future.