Axum API key authentication: layer, route_layer, and order
The twelve-line Axum auth middleware works. Then route_layer quietly hands strangers a route scanner, and ServiceBuilder reverses your layer order. Four probes against Axum 0.8.9.

Sorower
Co-founder

In this article
- The twelve lines of Axum API key authentication everyone ships
- Question one: does this run on routes that do not exist?
- Question two: which of your layers runs first?
- Question three: what is your rate limiter counting?
- A small win: you can have the route template
- What twelve lines still cannot do
- Handing it to a service instead
- If you want the wider Axum picture
- Key takeaways
Axum API key authentication, in every tutorial you will find, is about twelve lines of middleware. Read the header, compare it to a string, return 401 or call next.run(). It compiles, the tests pass, and you ship it on a Tuesday.
Then two things happen that you never chose. Your auth stops running on some requests. And when a colleague tidies your three .layer() calls into a ServiceBuilder, your middleware quietly starts running in the opposite order.
Neither is a bug. Both are documented. Neither shows up in a single tutorial, because the tutorials stop at the twelve lines. This post is the part after that, and every claim in it comes from a probe I ran against Axum 0.8.9 on rustc 1.97.1 while writing.
The twelve lines of Axum API key authentication everyone ships
Let us be fair to the tutorials first. Here is the shape they all teach, in Axum 0.8:
use axum::{
extract::Request,
http::StatusCode,
middleware::Next,
response::Response,
};
async fn auth(req: Request, next: Next) -> Result<Response, StatusCode> {
let ok = req
.headers()
.get("x-api-key")
.and_then(|v| v.to_str().ok())
.map(|v| v == "secret")
.unwrap_or(false);
if ok {
Ok(next.run(req).await)
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
For an internal service behind a VPN with one caller, this is genuinely fine. Ship it. The problem is never the twelve lines. The problem is that the next four decisions get made for you by whichever line you happened to type, and nobody tells you a decision was made.
Question one: does this run on routes that do not exist?
Axum gives you two ways to attach that middleware, and they behave differently in a way that matters.
// option A
Router::new()
.route("/users/{id}", get(handler))
.route_layer(middleware::from_fn(auth));
// option B
Router::new()
.route("/users/{id}", get(handler))
.layer(middleware::from_fn(auth));
I ran both, on separate ports, against the same three requests. Results:
| Request | route_layer | layer |
|---|---|---|
GET /users/1, no key | 401 | 401 |
GET /users/1, valid key | 200 | 200 |
GET /admin/secret-panel, no key | 404 | 401 |
That last row is the whole thing. With route_layer, the middleware never runs for a path that did not match a route, so the router's 404 comes back untouched.
This is deliberate and the Axum docs say so plainly. The route_layer documentation describes it as useful for middleware that returns early, "such as authorization", which might otherwise convert a 404 into a 401. Axum is recommending route_layer for exactly your use case, and it is recommending it for a good reason: a 401 on every typo'd URL is a bad developer experience and it makes your API look broken.
Here is the part nobody writes down. That same behaviour hands an unauthenticated stranger a route scanner. Send a key-less request to a hundred guessed paths and sort the responses: 401 means the route exists, 404 means it does not. You have leaked your internal route map to someone who never authenticated.
When does that matter? If your route names are boring (/users, /orders), it does not. Attackers guess those anyway. It matters when a path name is itself a hint, which is more common than people think: /internal/replay-webhook, /admin/impersonate, /v2/pricing-experiment. Route names leak roadmap and they leak attack surface.
If you want both, keep route_layer and add a catch-all fallback that returns the same 404 shape your real routes return when auth fails. The point is to make the two cases indistinguishable, whichever way you go. What you should not do is pick one at random because a blog post did, which is what happens today.
Question two: which of your layers runs first?
This is the one that will actually bite you, because it changes behaviour silently during a refactor.
I put two tracing layers on a router, each appending its name to a request extension, and had the handler report the order they ran in. First with chained .layer() calls:
Router::new()
.route("/users/{id}", get(report_order))
.layer(middleware::from_fn_with_state("FIRST_WRITTEN", tracer))
.layer(middleware::from_fn_with_state("SECOND_WRITTEN", tracer));
$ curl -s http://127.0.0.1:3002/users/1
SECOND_WRITTEN > FIRST_WRITTEN
The layer written last runs first. Now the same two layers, same written order, inside a ServiceBuilder:
Router::new()
.route("/users/{id}", get(report_order))
.layer(
ServiceBuilder::new()
.layer(middleware::from_fn_with_state("SB_FIRST_WRITTEN", tracer))
.layer(middleware::from_fn_with_state("SB_SECOND_WRITTEN", tracer)),
);
$ curl -s http://127.0.0.1:3006/users/1
SB_FIRST_WRITTEN > SB_SECOND_WRITTEN
Same code shape, same written order, opposite execution order.
Both behaviours are documented. Router::layer wraps all previously added routes, so each new call becomes the new outermost layer. ServiceBuilder composes so that layers "run top to bottom", which the Axum middleware docs call easier to follow mentally and give as a reason ServiceBuilder is the recommended way to apply multiple middleware.
Put those two facts together and you get the trap. Axum recommends ServiceBuilder when you have several middleware. So the moment your stack grows past one layer and you follow the recommendation, your entire middleware order inverts, and nothing in your test suite necessarily notices.
Concretely: you wrote .layer(auth).layer(rate_limit), which ran the rate limiter first and auth second. You move it into a ServiceBuilder untouched. Now auth runs first and the rate limiter second. Your 429s and 401s swap priority for unauthenticated traffic, and if your limiter buckets by something auth populates, it is now reading a value that exists where it previously read nothing.
The rule worth memorising: chained .layer() calls read bottom-to-top, ServiceBuilder reads top-to-bottom. Pick one idiom for the whole app and never mix them in the same router. If you inherit a codebase that mixes both, the order is genuinely hard to reason about by reading, so measure it the way I did rather than arguing about it in review.
Question three: what is your rate limiter counting?
The usual pairing for Axum is tower_governor. Here is the config that appears in most examples:
let conf = Arc::new(
GovernorConfigBuilder::default()
.per_second(3600)
.burst_size(2)
.finish()
.unwrap(),
);
Router::new()
.route("/users/{id}", get(handler))
.layer(GovernorLayer::new(conf));
I sent four requests carrying two different API keys from one machine. Burst size 2:
req 1 x-api-key: key_AAA -> 200
req 2 x-api-key: key_BBB -> 200
req 3 x-api-key: key_AAA -> 429
req 4 x-api-key: key_BBB -> 429
Two paying customers, one bucket. The default key extractor in tower_governor is the peer IP address, so the two customers' first requests together drained a burst of 2, and both of their second requests were refused. Neither customer exceeded anything on their own. Nothing here is broken, either: it is behaving exactly as configured, and the configuration is the default.
This is the same default we have found in every framework limiter we have checked, and it is always the same story: the limiter ships keyed on a network identifier because it was designed to run before anything knows who is calling. We wrote up why rate limiting by API key beats rate limiting by IP, with the framework-by-framework defaults, if you want the longer argument.
The fix in Axum is a custom KeyExtractor that pulls your API key (or better, the consumer ID your auth resolved) out of the request instead of the socket. Which brings the ordering question back around: your key extractor can only see what auth already put there, so auth has to run first. Get the ServiceBuilder inversion wrong and your extractor reads an empty extension and silently falls back to grouping everyone together.
A small win: you can have the route template
One thing Axum does better than some frameworks. If you record usage per endpoint, you want /users/{id}, not /users/12345. Log the raw path and you get one time series per user ID, which is how observability bills get ugly.
Axum exposes the MatchedPath extension, and I checked whether middleware can actually see it:
let mp = req.extensions().get::<MatchedPath>().map(|m| m.as_str());
| Request | Attached via layer | Attached via route_layer |
|---|---|---|
GET /users/12345 | /users/{id} | /users/{id} |
GET /nope/12345 | absent | middleware does not run |
Available either way, which is not true everywhere. In Go's net/http, the equivalent field is empty in outer middleware because routing has not happened yet. Axum sidesteps this because middleware added with Router::layer runs after routing, which the docs mention as the reason you cannot use it to rewrite the request URI. A restriction in one direction is a feature in the other.
So: label your usage records with MatchedPath, not uri().path(). It costs you one line and saves your analytics from cardinality it can never recover from.
What twelve lines still cannot do
Everything above is about placement. None of it addresses the thing the tutorials skip hardest, which is that v == "secret" is not a key store.
Where do the keys live? Not in a const, and not in plaintext in Postgres. Store a SHA-256 hash with an indexed plaintext prefix so you can still look the key up. Not bcrypt: it salts every call, so you cannot index it, and it truncates at 72 bytes. We went through why bcrypt is the wrong tool for hashing API keys in detail.
How do you revoke one? A restart is not a revocation strategy. You need a status you can flip that takes effect on the next request, everywhere.
Who is calling? A boolean tells you the key was valid. It does not tell you which customer to bill, which quota to decrement, or whose dashboard to update. The moment you have more than one customer, the middleware's job is to resolve identity, not to say yes.
What happens when they run out? Rate limits answer "how fast". They do not answer "how much this month". Those are different axes and you eventually need both.
Each of those is a weekend. Together they are the two-day project that ships in six weeks. That is not a reason to buy something; plenty of teams should write it. It is a reason to know what you are signing up for before you start.
Handing it to a service instead
The alternative is to let the middleware ask something else. That is what ReqKey does: the validation endpoint takes the key and returns a decision plus the consumer's credit position, in one call:
curl -X POST "https://api.reqkey.com/key/validate" \
-H "Authorization: Bearer reqkey_xxx..." \
-H "Content-Type: application/json" \
-d '{"key": "prod_A1B2C3D4E5F6G7H8I9J0K1L2", "credits": 1, "resource": "/users"}'
{
"valid": true,
"requestId": "abc123xyz",
"creditsRemaining": 9995,
"creditsLimit": 10000,
"allowedApis": ["api_payment", "api_analytics"]
}
An unknown key comes back as 200 with {"valid": false, "message": "Key not found"}. An exhausted credit pool is a 402 Payment Required, and a disabled key or consumer is a 403. The full status table is in the error reference.
There is an official Rust SDK, published on crates.io as reqkey with an Axum feature that gives you a Tower layer, so it slots into everything above:
[dependencies]
reqkey = { version = "0.1", features = ["axum"] }
use reqkey::{
axum::ReqKeyLayer,
middleware::{KeyLocation, Middleware, MiddlewareConfig},
Client, VerificationResult,
};
let client = Client::from_env()?;
let config = MiddlewareConfig::builder("api_payments")
.key_location(KeyLocation::header("X-Company-Key"))
.exclude_path("/health")
.credit_cost_resolver(|request| Ok(if request.path == "/payments" { 2 } else { 1 }))
.build()?;
let protected = Router::new()
.route("/payments", post(create_payment))
.layer(ReqKeyLayer::new(Middleware::new(client, config)));
Your handler then receives the decision as an extension, so identity and remaining credits are available without a second lookup:
async fn create_payment(Extension(decision): Extension<VerificationResult>) -> Json<Value> {
Json(json!({ "credits_remaining": decision.credits_remaining }))
}
Note the .layer() on a protected sub-router rather than route_layer on the whole app. Given everything above, that is a choice, and now it is one you are making on purpose.
The honest cost. This is a network call in your request path, so you are trading local microseconds for a hop. That is a real budget item, and if your service is latency-critical on every route you should measure it before committing. The credit_cost_resolver above also shows the other thing to watch: cost is per-request configuration, so an endpoint you price at 2 credits burns quota twice as fast as you may have modelled. Worth reading the pricing page against your actual request volume rather than assuming.
If you want the wider Axum picture
This post is deliberately narrow. If you are new to Axum 0.8 and want the full tour before worrying about middleware placement, this course covers the framework end to end:
Key takeaways
route_layerturns your 404s into a route scanner, andlayerturns your 404s into 401s. Axum documentsroute_layeras the right choice for auth, and it is, but it means unauthenticated callers can tell real routes from fake ones. Decide which you want; do not inherit it from a tutorial.- Chained
.layer()calls run bottom-to-top;ServiceBuilderruns top-to-bottom. Since Axum recommendsServiceBuilderonce you have several middleware, following the recommendation inverts an existing stack. Pick one idiom per router and stick to it. - Your rate limiter is counting IPs until you tell it not to.
tower_governordefaults to the peer IP, so two customers behind one NAT share a bucket. Write aKeyExtractorthat reads the identity your auth layer resolved, and make sure auth runs first. - Record
MatchedPath, neveruri().path(). Axum gives you the route template inside middleware, which most stacks make you fight for. One line now prevents unbounded cardinality in your usage data later. - The twelve-line middleware is a placement problem and a storage problem, and only one of them is fun. Hashing, revocation, identity and quotas are the actual work. Budget for them or delegate them, but do not discover them in production.
If you would rather not build the storage half, ReqKey's Rust SDK gives you the Tower layer, per-consumer credits and usage analytics behind the same ReqKeyLayer you saw above. The free tier is 100,000 requests a month, which is more than enough to run the probes in this post against a real key and see what your own middleware order is doing. The Rust SDK docs have the Actix Web, Rocket and Warp equivalents if Axum is not your stack.



