Skip to content
Select theme

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.

Terminal window
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 Created
content-type: application/json
content-length: 55
date: 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.

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:

Terminal window
cargo add tower

After the add, [dependencies] reads:

Cargo.toml
[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.

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:

src/main.rs
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.

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 struct
192 | 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 default

The 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.

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:

Terminal window
DATABASE_URL='postgres://postgres:dev@localhost:5432/shortlink' cargo run
thread 'main' (14412948) panicked at src/main.rs:401:50:
SHORTLINK_API_KEY is not set: NotPresent
note: 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:

src/main.rs
#[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 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-key

The 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.

Back in the real service, boot with a key:

Terminal window
DATABASE_URL='postgres://postgres:dev@localhost:5432/shortlink' \
SHORTLINK_API_KEY=dev-key cargo run

The same POST that opened the article:

Terminal window
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 Unauthorized
content-type: application/json
www-authenticate: Bearer
content-length: 27
date: Sun, 18 Oct 2026 14:35:23 GMT
{"error":"missing API key"}

The three failure modes read differently:

Terminal window
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 Unauthorized
content-type: application/json
www-authenticate: Bearer
content-length: 35
date: Sun, 18 Oct 2026 14:35:23 GMT
{"error":"expected a Bearer token"}
Terminal window
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 Unauthorized
content-type: application/json
www-authenticate: Bearer
content-length: 27
date: Sun, 18 Oct 2026 14:35:23 GMT
{"error":"invalid API key"}

With the key, everything from the last three steps still works:

Terminal window
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 Created
content-type: application/json
content-length: 54
date: Sun, 18 Oct 2026 14:36:29 GMT
{"code":"u2FL7EP","url":"https://example.com/article"}
Terminal window
curl -si -H 'Authorization: Bearer dev-key' http://127.0.0.1:8000/links
HTTP/1.1 200 OK
content-type: application/json
content-length: 86
date: 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:

Terminal window
curl -si -X POST http://127.0.0.1:8000/links -d '{"url":"https://example.com/x"}'
HTTP/1.1 401 Unauthorized
content-type: application/json
www-authenticate: Bearer
content-length: 27
date: Sun, 18 Oct 2026 14:36:42 GMT
{"error":"missing API key"}

The public routes stay public, key or no key:

Terminal window
curl -si http://127.0.0.1:8000/u2FL7EP
HTTP/1.1 302 Found
location: https://example.com/article
content-length: 0
date: Sun, 18 Oct 2026 14:36:42 GMT
Terminal window
curl -si http://127.0.0.1:8000/health
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 2
date: Sun, 18 Oct 2026 14:45:02 GMT
ok

And the typo keeps its original answer, thanks to route_layer:

Terminal window
curl -si http://127.0.0.1:8000/foo/bar
HTTP/1.1 404 Not Found
content-length: 0
date: Sun, 18 Oct 2026 14:35:23 GMT

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.