https://mattrighetti.com/2025/05/03/authentication-with-axum
About Projects Resume Series
Authentication with Axum
May 3, 2025
Discuss on HN
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.
{% block title %}{% endblock %}
{% block head %}{% endblock %}
{% block content %}
{% endblock %}
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,
jar: CookieJar, // CookieJar is available in axum_extras
Form(LoginData { username, password }): Form
) -> 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 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(pub T);
// since axum 0.8 you can implement extractors meant to be Option
// this is very useful, expecially for scenarios where endpoint can be accessed
// both by authed users and non-authed users
impl OptionalFromRequestParts for CookieJwt
where
AppState: FromRef,
S: Send + Sync,
T: DeserializeOwned,
{
type Rejection = Redirect;
async fn from_request_parts(
req: &mut Parts,
state: &S,
) -> Result