← Tous les articles • 14/09/2026 • 5 min de lecture

L'auth JWT dans Axum comme il faut : login, refresh, middleware

Ajoute l'authentification JWT à une API Axum : hash de mots de passe avec argon2, tokens d'accès et de refresh, middleware d'auth et logout.

#axum#auth#backend

La plupart des exemples JWT avec Axum s’arrêtent au retour d’un token signé. Celui-ci couvre le cycle complet : hash argon2 des mots de passe, JWT d’accès de 15 minutes, refresh tokens de 7 jours hashés en Postgres, rotation, middleware d’auth et extracteur AuthUser.

Le modèle de tokens : accès stateless, refresh stocké

Le flow de login utilise deux types de tokens. Les tokens d’accès sont des JWT signés qui expirent en 15 minutes. Ils ne sont pas stockés en Postgres, parce que les stocker casserait l’intérêt d’un token stateless et forcerait un aller-retour BDD à chaque requête.

Les refresh tokens ne sont pas des JWT. Ce sont 64 octets aléatoires, encodés en hex, hashés en SHA-256 et stockés dans une table refresh_tokens. Le client reçoit la valeur en clair dans un cookie httpOnly. Le serveur ne voit plus que le hash après le login, donc un snapshot Postgres qui fuite ne donne pas de refresh tokens utilisables à un attaquant.

TokenÉtatDurée de vieStockageRévocation
JWT d’accèsStateless15 minutesMémoire client ou cookie sécuriséExpire seul, non stocké
Refresh tokenHash stocké7 joursrefresh_tokens.token_hashFlag revoked ou suppression de ligne

Ne stocke pas les JWT d’accès en Postgres. Garde-les courts, et fais le coûteux travail de révocation sur le refresh token.

Le piège du localStorage pour les SPA

Si tu construis une SPA navigateur, le pire endroit pour un JWT c’est localStorage. N’importe quel script injecté lit localStorage.token aussitôt. Un refresh token dans localStorage, c’est pire : il vit longtemps et laisse souvent l’attaquant se réauthentifier en silence.

Le découpage classique en prod :

Le cookie httpOnly protège le refresh token contre l’exfiltration XSS. En contrepartie le CSRF devient pertinent, donc mets SameSite=Strict ou Lax, restreins le path du cookie, et fais lire le cookie (pas un corps JSON) à l’endpoint de refresh.

Si tu as déjà le squelette d’API Axum + Postgres, l’organisation du guide API REST Axum Postgres sert de base. Pour les dépendances non auth, garde la liste courte. Les choix actuels sont couverts dans les meilleures crates Rust pour une API.

Dépendances et schéma Postgres

Garde la stack d’auth petite : Axum, SQLx, jsonwebtoken, argon2, axum-extra pour les cookies, sha2 et hex pour hasher les refresh tokens.

[dependencies]
axum = "0.7"
axum-extra = { version = "0.9", features = ["cookie"] }
jsonwebtoken = "9"
argon2 = "0.5"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
sha2 = "0.10"
hex = "0.4"
rand = "0.8"
thiserror = "1"
anyhow = "1"

Schéma pour les utilisateurs et les refresh tokens :

CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE users (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  email text UNIQUE NOT NULL,
  password_hash text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE refresh_tokens (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash text NOT NULL UNIQUE,
  expires_at timestamptz NOT NULL,
  revoked boolean NOT NULL DEFAULT false,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);

Le hash du refresh token est en texte parce qu’on hashe le refresh token hex en SHA-256 et on stocke le digest hex.

State applicatif, claims et helpers de tokens

Le state applicatif garde le pool Postgres et le secret JWT. Mets le secret derrière un Arc<String> pour que le middleware clone le state sans coût.

#[derive(Clone)]
pub struct AppState {
    pub db: PgPool,
    pub jwt_secret: Arc<String>,
}

const ACCESS_TTL_SECONDS: u64 = 15 * 60;
const REFRESH_TTL_DAYS: i64 = 7;

#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    sub: String,
    email: String,
    token_type: String,
    exp: u64,
    iat: u64,
}

Signature du token d’accès :

fn sign_access_token(user_id: &Uuid, email: &str, state: &AppState) -> Result<String, AuthError> {
    let now = Utc::now().timestamp() as u64;

    let claims = Claims {
        sub: user_id.to_string(),
        email: email.to_owned(),
        token_type: "access".to_owned(),
        exp: now + ACCESS_TTL_SECONDS,
        iat: now,
    };

    Ok(jsonwebtoken::encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(state.jwt_secret.as_bytes()),
    )?)
}

La vérification du token d’accès contrôle l’expiration et le type :

fn decode_access_token(token: &str, state: &AppState) -> Result<Claims, AuthError> {
    let data = jsonwebtoken::decode::<Claims>(
        token,
        &DecodingKey::from_secret(state.jwt_secret.as_bytes()),
        &Validation::new(Algorithm::HS256),
    )?;

    if data.claims.token_type != "access" {
        return Err(AuthError::InvalidToken);
    }

    Ok(data.claims)
}

