Better Auth in Rust

Axum

Integrate Better Auth with the Axum web framework.

Better Auth provides first-class integration with Axum via the axum feature flag, including automatic route mounting, session extractors, and type-safe access to the current user.

Setup

Cargo.toml
[dependencies]
better-auth = { version = "0.9", features = ["axum"] }
axum = "0.8"
tokio = { version = "1", features = ["full"] }

Mounting Auth Routes

Use .axum_router() when Arc<BetterAuth> is the router state:

use better_auth::{run_migrations, AuthConfig, BetterAuth};
use better_auth::store::Database;
use better_auth::plugins::{
    EmailPasswordPlugin, SessionManagementPlugin,
    PasswordManagementPlugin, AccountManagementPlugin,
};
use better_auth::integrations::axum::AxumIntegration;
use axum::Router;
use std::sync::Arc;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = AuthConfig::new("your-very-secure-secret-key-at-least-32-chars-long")
        .base_url("http://localhost:8080");
    let database = Database::connect("sqlite::memory:").await?;
    run_migrations(&database).await?;

    let auth = Arc::new(
        BetterAuth::new(config)
            .database(database)
            .plugin(EmailPasswordPlugin::new().enable_signup(true))
            .plugin(SessionManagementPlugin::new())
            .plugin(PasswordManagementPlugin::new())
            .plugin(AccountManagementPlugin::new())
            .build()
            .await?
    );

    let auth_router = auth.clone().axum_router();

    let app = Router::new()
        .nest("/auth", auth_router)
        .with_state(auth);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
    axum::serve(listener, app).await?;

    Ok(())
}

Using Better Auth Inside Your AppState

For real applications, prefer axum_router_with_state::<AppState>() and let Axum extract Arc<BetterAuth> from your application state via FromRef.

use axum::extract::FromRef;
use axum::{Json, Router, routing::get};
use better_auth::{run_migrations, AuthConfig, BetterAuth};
use better_auth::integrations::axum::{AxumIntegration, CurrentSession};
use better_auth::plugins::{EmailPasswordPlugin, SessionManagementPlugin};
use better_auth::prelude::AuthUser;
use better_auth::store::{Database, DatabaseConnection};
use std::sync::Arc;

#[derive(Clone)]
struct AppState {
    auth: Arc<BetterAuth>,
    db: DatabaseConnection,
    app_name: &'static str,
}

impl FromRef<AppState> for Arc<BetterAuth> {
    fn from_ref(state: &AppState) -> Self {
        state.auth.clone()
    }
}

async fn profile(session: CurrentSession) -> Json<serde_json::Value> {
    Json(serde_json::json!({
        "id": session.user.id(),
        "email": session.user.email(),
    }))
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let database = Database::connect("sqlite::memory:").await?;
    run_migrations(&database).await?;

    let auth = Arc::new(
        BetterAuth::new(
            AuthConfig::new("your-very-secure-secret-key-at-least-32-chars-long")
                .base_url("http://localhost:8080"),
        )
        .database(database.clone())
        .plugin(EmailPasswordPlugin::new().enable_signup(true))
        .plugin(SessionManagementPlugin::new())
        .build()
        .await?,
    );

    let state = AppState {
        auth: auth.clone(),
        db: database,
        app_name: "my-app",
    };

    let app = Router::new()
        .route("/api/profile", get(profile))
        .nest("/auth", auth.axum_router_with_state::<AppState>())
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

What Gets Mounted

.axum_router() automatically registers:

  • All plugin routes (sign-up, sign-in, sessions, etc.)
  • GET /ok — health check
  • GET /reference/openapi.json — OpenAPI specification
  • POST /update-user — user profile updates
  • POST /delete-user — account deletion
  • POST /change-email — email changes

With the router nested under /auth, endpoints become /auth/sign-up/email, /auth/get-session, etc.

Session Extractors

Better Auth provides Axum extractors that automatically validate the session token and give you the current user — no manual middleware needed.

CurrentSession — Require Authentication

Use CurrentSession in your handler signature to require a valid session. Returns 401 Unauthorized automatically if no valid session is found.

use better_auth::integrations::axum::CurrentSession;
use better_auth::prelude::AuthUser; // trait for .id(), .email(), etc.
use axum::{Json, response::IntoResponse};

async fn get_profile(
    session: CurrentSession,
) -> impl IntoResponse {
    Json(serde_json::json!({
        "id": session.user.id(),
        "email": session.user.email(),
        "name": session.user.name(),
    }))
}

CurrentSession provides two public fields:

FieldTypeDescription
userbetter_auth::prelude::UserThe authenticated user (implements AuthUser)
sessionbetter_auth::prelude::SessionThe current session (implements AuthSession)

OptionalSession — Optional Authentication

Use OptionalSession for routes that should work for both authenticated and anonymous users. Never returns an error — wraps the result in Option.

use better_auth::integrations::axum::OptionalSession;
use better_auth::prelude::AuthUser;
use axum::{Json, response::IntoResponse};

async fn home(
    session: OptionalSession,
) -> impl IntoResponse {
    let user_info = session.0.map(|s| {
        serde_json::json!({
            "id": s.user.id(),
            "email": s.user.email(),
        })
    });

    Json(serde_json::json!({
        "message": "Welcome",
        "user": user_info,
    }))
}

Token Extraction

Both extractors look for the session token in this order:

  1. Authorization: Bearer <token> header
  2. Session cookie (name from SessionConfig::cookie_name, default better-auth.session_token)

Full Example

use axum::{Json, Router, response::IntoResponse, routing::get};
use better_auth::integrations::axum::{AxumIntegration, CurrentSession, OptionalSession};
use better_auth::plugins::{EmailPasswordPlugin, SessionManagementPlugin};
use better_auth::{run_migrations, AuthConfig, BetterAuth};
use better_auth::prelude::AuthUser;
use better_auth::store::Database;
use std::sync::Arc;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = AuthConfig::new("your-very-secure-secret-key-at-least-32-chars-long")
        .base_url("http://localhost:8080");
    let database = Database::connect("sqlite::memory:").await?;
    run_migrations(&database).await?;

    let auth = Arc::new(
        BetterAuth::new(config)
            .database(database)
            .plugin(EmailPasswordPlugin::new().enable_signup(true))
            .plugin(SessionManagementPlugin::new())
            .build()
            .await?
    );

    let auth_router = auth.clone().axum_router();

    let app = Router::new()
        .route("/api/profile", get(get_profile))
        .route("/api/public", get(public_route))
        .nest("/auth", auth_router)
        .with_state(auth);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
    axum::serve(listener, app).await?;

    Ok(())
}

/// Protected route — requires authentication
async fn get_profile(
    session: CurrentSession,
) -> impl IntoResponse {
    Json(serde_json::json!({
        "id": session.user.id(),
        "email": session.user.email(),
        "name": session.user.name(),
    }))
}

/// Public route — works for both authenticated and anonymous users
async fn public_route(
    session: OptionalSession,
) -> impl IntoResponse {
    let user = session.0.map(|s| s.user.id().to_string());
    Json(serde_json::json!({
        "authenticated": user.is_some(),
        "user_id": user,
    }))
}

Request/Response Conversion

The integration automatically converts between Axum and Better Auth types:

  • Headers: All request headers are forwarded
  • Body: Request body is read as bytes and passed through
  • Query: Query parameters are parsed from the URL
  • Status codes: Mapped directly to HTTP status codes
  • Response headers: Set-Cookie and other headers are forwarded

On this page