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();
}
Source: tower-http: CorsLayer and axum example: cors
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
Source: tower-http: CorsLayer and axum example: cors
-
CorsLayeranswers preflightOPTIONSrequests itself; they never reach the router, so you need nooptionsroutes. Apply it withRouter::layer(notroute_layer) so preflights to any path are handled. -
For a disallowed origin, the layer omits the
Access-Control-Allow-Originheader (the otherAccess-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), andAllowOrigin::mirror_request()echoes the request’s origin — equivalent to allowing every origin, so use it only for public, credential-free APIs. -
Vary: originis set automatically so caches keep per-origin variants; tower-http 0.7 relaxed theVarydefaults, 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: *`
Source: tower-http: CorsLayer and axum example: cors
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.
Source: tower-http: cors
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();
}
Source: tower-http: compression and axum example: compression
$ 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
Source: tower-http: compression and axum example: compression
-
The default predicate (
DefaultPredicate) already skips bodies under 32 bytes, gRPC, images and Server-Sent Events;compress_whenreplaces 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=0with nothing else acceptable, or*;q=0) receives406 Not Acceptableas 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();
}
Source: tower-http: decompression
$ printf 'isbn,title\n9780441013593,Dune\n' | gzip | curl -s -H 'content-encoding: gzip' --data-binary @- localhost:3000/api/v1/imports
30 bytes after decompression
Source: tower-http: 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.