Les refresh tokens sont aléatoires et hashés avant de toucher la base :

fn generate_refresh_token() -> String {
    let mut bytes = [0u8; 64];
    rand::rngs::OsRng.fill_bytes(&mut bytes);
    hex::encode(bytes)
}

fn hash_refresh_token(token: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(token.as_bytes());
    hex::encode(hasher.finalize())
}

Login : argon2 et émission

Le login accepte email et mot de passe, vérifie le mot de passe avec argon2, puis émet un JWT d’accès court et un cookie de refresh httpOnly.

#[derive(Deserialize)]
pub struct LoginRequest {
    email: String,
    password: String,
}

#[derive(Serialize)]
pub struct AuthTokens {
    access_token: String,
    token_type: &'static str,
    expires_in: u64,
}

#[derive(sqlx::FromRow)]
struct UserRow {
    id: Uuid,
    email: String,
    password_hash: String,
}

async fn login(
    State(state): State<AppState>,
    mut jar: CookieJar,
    Json(payload): Json<LoginRequest>,
) -> Result<(CookieJar, Json<AuthTokens>), AuthError> {
    let email = payload.email.trim().to_lowercase();

    let user = sqlx::query_as::<_, UserRow>(
        "SELECT id, email, password_hash FROM users WHERE email = $1",
    )
    .bind(&email)
    .fetch_optional(&state.db)
    .await?
    .ok_or(AuthError::InvalidCredentials)?;

    let parsed_hash = PasswordHash::new(&user.password_hash)
        .map_err(|_| AuthError::InvalidCredentials)?;

    Argon2::default()
        .verify_password(payload.password.as_bytes(), &parsed_hash)
        .map_err(|_| AuthError::InvalidCredentials)?;

    let access_token = sign_access_token(&user.id, &user.email, &state)?;
    let refresh_token_plain = generate_refresh_token();
    let refresh_token_hash = hash_refresh_token(&refresh_token_plain);

    sqlx::query(
        "INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
         VALUES ($1, $2, $3)",
    )
    .bind(user.id)
    .bind(&refresh_token_hash)
    .bind(Utc::now() + chrono::Duration::days(REFRESH_TTL_DAYS))
    .execute(&state.db)
    .await?;

    let mut refresh_cookie = Cookie::new("refresh_token", refresh_token_plain);
    refresh_cookie.set_path("/");
    refresh_cookie.set_http_only(true);
    refresh_cookie.set_secure(!cfg!(debug_assertions));
    refresh_cookie.set_same_site(SameSite::Strict);
    refresh_cookie.set_max_age(Duration::days(REFRESH_TTL_DAYS as u64));

    jar.add(refresh_cookie);

    Ok((
        jar,
        Json(AuthTokens {
            access_token,
            token_type: "Bearer",
            expires_in: ACCESS_TTL_SECONDS,
        }),
    ))
}

Le détail important : le refresh token en clair n’est jamais loggué ni renvoyé en JSON. La base ne stocke que son hash SHA-256.

Rotation du refresh dans une transaction

Le refresh doit faire plus que décoder un token. Il hashe la valeur du cookie, verrouille la ligne correspondante, charge l’utilisateur, révoque l’ancien token et en insère un nouveau. Un refresh token volé devient à usage unique.

#[derive(sqlx::FromRow)]
struct RefreshTokenRow {
    user_id: Uuid,
    expires_at: chrono::DateTime<chrono::Utc>,
}

async fn refresh(
    State(state): State<AppState>,
    mut jar: CookieJar,
) -> Result<(CookieJar, Json<AuthTokens>), AuthError> {
    let refresh_token_plain = jar
        .get("refresh_token")
        .ok_or(AuthError::InvalidRefreshToken)?
        .value()
        .to_owned();

    let token_hash = hash_refresh_token(&refresh_token_plain);
    let mut tx = state.db.begin().await?;

    let row = sqlx::query_as::<_, RefreshTokenRow>(
        r#"
        SELECT user_id, expires_at
        FROM refresh_tokens
        WHERE token_hash = $1
          AND revoked = false
          AND expires_at > now()
        FOR UPDATE
        "#,
    )
    .bind(&token_hash)
    .fetch_optional(&mut *tx)
    .await?
    .ok_or(AuthError::InvalidRefreshToken)?;

    sqlx::query("UPDATE refresh_tokens SET revoked = true WHERE token_hash = $1")
        .bind(&token_hash)
        .execute(&mut *tx)
        .await?;

    let user = sqlx::query_as::<_, UserRow>("SELECT id, email FROM users WHERE id = $1")
        .bind(row.user_id)
        .fetch_one(&mut *tx)
        .await?;

    let access_token = sign_access_token(&user.id, &user.email, &state)?;
    let new_refresh_token_plain = generate_refresh_token();
    let new_refresh_token_hash = hash_refresh_token(&new_refresh_token_plain);

    sqlx::query(
        "INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
         VALUES ($1, $2, $3)",
    )
    .bind(user.id)
    .bind(&new_refresh_token_hash)
    .bind(Utc::now() + chrono::Duration::days(REFRESH_TTL_DAYS))
    .execute(&mut *tx)
    .await?;

    tx.commit().await?;

    let mut refresh_cookie = Cookie::new("refresh_token", new_refresh_token_plain);
    refresh_cookie.set_path("/");
    refresh_cookie.set_http_only(true);
    refresh_cookie.set_secure(!cfg!(debug_assertions));
    refresh_cookie.set_same_site(SameSite::Strict);
    refresh_cookie.set_max_age(Duration::days(REFRESH_TTL_DAYS as u64));

    jar.add(refresh_cookie);

    Ok((
        jar,
        Json(AuthTokens {
            access_token,
            token_type: "Bearer",
            expires_in: ACCESS_TTL_SECONDS,
        }),
    ))
}

