middleware — TutorialsThese tutorials build up a small Actix-Web service step by step, adding one middleware at a time. Each section assumes the previous ones are in place, so by the end you have a service with request tracing, JWT auth, rate limiting, pagination, idempotent writes, and route-level permissions all working together correctly-ordered.
If you only need a single middleware in isolation, see EXAMPLES.md instead.
RequestIdRequestId is almost always the outermost middleware, because other pieces
(ReadContext<T>) depend on it having already run.
use actix_web::{web, App, HttpServer, HttpResponse};
use actixutils::middleware::RequestId;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.wrap(RequestId)
.route("/ping", web::get().to(ping))
})
.bind(("0.0.0.0", 8080))?
.run()
.await
}
async fn ping() -> HttpResponse {
HttpResponse::Ok().finish()
}
Run it and check the response headers — every response now carries an
X-Request-Id: <uuid> header, and every tracing span emitted while
handling the request includes the same id. Handlers can read it back with:
use actix_web::{HttpRequest, HttpMessage};
use actixutils::middleware::RequestIdStr;
async fn ping(req: HttpRequest) -> HttpResponse {
let rid = req.extensions().get::<RequestIdStr>().unwrap().0.clone();
tracing::info!(request_id = %rid, "handled ping");
HttpResponse::Ok().finish()
}
Auth<T>Next, protect an API scope with bearer-token auth. Auth<T> needs a
validator — anything implementing actixutils::locals::Validate<T>. We’ll use
the crate’s HS256Signer against an Identity claims type.
use actix_web::{web, App};
use actixutils::locals::{HS256Signer, Identity, Validate};
use actixutils::middleware::{Auth, RequestId};
use std::sync::Arc;
let signer: Arc<dyn Validate<Identity>> =
Arc::new(HS256Signer::new("my-service".to_string(), "super-secret".to_string()));
App::new()
.wrap(RequestId)
.service(
web::scope("/api")
.wrap(Auth { validator: signer.clone() })
.route("/me", web::get().to(me_handler)),
);
async fn me_handler(req: actix_web::HttpRequest) -> actix_web::HttpResponse {
use actix_web::HttpMessage;
let identity = req.extensions().get::<Identity>().cloned();
actix_web::HttpResponse::Ok().json(identity.is_some())
}
What happens on a request:
Auth<T> looks for the token in the Authorization: Bearer <token> header,
falling back to an access_token cookie.401 Unauthorized immediately.signer.validate(&token) fails → 401 Unauthorized, with the underlying
error logged via tracing::warn!.Identity (or whatever T you chose) is
inserted into request extensions for every downstream handler and
middleware to read.If a value of type T is already in extensions (e.g. inserted by an outer
middleware layer during testing), Auth<T> skips validation entirely — handy
for injecting a fake principal in integration tests without a real token.
RateLimiter<T>RateLimiter<T> needs a key type that implements both FromRequest (to pull
it out of the request) and GetId (to turn it into a hashable key). We’ll key
on the Identity extractor from step 2.
First, implement GetId once for your key type:
use actixutils::extractors::Jwt;
use actixutils::locals::Identity;
use actixutils::locals::rate_limiter::GetId;
use uuid::Uuid;
impl GetId for Jwt<Identity> {
type Id = Uuid;
fn id(&self) -> Uuid {
self.0.sub
}
}
RateLimiter::new also needs a backing store — Arc<dyn locals::Store<Uuid, VecDeque<Instant>>>
here, since Jwt<Identity>::Id is Uuid. actixutils doesn’t ship a default
in-memory Store, so supply a minimal one (a HashMap behind a lock is
enough for a single process; see src/middleware/test_session.rs for the
same pattern used with sessions):
use actixutils::locals::Store;
use async_trait::async_trait;
use std::{collections::{HashMap, VecDeque}, error::Error, sync::{Arc, Instant}};
use tokio::sync::RwLock;
use uuid::Uuid;
struct MemoryRateStore {
inner: RwLock<HashMap<Uuid, VecDeque<Instant>>>,
}
#[async_trait]
impl Store<Uuid, VecDeque<Instant>> for MemoryRateStore {
async fn get(&self, key: &Uuid) -> Result<Option<VecDeque<Instant>>, Box<dyn Error>> {
Ok(self.inner.read().await.get(key).cloned())
}
async fn set(&self, key: &Uuid, value: VecDeque<Instant>) -> Result<(), Box<dyn Error>> {
self.inner.write().await.insert(*key, value);
Ok(())
}
async fn delete(&self, key: &Uuid) -> Result<(), Box<dyn Error>> {
self.inner.write().await.remove(key);
Ok(())
}
async fn clear(&self) -> Result<(), Box<dyn Error>> {
self.inner.write().await.clear();
Ok(())
}
}
Then wrap the scope:
use actixutils::middleware::RateLimiter;
use std::time::Duration;
let rate_store = Arc::new(MemoryRateStore { inner: RwLock::new(HashMap::new()) });
web::scope("/api")
.wrap(Auth { validator: signer.clone() })
.wrap(RateLimiter::<Jwt<Identity>>::new(rate_store, 100, Duration::from_secs(60)))
.route("/me", web::get().to(me_handler));
This allows 100 requests per user per rolling 60-second window. Requests over
the limit get 429 Too Many Requests without the handler ever running. The
MemoryRateStore above is per-process — fine for a single instance, but for
multi-node deployments implement Store against a shared backend (Redis or
similar) instead.
Note on ordering: put
RateLimiterinsideAuth(i.e..wrap(Auth)then.wrap(RateLimiter), remembering.wrap()calls apply outer-to-inner in the order written but execute inner-to-outer at runtime — the important thing is that whatever extractorRateLimiteruses to identify the caller must succeed, which for a JWT-based key meansAuthisn’t strictly required first sinceRateLimiterperforms its own extraction viaFromRequest).
Idempotency<Store>For any POST/PUT/PATCH endpoint where a network retry could double-apply a
mutation (charging a card, creating an order), wrap it in Idempotency.
You need a store. Note that locals::IdempotencyStore is its own trait
family (acquire/get/complete/release), separate from the general
locals::Store<K, V> trait used by RateLimiter/Cache/SessionMiddleware
— it isn’t a Store implementation. For a quick start, an in-memory one:
use actixutils::locals::{IdempotencyStore, IdempotencyState, CachedResponse};
use async_trait::async_trait;
use std::{collections::HashMap, sync::{Arc, Mutex}, time::Duration};
#[derive(Default)]
struct MemoryIdempotencyStore {
entries: Mutex<HashMap<String, IdempotencyState>>,
}
#[async_trait]
impl IdempotencyStore for MemoryIdempotencyStore {
type Error = std::convert::Infallible;
async fn acquire(&self, key: &str, _ttl: Duration) -> Result<bool, Self::Error> {
let mut entries = self.entries.lock().unwrap();
if entries.contains_key(key) {
Ok(false)
} else {
entries.insert(key.to_string(), IdempotencyState::InProgress);
Ok(true)
}
}
async fn get(&self, key: &str) -> Result<Option<IdempotencyState>, Self::Error> {
Ok(match self.entries.lock().unwrap().get(key) {
Some(IdempotencyState::InProgress) => Some(IdempotencyState::InProgress),
Some(IdempotencyState::Completed(r)) => Some(IdempotencyState::Completed(r.clone())),
None => None,
})
}
async fn complete(&self, key: &str, response: CachedResponse) -> Result<(), Self::Error> {
self.entries
.lock()
.unwrap()
.insert(key.to_string(), IdempotencyState::Completed(response));
Ok(())
}
async fn release(&self, key: &str) -> Result<(), Self::Error> {
self.entries.lock().unwrap().remove(key);
Ok(())
}
}
This single-mutex version is not perfectly atomic under extreme concurrency
on acquire (a real implementation would want an atomic check-and-insert,
which HashMap::entry under a single Mutex does provide here) — for
production, back this with Redis (SET key val NX EX ttl) or an equivalent
atomic primitive.
Wire it into the payments scope:
use actixutils::middleware::Idempotency;
let store = Arc::new(MemoryIdempotencyStore::default());
web::scope("/payments")
.wrap(Idempotency::new(store).ttl(Duration::from_secs(24 * 60 * 60)))
.route("/charge", web::post().to(charge_handler));
Now a client that wants a charge to be retry-safe sends an
Idempotency-Key: <client-generated-uuid> header. The first request with a
given key runs the handler normally and caches the resulting status, headers,
and body. Any subsequent request with the same key — while the first is still
in flight, or after it completed — gets the cached outcome instead of
re-running charge_handler. Requests with no Idempotency-Key header pass
through untouched, so this is safe to apply broadly.
PaginationMiddlewareFor list endpoints, avoid passing page/limit through every function
signature by reading them from a task-local instead.
use actixutils::middleware::PaginationMiddleware;
use actixutils::locals::Pagination;
web::scope("/items")
.wrap(PaginationMiddleware)
.route("", web::get().to(list_items));
async fn list_items() -> actix_web::HttpResponse {
let items = repo::list().await; // repo::list reads Pagination::get() internally, several calls deep
actix_web::HttpResponse::Ok().json(items)
}
mod repo {
use actixutils::locals::Pagination;
pub async fn list() -> Vec<String> {
let p = Pagination::get();
// SELECT ... LIMIT p.limit OFFSET (p.page * p.limit)
vec![]
}
}
?page= and ?limit= are optional; missing or unparseable values default to
page = 0, limit = 100. Because the value lives in a Tokio task-local
scoped around the whole request future, repo::list() can call
Pagination::get() without list_items ever seeing a Pagination value
itself.
Permissions<P>The permission submodule is a separate, authentication-agnostic RBAC layer.
It expects a Principal (something with a role() -> u128) to already be in
request extensions — typically inserted by the Auth<T> middleware from step
2, provided T implements Principal.
Define the permission map, usually as a JSON file checked into the repo:
{
"permissions": [
{ "method": "GET", "url": "/items", "bit_id": 0 },
{ "method": "POST", "url": "/items", "bit_id": 1 },
{ "method": "DELETE", "url": "/items/{id}", "bit_id": 2 }
]
}
Load it and wrap the scope, with the permissions middleware inside auth so the principal is already present:
use actixutils::middleware::{Permissions, PermissionSet, Principal};
#[derive(Clone)]
struct Identity { role: u128 /* ...other claims */ }
impl Principal for Identity {
fn role(&self) -> u128 { self.role }
}
let permissions = PermissionSet::from_file("permissions.json")?;
web::scope("/items")
.wrap(Auth { validator: signer.clone() }) // inserts Identity
.wrap(Permissions::<Identity>::new(permissions))
.route("", web::get().to(list_items))
.route("", web::post().to(create_item))
.route("/{id}", web::delete().to(delete_item));
This is default-deny: any (method, path) combination not listed in
permissions.json returns 403 Forbidden, even for an authenticated user. A
missing principal (auth middleware didn’t run, or the user genuinely isn’t
authenticated) returns 401 Unauthorized. A principal whose role() bitmask
doesn’t have the required bit set returns 403 Forbidden.
A full stack combining everything above, respecting dependency order (outermost first):
App::new()
.wrap(RequestId) // 1. correlation id, must be outermost
.service(
web::scope("/api")
.wrap(Auth { validator: signer.clone() }) // 2. authenticate, inserts Identity
.service(
web::scope("/items")
.wrap(Permissions::<Identity>::new(permissions.clone())) // 6. authorize
.wrap(PaginationMiddleware) // 5. list params
.wrap(RateLimiter::<Jwt<Identity>>::new(rate_store.clone(), 100, Duration::from_secs(60))) // 3.
.route("", web::get().to(list_items))
.route("", web::post().to(create_item)),
)
.service(
web::scope("/payments")
.wrap(Idempotency::new(idempotency_store.clone())) // 4. retry-safety
.route("/charge", web::post().to(charge_handler)),
),
);
From here, see EXAMPLES.md for standalone snippets covering
ResponseEqualizer, Session<T>, ReadContext<T>, AttachLocal<T>, Cache,
Singleflight<K, KeyFn>, TimeoutMiddleware, ClientIpMiddleware, and the
identity/authority helper functions that weren’t needed in this
walkthrough.