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
[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 checkGET /reference/openapi.json— OpenAPI specificationPOST /update-user— user profile updatesPOST /delete-user— account deletionPOST /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:
| Field | Type | Description |
|---|---|---|
user | better_auth::prelude::User | The authenticated user (implements AuthUser) |
session | better_auth::prelude::Session | The 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:
Authorization: Bearer <token>header- Session cookie (name from
SessionConfig::cookie_name, defaultbetter-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