CORS & Compression

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against tower-http 0.7.1 with axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06. It covers only the axum configuration; how CORS works in browsers — simple and preflighted requests, the same-origin policy, why CORS is not an authorisation mechanism — is explained on CORS.

CORS with CorsLayer

The Bookshelf JSON API is called from its own server-rendered pages (same origin, no CORS needed) and from a separate single-page app at https://app.bookshelf.example, which needs CORS:

use std::time::Duration;

use axum::{
    Json, Router,
    http::{HeaderName, HeaderValue, Method, header},
    routing::get,
};
use tower_http::cors::{AllowOrigin, CorsLayer};

fn cors() -> CorsLayer {
    let allowed: Vec<HeaderValue> = ["https://app.bookshelf.example", "http://localhost:5173"]
        .into_iter()
        .map(|o| o.parse().expect("valid origin"))
        .collect();
    CorsLayer::new()
        .allow_origin(AllowOrigin::list(allowed))
        .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
        .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION, HeaderName::from_static("x-request-id")])
        .expose_headers([header::LOCATION, header::ETAG, HeaderName::from_static("x-request-id")])
        .allow_credentials(true) // the SPA sends the session cookie
        .max_age(Duration::from_secs(600)) // browsers may cache the preflight for 10 minutes
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/api/v1/books", get(|| async { Json(vec!["Dune"]) }).post(|| async { "created" }))
        .layer(cors());
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
sequenceDiagram participant B as Browser at app.bookshelf.example participant C as CorsLayer participant R as Bookshelf router B->>C: OPTIONS /api/v1/books
Origin, Access-Control-Request-Method: POST,
Access-Control-Request-Headers: content-type C-->>B: 200 OK, Access-Control-Allow-Origin: https://app.bookshelf.example,
Allow-Methods, Allow-Headers, Allow-Credentials, Max-Age: 600 Note over B: preflight cached for 10 minutes B->>C: POST /api/v1/books, Origin, Cookie, JSON body C->>R: forward the request R-->>C: 201 Created C-->>B: 201 + Access-Control-Allow-Origin, Allow-Credentials,
Expose-Headers: location, etag, x-request-id
$ curl -si -X OPTIONS localhost:3000/api/v1/books -H 'origin: https://app.bookshelf.example' \
    -H 'access-control-request-method: POST' -H 'access-control-request-headers: content-type'
HTTP/1.1 200 OK
access-control-allow-credentials: true
vary: origin
access-control-allow-methods: GET,POST,PUT,DELETE
access-control-allow-headers: content-type,authorization,x-request-id
access-control-max-age: 600
access-control-allow-origin: https://app.bookshelf.example
allow: GET,HEAD,POST
content-length: 0
$ curl -si localhost:3000/api/v1/books -H 'origin: https://evil.example' | grep -i access-control
access-control-allow-credentials: true
access-control-expose-headers: location,etag,x-request-id
  • CorsLayer answers preflight OPTIONS requests itself; they never reach the router, so you need no options routes. Apply it with Router::layer (not route_layer) so preflights to any path are handled.

  • For a disallowed origin, the layer omits the Access-Control-Allow-Origin header (the other Access-Control-* headers may still appear, as above) and the browser blocks the response. The server still executed the request: CORS protects the user’s data from other sites' scripts, not your server from requests. Pair cookie-authenticated APIs with CSRF protection (see CSRF & Security Hardening).

  • AllowOrigin::predicate(|origin, parts| ...) decides dynamically (for example from a tenant table), and AllowOrigin::mirror_request() echoes the request’s origin — equivalent to allowing every origin, so use it only for public, credential-free APIs.

  • Vary: origin is set automatically so caches keep per-origin variants; tower-http 0.7 relaxed the Vary defaults, so check them if a CDN caches your API.

The Any + credentials pitfall

Access-Control-Allow-Origin: * is not allowed together with Access-Control-Allow-Credentials: true — browsers reject the combination. tower-http checks the configuration when the layer is applied and panics at start-up:

