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 — une vérification BDD par requête est exactement le genre d’aller-retour qui gonfle la p99.

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 :

  • Token d’accès : renvoyé dans le corps JSON, gardé en mémoire JavaScript. La SPA l’envoie avec Authorization: Bearer ....
  • Refresh token : posé en cookie httpOnly scopé aux routes d’auth. JavaScript ne peut pas le lire.

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.

Tout le code de cet article vient du template Axum, qui compile et tourne en CI. Les versions comptent ici : axum-extra 0.10 va avec Axum 0.8, rand 0.9 a remplacé OsRng.fill_bytes par rand::rng(), et SQLx 0.9 a scindé runtime-tokio-rustls en runtime-tokio plus un backend TLS.

[dependencies]
axum = "0.8"
axum-extra = { version = "0.10", features = ["cookie"] }
jsonwebtoken = "9"
argon2 = "0.5"
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "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.9"
thiserror = "2"
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::rng().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  // Axum 0.8 : plus besoin de #[async_trait]
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 :

  • Les hashs de mots de passe utilisent argon2 avec un vrai coût mémoire.
  • Les tokens d’accès expirent en 15 minutes max.
  • Les refresh tokens sont aléatoires, hashés en Postgres, avec rotation au refresh.
  • Les refresh tokens utilisent des cookies httpOnly, Secure et SameSite.
  • La SPA n’écrit jamais de tokens dans localStorage.
  • Le logout révoque ou supprime le refresh token.
  • Rate limite /login et /refresh.
  • Ajoute des index sur refresh_tokens.token_hash et users.email.
  • Utilise TLS pour PostgreSQL en production.

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.