Le verrou FOR UPDATE est volontaire. Si deux requêtes se battent pour le même refresh token, une seule verrouille et fait la rotation. La seconde attend, voit revoked = true, et échoue.

Middleware d’auth et extracteur AuthUser

Dans Axum, un middleware au niveau des routes est le bon endroit pour valider le token Bearer et insérer l’utilisateur authentifié dans les extensions de la requête. Les handlers utilisent ensuite un extracteur typé.

#[derive(Clone, Debug)]
pub struct AuthUser {
    pub id: Uuid,
    pub email: String,
}

async fn auth_middleware(
    State(state): State<AppState>,
    mut req: Request,
    next: Next,
) -> Result<Response, AuthError> {
    let token = extract_bearer_token(req.headers()).ok_or(AuthError::MissingToken)?;
    let claims = decode_access_token(token, &state)?;

    let user_id = Uuid::parse_str(&claims.sub).map_err(|_| AuthError::InvalidToken)?;

    req.extensions_mut().insert(AuthUser {
        id: user_id,
        email: claims.email,
    });

    Ok(next.run(req).await)
}

impl<S> FromRequestParts<S> for AuthUser
where
    S: Send + Sync,
{
    type Rejection = AuthError;

    async fn from_request_parts(
        parts: &mut Parts,
        _state: &S,
    ) -> Result<Self, Self::Rejection> {
        parts
            .extensions
            .get::<AuthUser>()
            .cloned()
            .ok_or(AuthError::NotAuthenticated)
    }
}

Les handlers restent propres :

async fn me(AuthUser { id, email }: AuthUser) -> Json<serde_json::Value> {
    Json(json!({
        "id": id.to_string(),
        "email": email
    }))
}

L’extracteur ne redécode pas le token. Il lit seulement la valeur insérée par le middleware, donc l’authentification a lieu une fois par requête.

Logout

Le logout suit le même flow de hash et suppression. Il supprime la ligne du refresh token en Postgres et retire le cookie.

async fn logout(
    State(state): State<AppState>,
    mut jar: CookieJar,
) -> Result<(CookieJar, StatusCode), AuthError> {
    if let Some(cookie) = jar.get("refresh_token") {
        let token_hash = hash_refresh_token(cookie.value());

        sqlx::query("DELETE FROM refresh_tokens WHERE token_hash = $1")
            .bind(&token_hash)
            .execute(&state.db)
            .await?;
    }

    jar.remove(Cookie::from("refresh_token"));

    Ok((jar, StatusCode::NO_CONTENT))
}

Le JWT d’accès reste valide jusqu’à ses 15 minutes d’expiration. Pour la plupart des API, c’est un tradeoff acceptable. Si tu as besoin d’une révocation immédiate des tokens d’accès, ajoute une petite denylist ou mets le token d’accès en base, mais ne construis ça que si un vrai besoin produit l’exige.

Câblage du router

let protected_routes = Router::new()
    .route("/me", get(me))
    .route_layer(axum::middleware::from_fn_with_state(
        state.clone(),
        auth_middleware,
    ));

let app = Router::new()
    .route("/login", post(login))
    .route("/refresh", post(refresh))
    .route("/logout", post(logout))
    .merge(protected_routes)
    .with_state(state);

Si ce découpage en modules te semble à l’étroit, la même organisation scale quand tu déplaces handlers, middleware et code de tokens dans des modules séparés. Voir comment structurer un projet web Rust pour les frontières de fichiers.

Checklist prod

Avant de shipper :

Une implémentation JWT avec Axum ne vaut que par sa gestion des refresh tokens. Le JWT d’accès est facile. La rotation du refresh, son stockage hashé et la frontière du cookie httpOnly font qu’un flow de login survit à un vrai usage abusif.

Tu veux le starter complet ? Récupère mon template Axum gratuit (structure de projet + Dockerfile + CI + migrations) dans la newsletter.


Ça t'a plu ? Récupère la checklist prod.

Template Axum + patterns Postgres + checklist p99. Gratuit, un email par semaine.

Je la veux →

🚀 Gratuit : la checklist Rust Backend Prod

Rejoins les devs backend. Reçois mon template Axum, ma checklist latence p99 et mes questions d'entretien. Un email pratique par semaine.

Zéro spam. Désinscription en un clic. Ce que tu vas recevoir →