Authentication with Axum
Consider this scenario: you’re building a website that has a classic navbar at the top, this navbar has a button that reflects the user authentication status, showing a "Profile" button if the user is authenticated and showing a "Login" button in case the user is unauthenticated.
This is a very common scenario, let’s sketch something quick using axum and askama.
<!DOCTYPE html>
<html lang="en">
<head>
<title>{% block title %}{% endblock %}</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{% block head %}{% endblock %}
</head>
<body>
<nav>
<div>
<div>
{% if ctx.authed %}
<a href="/profile">Profile</a>
{% else %}
<a href="/login">Login</a>
{% endif %}
</div>
</div>
</nav>
{% block content %}
{% endblock %}
</body>
</html>
This will be our layout.jinja
file that we can build upon. The template above
would be served by an endpoint that looks like the following
#[derive(Debug, Default)]
pub struct Context {
authed: bool,
}
#[derive(Template)]
#[templage = "layout.jinja"]
struct HomeTemplate {
ctx: Context
};
pub async fn home() -> impl IntoResponse {
HtmlTemplate(
HomeTemplate { ctx: Context::default() }
).into_response()
}
We have something to work with, all is missing is a way derive a
Context
from a user’s HTTP request.
I’d argue the simplest way to handle user authentication if you’re doing SSR is using cookies. Cookies are a cornerstone of backend authentication because they’re reliable, browser-managed, and can be hardened with specific attributes to mitigate common security risks. Here’s why:
-
HttpOnly
Attribute: Prevents client-side JavaScript from accessing the cookie, neutralizing XSS attacks. If an attacker injects malicious scripts, they can’t steal your session cookie. -
Secure
Attribute: Ensures the cookie is only sent over HTTPS, protecting it from interception on insecure networks (e.g., public Wi-Fi). -
SameSite
Attribute: Mitigates CSRF (Cross-Site Request Forgery) by controlling when cookies are sent in cross-origin requests. SameSite=Strict blocks cookies in requests from external sites, while SameSite=Lax allows safe methods like GET. -
Expiration and Domain/Path Scoping: Cookies can be set to expire after a session or a fixed time, reducing the window for misuse. Scoping to specific domains and paths (e.g.,
domain=api.example.com
,path=/auth
) limits their exposure. -
Signed Cookies: Frameworks often support signing cookies with a secret key, ensuring they haven’t been tampered with on the client side.
This is a typical reponse that uses the Set-Cookies
header to instruct the
browser to set those cookies
HTTP/1.1 200 OK
Content-Type: application/json
Set-Cookie: session=xyz123; HttpOnly; Secure; SameSite=Strict; Max-Age=86400; Path=/; Domain=api.example.com
When configured correctly, cookies are a fortress for storing session IDs,
JWTs, or other authentication tokens in SSR apps. They’re automatically
sent by the browser with every request, simplifying server-side validation
compared to managing tokens in localStorage
or HTTP headers.
Axum provides a cool axum-extra
crate that makes it easy to
work with them. That crate contains a very useful extractor called CookieJar
that exposes a very minimal interface to .add
and .remove
cookies for a
user. This is the utility function I use to generate a default cookie
pub(crate) fn default_cookie<'a>(
key: &str,
token: String,
duration_hrs: i64
) -> Cookie<'a> {
Cookie::build((key.to_string(), token))
.path("/")
.http_only(true)
.max_age(Duration::hours(duration_hrs))
.secure(if cfg!(debug_assertions) {
// Safari won't allow secure cookies
// coming from localhost in debug mode
false
} else {
// Secure cookies in release mode
true
})
.build()
}
I won’t get sucked into the session ID vs. JWT argument, but honestly, using JWTs in cookies is a win because you don’t have to fuss with storing session data on the server.
Jwt are usually very short-lived, they shouldn’t last for long periods of time and they must be renewed frequently for security purposes. For that reason you usually issue two different cookies:
-
jwt: short-lived token containing information about a user in json format, signed with a secret key so you know you were the one who issued it
-
refresh token: a longer-lived token with which you can request new jwts
Now that we’ve covered the cookies and jwt basics, let’s start by implementing a standard login endpoint with which users can be given these two cookies.
#[derive(Debug)]
struct LoginData {
username: String,
password: String
}
pub async fn login(
State(app): State<AppState>,
jar: CookieJar, // CookieJar is available in axum_extras
Form(LoginData { username, password }): Form<LoginData>
) -> impl IntoResponse {
// dummy function to get a user
let user = match db::user::get(&app.pg_pool, &username, &password).await {
None => return Redirect::to("/signup").into_response()
Some(user) => user
};
// get/create a refresh token for the user
let refresh_token = match db::refresh_tokens::create(user.id).await {
Ok(token) => token,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Somethign bad happened, try again later"
).into_response();
}
};
let claims = Claims::with(user.email, user.id);
match jwt::generate_jwt(app.jwt_signing_key.as_bytes(), claims) {
Ok(token) => (
[("hx-redirect", "/")],
jar.add(default_cookie("jwt", token, 1)).add(default_cookie(
"refresh",
refresh_token,
30 * 24,
),
)
.into_response()),
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Somethign bad happened, try again later"
).into_response();
}
}
}
This login endpoint will receive a request with some form data that contain a
username
and a password
. First thing you usually have to do is check if the
user exists in your database, otherwise you’ll kindly 302
to a signup page
where he/she has to register, returning a message to show in the login form
sometimes works as well - whatever suits you.
Once you know the user exists you need to create a refresh token. It usually
makes sense to implement refresh_token::create
so that it returns a valid
non-expired refresh token stored in your database associated with the user
before creating a new one. This is because users can delete cookies and/or
users can authenticate with different devices and you don’t want to create a
refresh token each time.
When you get your refresh token back you’re ready to move on and handle the last part of the process, which is generating the jwt and returning a valid response to the user that will set those cookies.
Ignore `hx-redirect` header for now, this was a snippet of code that I had laying around on github. Also, note that the responses I return in case of errors are not very exhaustive for most scenarios, I'm conciously leaving out the details because it's not the focus of this blog post.
If login
is successful the user will be redirected to the homepage at /
and
will trigger the home
endpoint again but his navbar will still show the login
button because we’re using Context::default()
. Let’s change that with our
first approach using Axum extractors.
When I first started using Axum I really liked the idea of Extractors
, if
you’ve used the framework you’re probably familiar with them (i.e Json
,
Form
etc.). Everything that implements FromRequest
or
FromRequestParts
(and the Option
alternative since Axum 0.8!) can be
considered an extractor and can be used in the function signature to get
something out of a request.
In our case, we would like to get some user data out of a request (cookies are
always sent with an HTTP request), in particular we can create a custom
extractor that tries to extract our user data from the jwt token in the user’s
request, if present. Let’s implement CookieJwt<T>
which we’re going to use to
get that information out of requests that reaches our endpoints.
/// Basic claims that a classic jwt contain
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub exp: usize,
pub user_id: uuid::Uuid,
}
/// A flexible extractor that tries
/// to get a type `T` from a request cookie
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct CookieJwt<T: DeserializeOwned>(pub T);
// since axum 0.8 you can implement extractors meant to be Option<T>
// this is very useful, expecially for scenarios where endpoint can be accessed
// both by authed users and non-authed users
impl<S, T> OptionalFromRequestParts<S> for CookieJwt<T>
where
AppState: FromRef<S>,
S: Send + Sync,
T: DeserializeOwned,
{
type Rejection = Redirect;
async fn from_request_parts(
req: &mut Parts,
state: &S,
) -> Result<Option<Self>, Self::Rejection> {
let jar = CookieJar::from_headers(&req.headers);
if let Some(jwt) = jar.get("jwt").map(|c| c.value()) {
return match validate_jwt::<T>(JWT_SIGNING_KEY, jwt) {
Ok(data) => return Ok(Some(CookieJwt(data))),
// user tampered with cookie here, we want to delete that cookie
// returning None here would have been okay too if you're okay with
// manufactured cookies :)
Err(_) => Err(Redirect::to("/logout")),
};
}
// if refresh token is present, try and get a new jwt
// by redirecting user to /refresh_token endpoint
if jar.get("refresh").is_some() {
return Err(Redirect::to(
format!("/refresh_token?next={}", req.uri).as_str(),
));
}
// at this point, user has no jwt and no refresh token
Ok(None)
}
}
And we can use our brand new extractor in our home
function
pub async fn home(jwt: Option<CookieJwt<Claims>>) -> impl IntoResponse {
HtmlTemplate(
HomeTemplate { ctx: Context { authed: jwt.is_some() } }
).into_response()
}
Now each time try and navigate to /
the extractor is going to peek into your
request and look for jwt
and refresh
cookies, if jwt
is found and can be
decoded to Claims
then jwt: Option<_>
is going to contain Some(jwt)
data
and our user will see the "Profile" button in his navbar, indicating he’s
correctly logged in. If neither of the cookies is found then the user will be
returned the classic navbar with the option to "Login". If however jwt
can’t
be found but a refresh
cookie is present we can still do something for the user
and get him a proper jwt
.
Indeed, the logic implemented above will redirect the user to /refresh_token
along with a query parameter indicating where the user was previously
navigating to. This way we’re not distrupting the original user’s intent and
everything is going to happen in a quick succession of requests. How does our
/refresh_token
endpoint look like?
#[derive(Debug, Deserialize)]
pub struct RefreshTokenQuery {
next: Option<String>,
}
pub async fn refresh_token(
State(app): State<AppState>,
jar: CookieJar,
Query(RefreshTokenQuery { next }): Query<RefreshTokenQuery>,
) -> impl IntoResponse {
let token = match jar.get("refresh") {
Some(token) => token,
None => {
// if there's no token then the user goes back to /login
return Redirect::to("/login").into_response();
}
};
// if something goes wrong here we remove the token, otherwise the user could end up
// in a loop where he's constantly being redirected here and this function fails every time
let user = match db::refresh_tokens::get_user(&app.pg_pool, token.value()).await {
Ok(Some(user)) => user,
_ => {
return (jar.remove(Cookie::from("refresh")), Redirect::to("/login")).into_response();
}
};
// set new jwt
let claims = Claims::with(user.email, user.id);
match jwt::generate_jwt(app.jwt_signing_key.as_bytes(), claims) {
Ok(token) => (
jar.add(default_cookie("jwt", token, 1)),
Redirect::to(&next.unwrap_or("/".to_owned())),
)
.into_response(),
Err(_) => {
(jar.remove(Cookie::from("refresh")), Redirect::to("/login")).into_response()
}
}
}
Nothing surprising here, our refresh token endpoint is going to check that the
refresh
cookie is part of the request and if it is it will try to ask the db to
return the user information associated to that refresh token. Once the
information is retrieved it re-generates the valid jwt
and redirect the user
to his previous url next
, if present.
Even though it is a great starting point and has worked very well for me, it’s not flawless:
-
It doesn’t feel right: handling authentication logic in an extractor doesn’t feel quite right.
-
It’s not flexible for more complex authentication scenarios i.e restricting some endpoints to users with a specific role.
-
If a user sends a POST request that has a body attached to it the
/refresh_token
redirect will break that flow because almost every browser won’t expect a302
redirect to have a body.
If you’re a backend developer, authentication screams middleware. To level up the cookie-based authentication we’ve discussed, authentication middleware offers a cleaner, reusable way to validate cookies and secure routes.
Axum gives you quite a few options when you want to implement a middleware. You don’t have to give up on the granularity that extractor provided because axum middleware can be applied at the app level down to the individual route level.
Axum allows you to add middleware just about anywhere
The easiest way to implement an Axum middleware is to create a function that
matches the axum::middleware::from_fn
(or
axum::middleware::from_fn_with_sate
if you need
State
) function. The requirements are pretty straightfoward:
Be an async
fn
.Take zero or more
FromRequestParts
extractors.Take exactly one
FromRequest
extractor as the second to last argument.Take
Next
as the last argument.Return something that implements
IntoResponse
.
With that in mind, let’s try and create our authentication middleware.
/// Middleware that handles both authenticated and unauthenticated requests.
///
/// This middleware performs JWT-based authentication by checking for `jwt` and `refresh` cookies.
/// It establishes a [`UserContext`] that flows through the request chain and manages cookie updates.
///
/// # Behavior
/// - **JWT Present**: Validates the JWT and extracts user claims if successful.
/// - Invalid JWT: Clears auth cookies (potential tampering)
/// - **No JWT but Refresh Token Present**:
/// - Attempts to refresh the token and issue a new JWT
/// - On success: Sets new cookies and establishes authenticated context
/// - **No Auth Cookies**: Proceeds with default unauthenticated context
///
/// # Cookie Management
/// - Automatically removes suspicious/invalid auth cookies
/// - Adds new JWT cookies when refresh is successful
/// - Propagates all cookie changes in the response
pub async fn base(
State(app): State<AppState>,
mut request: Request,
next: Next,
) -> impl IntoResponse {
let mut jar = CookieJar::from_headers(request.headers());
let jwt = jar.get("jwt");
let refresh = jar.get("refresh");
// Default context for unauthenticated requests
let mut context = UserContext {
user_id: None,
is_admin: false,
};
// JWT takes precedence if present
if let Some(jwt) = jwt {
match validate_jwt::<Claims>(JWT_SIGNING_KEY, jwt.value()) {
Ok(claims) => {
context.user_id = Some(claims.user_id);
}
Err(_) => {
// Clear potentially compromised cookies
jar = jar.remove("jwt").remove("refresh");
}
}
}
// Fall back to refresh token if JWT is absent/invalid
else if let Some(refresh) = refresh {
if let Ok(Some(user)) = db::refresh_tokens::get_user(&app.pg_pool, refresh.value()).await {
let claims = Claims::with(user.email, user.uuid);
if let Ok(jwt) = generate_jwt(app.jwt_signing_key.as_bytes(), claims) {
context.user_id = Some(user.uuid);
jar = jar.add(default_cookie("jwt", jwt, 1));
}
// Note: JWT generation errors are intentionally swallowed here
// to prevent refresh token from being invalidated due to
// temporary JWT generation issues
}
}
// Inject the resolved context into request extensions
request.extensions_mut().insert(context);
let response = next.run(request).await;
// Merge cookie updates with the response
(jar, response).into_response()
}
The middleware we’ve built takes the same core idea as our initial extractor but makes it far more powerful. Unlike a simple extractor, middleware can intercept and modify responses and modify it as needed. This enables us to do much more interesting things.
First of all, the middleware initializes the UserContext
with some default
values that an un-authenticated users will reflect. After that, it goes through
the first authentication step which tries to look for a valid jwt
token in
the request’s cookies, if it finds one it updates the UserContext
accordingly
with the data decoded from the jwt
. If a jwt
token is not found, it falls
back to the refresh
token and uses it to generate a valid jwt
for the user
and updates the UserContext
accordingly. The authentication part of
middleware is now complete and the UserContext
is passed along with the
request so that handlers can make use of it. But we’re not done yet! The
middleware will wait for the response
returned by whatever we have running in
next.run(_)
and does something pretty cool: in case the request wasn’t
originally authenticated (did not have a jwt
attached) it sends back with the
response new a new cookie containing the generated jwt
.
This last part is very cool for different reasons:
-
Silent Authentication: Requests that come in un-authenticated will be treated as authenticated (if
refresh
is present!) because we do the heavy lifting of generating thejwt
in the middleware. -
Works with all request types: whatever the request was (POST, PUT, GET etc.), the middleware won’t distrupt the flow and the user will get back a fresh
jwt
if he’s missing one. -
Simplified Architecture: with this middleware we don’t need extra round trips to authenticate the user, therefore we can also get rid of the
/refresh_token
endpoint.
Our new home
endpoint now would end up looking like this
pub async fn home(Extension(usr_ctx): Extension<UserContext>) -> impl IntoResponse {
HtmlTemplate(
HomeTemplate { ctx: Context { authed: usr_ctx.user_id.is_some() } }
).into_response()
}
Nothing is stopping us from generating a Context
directly in the middleware,
that would actually be a better approach here so that we can pop it in
directly in the template
pub async fn home(Extension(ctx): Extension<Context>) -> impl IntoResponse {
HtmlTemplate(HomeTemplate { ctx }).into_response()
}
We’re not done yet - one last cool thing that you can do with middlewares is stack them on top of each other and have multiple layers of logic to protect different parts of your backend, just like an onion.
Let’s consider the scenario where you want some parts of your application to be public, some others to be for authenticated only users and then you have a very special dashboard that only super admins can access. You can leverage middlewares to implement all of those protection layers
/// middleware that requires the user to be authenticated
pub async fn required_auth(
Extension(context): Extension<UserContext>,
request: Request,
next: Next
) -> impl IntoResponse {
if context.user_id.is_none() {
return Redirect::to("/login").into_response();
}
next.run(request).await
}
/// middleware that requires the user to be authenticated
pub async fn required_admin(
Extension(context): Extension<UserContext>,
request: Request,
next: Next,
) -> impl IntoResponse {
if !context.is_admin {
return Redirect::to("/").into_response();
}
next.run(request).await
}
If you remember correctly, middlewares can have zero or more FromRequestParts
in its signature, which means you can use as many extractors as you want to and
Extension
is an extractor too! This means you can re-use something that the
previous middleware computed in the following middlewares. The only thing you
have to pay attention to in this case is to apply the middlewares in the
correct order. This is a pretty good look of how middlewares work in Axum
+-----------------------+
| Requests |
+-----------------------+
|
v
+-----------------------+
| Layer Three |
| +-----------------+ |
| | Layer Two | |
| | +-----------+ | |
| | | Layer One | | |
| | | +---+ | | |
| | | | H | | | |
| | | | a | | | |
| | | | n | | | |
| | | | d | | | |
| | | | l | | | |
| | | | e | | | |
| | | | r | | | |
| | | +---+ | | |
| | +-----------+ | |
| +-----------------+ |
+-----------------------+
|
v
+-----------------------+
| Responses |
+-----------------------+
It really is like an onion after all
let app = Router::new()
.route("/admin", get(admin::get))
// only admins can access the routes above
.layer(
middleware::from_fn_with_state(state.clone(), required_admin)
)
.route("/profile", get(profile::get))
// routes above will need to be authenticated
.layer(
middleware::from_fn_with_state(state.clone(), required_auth)
)
.route("/", get(home))
// most external layer, will provide
// `Extension<UserContext>` to all the routes above
.layer(
middleware::from_fn_with_state(state.clone(), base)
);
For better readability you can also create different routers with different layers and finally merge them together into the main app’s router.
let admin = Router::new()
.route("/admin", get(handler))
.layer(
middleware::from_fn_with_state(state.clone(), required_admin)
);
let protected = Router::new()
.route("/profile")
.layer(
middleware::from_fn_with_state(state.clone(), required_auth)
);
let public = Router::new()
.route("/");
let app = Router::new()
.merge(public)
.merge(protected)
.merge(admin);
.layer(
middleware::from_fn_with_state(state.clone(), base)
)
I’ve been playing with middlewares for a while now in Axum and I feel they
provide a much better option for this scenario than creative alternatives like
the one I’ve talked about initially. Axum provides much more powerful features
for middlewares if you need it, but I still haven’t delved into those that much
because there was no need for me to do it, I almost always can get stuff done
with the simple middleware::from_fn_with_sate
function. You should give them
a try!