Authentication with Axum

1 day ago 4

May 3, 2025

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 a 302 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

  1. Be an async fn.

  2. Take zero or more FromRequestParts extractors.

  3. Take exactly one FromRequest extractor as the second to last argument.

  4. Take Next as the last argument.

  5. 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:

  1. 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 the jwt in the middleware.

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

  3. 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!

Read Entire Article