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.
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
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
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
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.
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.
And we can use our brand new extractor in our home function
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?
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
-
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.
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 the jwt 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
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
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
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
It really is like an onion after all
For better readability you can also create different routers with different layers and finally merge them together into the main app’s router.
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!