API key authentication with middleware
Step 11 of 15. Step 10 gave every failure a JSON body with the right status
code — and left the front door wide open. The responses are polished; the
access control is absent. Anyone who can reach the service can create links,
list them, and read every URL stored in the table. This step puts /links
behind an API key with a tower layer written by hand, and answers the question
step 10’s ApiError leaves open: how does an error raised outside a handler,
in code that runs before the router picks a route, reach the client as the
same JSON body every other failure uses?
Start from step 10’s end state: git checkout api-ch10 in your clone of
the examples repository, inside
axum-sqlx-api/, with the Postgres container from step 5 running and
DATABASE_URL exported.
What a stranger can do today
Section titled “What a stranger can do today”curl -si -X POST http://127.0.0.1:8000/links \ -H 'content-type: application/json' \ -d '{"url":"https://example.com/anything"}'HTTP/1.1 201 Createdcontent-type: application/jsoncontent-length: 55date: Sun, 18 Oct 2026 14:42:24 GMT
{"code":"oRVyWuf","url":"https://example.com/anything"}No header, no token, no problem. GET /links is just as open, and it hands
over the whole table. Meanwhile two routes must stay public: /{code} exists
so browsers can follow links, and browsers send no headers; /health is read
by a load balancer, which has none to offer either. So the layer goes on one
route only — /links, the one that reads and writes data.
One crate, two traits
Section titled “One crate, two traits”axum is built on tower — every handler and extractor in the stack is a
tower::Service underneath — but the traits live in their own crate, so
writing one by hand means naming it:
cargo add towerAfter the add, [dependencies] reads:
[dependencies]axum = { version = "0.8", features = ["macros"] }serde = { version = "1", features = ["derive"] }serde_json = "1"sqlx = { version = "0.9", features = ["runtime-tokio", "tls-rustls", "postgres", "macros"] }thiserror = "2"tokio = { version = "1", features = ["macros", "rt-multi-thread"] }tower = "0.5"validator = { version = "0.21", features = ["derive"] }Nothing else changes shape this step: same routes, same SQL, same .sqlx/
cache, same handlers.
The layer, by hand
Section titled “The layer, by hand”Two traits do the work. Layer is the factory — it holds the configuration
and produces the service. Service is the thing that sees requests. The
imports at the top of src/main.rs grow to this list, and everything below it
down to check_key is new:
use axum::{ Json, Router, extract::{Extension, FromRequest, Path, Query, Request}, http::{StatusCode, header, HeaderMap, HeaderValue}, response::{IntoResponse, Response}, routing::get,};use serde::{Deserialize, Serialize, de::DeserializeOwned};use serde_json::json;use sqlx::{PgPool, postgres::PgPoolOptions};use std::{ convert::Infallible, future::Future, pin::Pin, sync::Arc, task::{Context, Poll},};use thiserror::Error;use tower::{Layer, Service};use validator::{Validate, ValidationErrors};
#[derive(Debug, Error)]enum ApiError { // Extractor rejections carry their own status: 400 for malformed JSON, // 415 for a missing or wrong content-type. #[error("{1}")] BadBody(StatusCode, String), #[error("{0}")] Validation(#[from] ValidationErrors), #[error("{0}")] Unauthorized(&'static str), #[error("not found")] NotFound, // The sqlx error is kept as the source for logging (step 13) but never // serialised - its message names tables and columns a client has no // business reading. #[error("internal server error")] Database(#[from] sqlx::Error), #[error("internal server error")] Exhausted,}
impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = match &self { ApiError::BadBody(status, _) => *status, ApiError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY, ApiError::Unauthorized(_) => StatusCode::UNAUTHORIZED, ApiError::NotFound => StatusCode::NOT_FOUND, ApiError::Database(_) | ApiError::Exhausted => StatusCode::INTERNAL_SERVER_ERROR, }; (status, Json(json!({ "error": self.to_string() }))).into_response() }}
// The identity the layer proved. Handlers never re-check the header, and the// rate limiter in the next step reads it from the request extensions.#[derive(Clone)]struct ApiUser { key: Arc<str>,}
// Wraps the routes only an authenticated client may touch. A tower layer// sits between the router and the handler: it sees every request first and// can answer it without the handler ever running.#[derive(Clone)]struct RequireApiKey { key: Arc<str>,}
impl RequireApiKey { fn new(key: impl Into<Arc<str>>) -> Self { Self { key: key.into() } }}
impl<S> Layer<S> for RequireApiKey { type Service = RequireApiKeyService<S>;
fn layer(&self, inner: S) -> Self::Service { RequireApiKeyService { inner, key: self.key.clone(), } }}
#[derive(Clone)]struct RequireApiKeyService<S> { inner: S, key: Arc<str>,}
impl<S> Service<Request> for RequireApiKeyService<S>where S: Service<Request, Response = Response, Error = Infallible> + Send + 'static, S::Future: Send + 'static,{ type Response = Response; // axum requires every service in the stack to be infallible: the router // has no Err branch to handle. A failed check therefore becomes a // Response here - which ApiError's IntoResponse already knows how to // build. type Error = Infallible; type Future = Pin<Box<dyn Future<Output = Result<Response, Infallible>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Infallible>> { self.inner.poll_ready(cx) }
fn call(&mut self, req: Request) -> Self::Future { match check_key(req.headers(), &self.key) { Err(err) => { let mut res = err.into_response(); // RFC 9110: a 401 tells the client how to authenticate. This // is the only place the header can be added - handlers never // see a rejected request. res.headers_mut().insert( header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"), ); Box::pin(async move { Ok(res) }) } Ok(()) => { let mut req = req; req.extensions_mut().insert(ApiUser { key: self.key.clone(), }); Box::pin(self.inner.call(req)) } } }}
// The scheme is case-insensitive per RFC 9110, so "bearer" passes too.fn check_key(headers: &HeaderMap, key: &str) -> Result<(), ApiError> { let value = headers .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .ok_or(ApiError::Unauthorized("missing API key"))?; let token = value .split_once(' ') .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("bearer")) .map(|(_, token)| token) .ok_or(ApiError::Unauthorized("expected a Bearer token"))?; if token != key { return Err(ApiError::Unauthorized("invalid API key")); } Ok(())}A Layer is only a factory: the router calls layer once, handing it
whatever sits inside, and gets a Service back. Service is where requests
flow. The Clone on both types is not decoration — tower clones services to
hand a copy to each connection, which is why the key lives behind an Arc
instead of being moved. poll_ready forwards untouched; this layer has no
backpressure of its own.
call is the whole feature. It checks the header first; on failure it never
touches inner — the request dies right here. On success it inserts the
identity and lets the request continue.
Now the part the brief calls errors from inside it. A handler reports failure
through its return type — Result<_, ApiError> — and axum turns the Err
branch into a response. Middleware has no such channel: axum requires every
service in the stack to declare Error = Infallible, because the router has
nowhere to route an Err. So an auth failure cannot be an error. It has to
become a Response inside call — and the cheapest way to build one is
err.into_response() on an ApiError, the exact IntoResponse
implementation step 10 wrote for handlers. That is why the 401 comes back as
{"error":"..."} in the same shape as the 404 and the 500: one error type,
one body format, two places that produce it.
The one header added on top is WWW-Authenticate: Bearer. RFC 9110 says a
401 should tell the client how to authenticate, and only the middleware can
say it — handlers never see a rejected request.
check_key answers with three different messages — missing header, wrong
scheme, wrong key — all through one enum variant. The scheme comparison uses
eq_ignore_ascii_case because HTTP scheme names are case-insensitive; a
client that sends bearer dev-key is correct, and rejecting it would be a
bug in this code.
One honest limit: token != key is an ordinary string comparison, which
short-circuits on the first differing byte. Timing that difference is a known
side channel, and a real service with real secrets compares in constant time
(the subtle crate’s ConstantTimeEq, one line). For a dev key in an
environment variable the risk is mostly theoretical — but the hole is there,
and pretending otherwise would be worse than the two-line fix later.
The identity, and a warning you will see
Section titled “The identity, and a warning you will see”ApiUser goes into the request extensions — the side channel that travels
with the request through the stack. Extension extractors read it like they
read the pool. No handler reads it yet, and cargo build notices:
warning: field `key` is never read --> src/main.rs:192:5 |191 | struct ApiUser { | ------- field in this struct192 | key: Arc<str>, | ^^^ | = note: `ApiUser` has a derived impl for the trait `Clone`, but this is intentionally ignored during dead code analysis = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by defaultThe compiler is right. The next step is rate limiting per API key, and a rate
limiter needs to know which key made the request — that is what reads
ApiUser out of the extensions. If the warning survives that step, the field
gets deleted. For now the request carries the identity whether or not anyone
looks at it.
Wiring it into the router
Section titled “Wiring it into the router”The key comes from the environment at startup, and the service refuses to
boot without one — a missing key means every /links request is rejected,
which is better discovered at boot than by the first client:
DATABASE_URL='postgres://postgres:dev@localhost:5432/shortlink' cargo runthread 'main' (14412948) panicked at src/main.rs:401:50:SHORTLINK_API_KEY is not set: NotPresentnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace(The thread id is yours, not this number’s.)
Then the router split. main now reads:
#[tokio::main]async fn main() { let url = std::env::var("DATABASE_URL").expect("DATABASE_URL is not set"); let pool = PgPoolOptions::new() .max_connections(5) .acquire_timeout(std::time::Duration::from_secs(3)) .connect(&url) .await .expect("failed to connect to DATABASE_URL");
sqlx::migrate!().run(&pool).await.expect("failed to run migrations");
// The key lives in the environment, not in the source. The service // refuses to start without one - a missing key means every /links // request is rejected, which is better discovered at boot. let key = std::env::var("SHORTLINK_API_KEY").expect("SHORTLINK_API_KEY is not set");
// Two routers, because the routes have different audiences: browsers // hitting /{code} send no headers, and a load balancer polling /health // has none to offer. Only /links - the routes that change or read data - // sit behind the layer. route_layer, not layer: it runs only once a // route has matched, so a typo like /foo/bar stays a 404 instead of // being told to authenticate against a path that does not exist. let protected = Router::new() .route("/links", get(list_links).post(create_link)) .route_layer(RequireApiKey::new(key));
let app = Router::new() .route("/health", get(health)) .route("/{code}", get(resolve_link)) .merge(protected) .layer(Extension(pool));
let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap(); axum::serve(listener, app).await.unwrap();}(The block is marked unverified because the sqlx::migrate! macro reads
migrations/ at compile time, which the verifier’s standalone sandbox does
not have — step 6 hit the same wall. The file itself is in the tag and
cargo build proves it.)
The router split does what the comment says: two routers, each holding the
routes for its audience, merged before the shared Extension(pool) layer.
The one word worth an experiment is route_layer. Swap it for layer and
rebuild, and GET /foo/bar — a path no route matches — comes back
401 Unauthorized instead of 404: with layer, the middleware also wraps
the router’s fallback, so it runs even when nothing matched. route_layer
runs the middleware only after a route has matched, which is what an API
wants: a client that mistypes a path should hear “no such page”, not
“authenticate first”.
The flow, offline
Section titled “The flow, offline”The layer’s whole behaviour fits in a scratch cargo new — no server, no
database. The innermost service here is a stub that reads the identity and
reports it; in the real service that position is the handler stack the router
built. The enum is trimmed to the variant the layer produces:
use axum::{ body::Body, extract::Request, http::{HeaderValue, StatusCode, header}, response::{IntoResponse, Response},};use std::{ convert::Infallible, future::{Future, ready, Ready}, pin::Pin, sync::Arc, task::{Context, Poll},};use serde_json::json;use thiserror::Error;use tower::{Layer, Service};
#[derive(Debug, Error)]enum ApiError { #[error("{0}")] Unauthorized(&'static str),}
impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = StatusCode::UNAUTHORIZED; (status, axum::Json(json!({ "error": self.to_string() }))).into_response() }}
#[derive(Clone)]struct ApiUser { key: Arc<str>,}
#[derive(Clone)]struct RequireApiKey { key: Arc<str>,}
impl RequireApiKey { fn new(key: impl Into<Arc<str>>) -> Self { Self { key: key.into() } }}
impl<S> Layer<S> for RequireApiKey { type Service = RequireApiKeyService<S>;
fn layer(&self, inner: S) -> Self::Service { RequireApiKeyService { inner, key: self.key.clone(), } }}
#[derive(Clone)]struct RequireApiKeyService<S> { inner: S, key: Arc<str>,}
impl<S> Service<Request> for RequireApiKeyService<S>where S: Service<Request, Response = Response, Error = Infallible> + Send + 'static, S::Future: Send + 'static,{ type Response = Response; type Error = Infallible; type Future = Pin<Box<dyn Future<Output = Result<Response, Infallible>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Infallible>> { self.inner.poll_ready(cx) }
fn call(&mut self, req: Request) -> Self::Future { match check_key(req.headers(), &self.key) { Err(err) => { let mut res = err.into_response(); res.headers_mut().insert( header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"), ); Box::pin(async move { Ok(res) }) } Ok(()) => { let mut req = req; req.extensions_mut().insert(ApiUser { key: self.key.clone(), }); Box::pin(self.inner.call(req)) } } }}
fn check_key(headers: &axum::http::HeaderMap, key: &str) -> Result<(), ApiError> { let value = headers .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .ok_or(ApiError::Unauthorized("missing API key"))?; let token = value .split_once(' ') .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("bearer")) .map(|(_, token)| token) .ok_or(ApiError::Unauthorized("expected a Bearer token"))?; if token != key { return Err(ApiError::Unauthorized("invalid API key")); } Ok(())}
// In the real service this is the handler stack the router built. A stub// keeps the demo offline and shows what the layer hands it.struct Stub;
impl Service<Request> for Stub { type Response = Response; type Error = Infallible; type Future = Ready<Result<Response, Infallible>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Infallible>> { Poll::Ready(Ok(())) }
fn call(&mut self, req: Request) -> Self::Future { let user = req .extensions() .get::<ApiUser>() .expect("the layer did not run"); ready(Ok(format!("authenticated as {}\n", user.key).into_response())) }}
#[tokio::main]async fn main() { let mut svc = RequireApiKey::new("dev-key").layer(Stub);
let request = |value: Option<&str>| { let mut req = Request::new(Body::empty()); if let Some(v) = value { req.headers_mut() .insert(header::AUTHORIZATION, HeaderValue::from_str(v).unwrap()); } req };
for (name, req) in [ ("no header", request(None)), ("wrong scheme", request(Some("Basic ZGV2OnByb2R1Y3Rpb24="))), ("wrong key", request(Some("Bearer nope"))), ("right key", request(Some("Bearer dev-key"))), ] { let res = svc.call(req).await.unwrap(); let status = res.status(); let www = res .headers() .get(header::WWW_AUTHENTICATE) .and_then(|v| v.to_str().ok()) .map(str::to_owned) .unwrap_or_else(|| "-".into()); let body = axum::body::to_bytes(res.into_body(), 1024).await.unwrap(); println!("{name}: {status}"); println!(" www-authenticate: {www}"); print!(" {}", String::from_utf8_lossy(&body)); println!(); }}no header: 401 Unauthorized www-authenticate: Bearer {"error":"missing API key"}wrong scheme: 401 Unauthorized www-authenticate: Bearer {"error":"expected a Bearer token"}wrong key: 401 Unauthorized www-authenticate: Bearer {"error":"invalid API key"}right key: 200 OK www-authenticate: - authenticated as dev-keyThe last case is the point of the exercise: the stub never saw a header, and it did not need to — the layer had already checked it and left the identity in the extensions. The 200 is the stub’s own choice; a real handler returns whatever the route returns.
See it run
Section titled “See it run”Back in the real service, boot with a key:
DATABASE_URL='postgres://postgres:dev@localhost:5432/shortlink' \SHORTLINK_API_KEY=dev-key cargo runThe same POST that opened the article:
curl -si -X POST http://127.0.0.1:8000/links \ -H 'content-type: application/json' \ -d '{"url":"https://example.com/article"}'HTTP/1.1 401 Unauthorizedcontent-type: application/jsonwww-authenticate: Bearercontent-length: 27date: Sun, 18 Oct 2026 14:35:23 GMT
{"error":"missing API key"}The three failure modes read differently:
curl -si -X POST http://127.0.0.1:8000/links \ -H 'content-type: application/json' \ -H 'Authorization: Basic ZGV2OnByb2R1Y3Rpb24=' \ -d '{"url":"https://example.com/article"}'HTTP/1.1 401 Unauthorizedcontent-type: application/jsonwww-authenticate: Bearercontent-length: 35date: Sun, 18 Oct 2026 14:35:23 GMT
{"error":"expected a Bearer token"}curl -si -X POST http://127.0.0.1:8000/links \ -H 'content-type: application/json' \ -H 'Authorization: Bearer wrong-key' \ -d '{"url":"https://example.com/article"}'HTTP/1.1 401 Unauthorizedcontent-type: application/jsonwww-authenticate: Bearercontent-length: 27date: Sun, 18 Oct 2026 14:35:23 GMT
{"error":"invalid API key"}With the key, everything from the last three steps still works:
curl -si -X POST http://127.0.0.1:8000/links \ -H 'content-type: application/json' \ -H 'Authorization: Bearer dev-key' \ -d '{"url":"https://example.com/article"}'HTTP/1.1 201 Createdcontent-type: application/jsoncontent-length: 54date: Sun, 18 Oct 2026 14:36:29 GMT
{"code":"u2FL7EP","url":"https://example.com/article"}curl -si -H 'Authorization: Bearer dev-key' http://127.0.0.1:8000/linksHTTP/1.1 200 OKcontent-type: application/jsoncontent-length: 86date: Sun, 18 Oct 2026 14:36:29 GMT
{"page":1,"limit":20,"links":[{"code":"u2FL7EP","url":"https://example.com/article"}]}One more check proves the layer runs before the extractors. Post without a
key and without a content-type — step 10 would have answered 415 from the
Json extractor, but the extractor never gets the chance now:
curl -si -X POST http://127.0.0.1:8000/links -d '{"url":"https://example.com/x"}'HTTP/1.1 401 Unauthorizedcontent-type: application/jsonwww-authenticate: Bearercontent-length: 27date: Sun, 18 Oct 2026 14:36:42 GMT
{"error":"missing API key"}The public routes stay public, key or no key:
curl -si http://127.0.0.1:8000/u2FL7EPHTTP/1.1 302 Foundlocation: https://example.com/articlecontent-length: 0date: Sun, 18 Oct 2026 14:36:42 GMTcurl -si http://127.0.0.1:8000/healthHTTP/1.1 200 OKcontent-type: text/plain; charset=utf-8content-length: 2date: Sun, 18 Oct 2026 14:45:02 GMT
okAnd the typo keeps its original answer, thanks to route_layer:
curl -si http://127.0.0.1:8000/foo/barHTTP/1.1 404 Not Foundcontent-length: 0date: Sun, 18 Oct 2026 14:35:23 GMTWhere this leaves the service
Section titled “Where this leaves the service”Writing to the API now requires a key the client was given; the redirect that
the whole service exists for still works for a browser that has never heard
of the key. The error format did not fork: a failure inside middleware and a
failure inside a handler produce the same JSON, because both go through one
IntoResponse.
The end state is tagged api-ch11 in
the examples repository —
git checkout api-ch11 inside axum-sqlx-api/. The encoder tests still run
offline with cargo test; the curl sequence above needs the container up.
Next: rate limiting per API key. The bucket has to know which key is asking — which is what the layer now leaves in the request extensions, the piece of this step the compiler says nobody reads yet. The track overview keeps the full list current as articles land.