thread 'main' panicked at tower-http-0.7.1/src/cors/mod.rs:834:9:
Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` with
`Access-Control-Allow-Origin: *`

The same check applies to Any methods and headers with credentials. The fix is an explicit origin list (as above) or, when you really must accept arbitrary origins with credentials, mirror_request() — which you should treat as a security review item. CorsLayer::permissive() (Any everything, no credentials) and very_permissive() (mirrors origins, allows credentials) exist for local development; never ship them.

Response compression

CompressionLayer compresses response bodies according to the client’s Accept-Encoding, sets Content-Encoding and Vary: accept-encoding, and removes Content-Length (the compressed size is not known in advance). Each algorithm is a Cargo feature: compression-gzip, compression-br, compression-zstd, compression-deflate (compression-full for all).

use axum::{Json, Router, routing::get};
use tower_http::compression::{
    CompressionLayer, CompressionLevel,
    predicate::{NotForContentType, Predicate, SizeAbove},
};

async fn catalogue() -> Json<Vec<String>> {
    Json((1..=500).map(|i| format!("Book number {i}")).collect())
}

#[tokio::main]
async fn main() {
    let compression = CompressionLayer::new()
        .gzip(true)
        .br(true)
        .zstd(true)
        .deflate(false)
        .quality(CompressionLevel::Fastest) // CPU matters more than the last few percent for dynamic responses
        .compress_when(
            SizeAbove::new(1024) // not worth it for tiny bodies (the default threshold is 32 bytes)
                .and(NotForContentType::GRPC)
                .and(NotForContentType::IMAGES)
                .and(NotForContentType::SSE),
        );
    let app = Router::new().route("/api/v1/books", get(catalogue)).layer(compression);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
$ curl -s localhost:3000/api/v1/books | wc -c
8893
$ curl -s -H 'accept-encoding: br' localhost:3000/api/v1/books | wc -c
1702
$ curl -s -o /dev/null -D - -H 'accept-encoding: gzip' localhost:3000/api/v1/books | grep -i -E 'content-encoding|vary'
vary: accept-encoding
content-encoding: gzip
  • The default predicate (DefaultPredicate) already skips bodies under 32 bytes, gRPC, images and Server-Sent Events; compress_when replaces it, so include those exclusions if you customise it.

  • Compression costs CPU per response. Behind a CDN or a reverse proxy that compresses, disable it in the application. Static assets are better served precompressed by ServeDir (see Static Files & SPAs).

  • Since tower-http 0.7, a client that explicitly refuses every encoding it is offered (identity;q=0 with nothing else acceptable, or *;q=0) receives 406 Not Acceptable as RFC 9110 requires; previously it silently got an uncompressed body.

  • Compressing responses that mix secrets with attacker-controlled input over TLS can leak information (the BREACH attack); avoid compressing pages that reflect user input next to CSRF tokens, or use per-request random masking.

Request decompression

RequestDecompressionLayer transparently decompresses request bodies sent with Content-Encoding: gzip (or br, zstd, deflate), so extractors see plain bytes. Unsupported encodings are rejected with 415 Unsupported Media Type. Put a RequestBodyLimitLayer inside it (after it in a ServiceBuilder) so the limit applies to the decompressed size — otherwise a small "zip bomb" can expand into gigabytes.

use axum::{Router, routing::post};
use tower::ServiceBuilder;
use tower_http::{decompression::RequestDecompressionLayer, limit::RequestBodyLimitLayer};

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/api/v1/imports", post(|body: String| async move { format!("{} bytes after decompression\n", body.len()) }))
        .layer(
            ServiceBuilder::new()
                .layer(RequestDecompressionLayer::new())
                .layer(RequestBodyLimitLayer::new(1024 * 1024)), // limits the decompressed body
        );
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
$ printf 'isbn,title\n9780441013593,Dune\n' | gzip | curl -s -H 'content-encoding: gzip' --data-binary @- localhost:3000/api/v1/imports
30 bytes after decompression

Where the books are outdated

Yoon (Beginning Axum, chapter 4) configures CorsLayer from tower-http 0.6, whose API is unchanged in 0.7 apart from relaxed Vary defaults. Many online examples use CorsLayer::permissive() or allow_origin(Any) "to make it work"; with cookies or Authorization headers that either panics or exposes the API to every website.