Rust Axumミドルウェア認証実践:JWTからRBACまで6つのプロダクションパターン

编程语言

Rust Webサービスの認証、なぜ常に落とし穴にはまるのか

Axumエンドポイントを認証なしでデプロイした;JWT検証を追加したら、期限切れトークンでユーザーが401を受け取る;RBACを実装したかったが、ミドルウェアでユーザーロールにアクセスできない;レート制限を追加したら、未認証リクエストがクォータを消費している。2026年、Axum 0.8FromRequestPartsエクストラクタ、Tower Layerミドルウェア、型安全な状態管理を提供している——しかし認証はフレームワークが代わりにやってくれるものではない

本記事ではJWT検証から出発し、JWT認証→APIキー認証→RBACロールベースアクセス制御→レート制限→セッション管理→プロダクション認証サービスの6つの実践パターンを解説し、Axumの認証を「動く」から「耐える」レベルへ引き上げる。


コア概念

概念 説明
FromRequestParts Axumエクストラクタtrait、リクエストPartsから認証情報を抽出
JWT (JSON Web Token) ステートレストークン、ユーザーIDと有効期限を含む署名済みデータ
API Key Header経由で渡されるキー、サービス間呼び出しに適している
RBAC ロールベースアクセス制御、ユーザー→ロール→パーミッションの3層モデル
Tower Layer ミドルウェア抽象レイヤ、認証やレート制限などの横断的関心事を組み合わせる
Rate Limiting レート制限、API悪用を防止
Session ステートフルセッション、サーバー側でログイン状態を保存(Redis等)
Claims JWTペイロード内のクレームデータ(sub/exp/role等)

認証リクエストフロー

認証リクエストフロー:
1. クライアントがリクエストを送信、Authorization HeaderまたはAPIキーを付与
2. ミドルウェア/エクストラクタが認証クレデンシャルを抽出
3. クレデンシャルの有効性を検証(署名検証/有効期限チェック/キー照合)
4. ユーザーコンテキストを構築(UserId/Role/Permissions)
5. RBACミドルウェアがユーザーの現在のルートへのアクセス権をチェック
6. レート制限ミドルウェアがリクエスト頻度をチェック
7. Handlerがビジネスロジックを実行、State経由でユーザーコンテキストにアクセス可能
8. レスポンスをクライアントに返却

問題分析:Axum認証開発の5つの課題

  1. JWT検証とユーザーコンテキストの断絶:ミドルウェアでトークンを検証しても、Handlerでユーザー情報にアクセスできず、再度解析する羽目になる
  2. 複数認証方式の共存が困難:JWTとAPIキーの両方をサポートする必要があるが、ミドルウェアがif-elseスパゲッティになる
  3. RBACパーミッションモデルの設計混乱:ロールとパーミッションを文字列でハードコード、パーミッション追加時に十数箇所を変更
  4. レート制限と認証の順序衝突:レート制限が認証の前にあると未認証リクエストがクォータを浪費;認証の後だと悪意あるリクエストが認証レイヤに直撃
  5. セッション管理のプロダクションソリューション不足:JWTはステートレスだが能動的な失効が不可能、SessionはステートフルだがRedis接続プールと有効期限クリーンアップが落とし穴

ステップバイステップ:6つのプロダクション認証パターン

パターン1:JWT認証ミドルウェアとFromRequestParts

use axum::extract::{FromRequestParts, Request};
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
use axum::middleware::{Next, from_fn};
use axum::{Json, Router, middleware, routing::get};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use chrono::{Utc, Duration};
use std::sync::Arc;

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
    pub sub: String,
    pub role: String,
    pub exp: i64,
    pub iat: i64,
}

#[derive(Clone)]
pub struct AuthConfig {
    pub jwt_secret: String,
    pub jwt_expiration_hours: i64,
}

pub struct AppState {
    pub auth_config: AuthConfig,
    pub db: DbPool,
}

pub type SharedState = Arc<AppState>;

impl Claims {
    pub fn new(user_id: &str, role: &str, expiration_hours: i64) -> Self {
        let now = Utc::now();
        Self {
            sub: user_id.to_string(),
            role: role.to_string(),
            iat: now.timestamp(),
            exp: (now + Duration::hours(expiration_hours)).timestamp(),
        }
    }

    pub fn encode(&self, secret: &str) -> Result<String, jsonwebtoken::errors::Error> {
        encode(
            &Header::default(),
            self,
            &EncodingKey::from_secret(secret.as_bytes()),
        )
    }

    pub fn decode(token: &str, secret: &str) -> Result<Self, jsonwebtoken::errors::Error> {
        let token_data = decode::<Claims>(
            token,
            &DecodingKey::from_secret(secret.as_bytes()),
            &Validation::default(),
        )?;
        Ok(token_data.claims)
    }
}
use axum::extract::FromRequestParts;
use axum::http::StatusCode;

pub struct AuthUser {
    pub user_id: String,
    pub role: String,
}

#[axum::async_trait]
impl FromRequestParts<SharedState> for AuthUser {
    type Rejection = AuthError;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &SharedState,
    ) -> Result<Self, Self::Rejection> {
        let auth_header = parts
            .headers
            .get("authorization")
            .and_then(|v| v.to_str().ok())
            .ok_or(AuthError::MissingToken)?;

        let token = auth_header
            .strip_prefix("Bearer ")
            .ok_or(AuthError::InvalidTokenFormat)?;

        let claims = Claims::decode(token, &state.auth_config.jwt_secret)
            .map_err(|_| AuthError::InvalidToken)?;

        Ok(AuthUser {
            user_id: claims.sub,
            role: claims.role,
        })
    }
}

async fn protected_handler(
    auth_user: AuthUser,
) -> Result<Json<serde_json::Value>, AuthError> {
    Ok(Json(serde_json::json!({
        "user_id": auth_user.user_id,
        "role": auth_user.role,
    })))
}

pub fn auth_router() -> Router<SharedState> {
    Router::new()
        .route("/me", get(protected_handler))
        .route("/refresh", get(refresh_token))
}

async fn refresh_token(
    auth_user: AuthUser,
    State(state): State<SharedState>,
) -> Result<Json<serde_json::Value>, AuthError> {
    let claims = Claims::new(
        &auth_user.user_id,
        &auth_user.role,
        state.auth_config.jwt_expiration_hours,
    );
    let token = claims.encode(&state.auth_config.jwt_secret)?;
    Ok(Json(serde_json::json!({ "token": token })))
}

パターン2:APIキー認証とヘッダー抽出

use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use std::collections::HashMap;
use tokio::sync::RwLock;

#[derive(Debug, Clone)]
pub struct ApiKeyInfo {
    pub key_id: String,
    pub client_name: String,
    pub permissions: Vec<String>,
    pub rate_limit: u32,
}

pub struct ApiKeyState {
    pub keys: Arc<RwLock<HashMap<String, ApiKeyInfo>>>,
}

pub struct AuthenticatedApi {
    pub key_info: ApiKeyInfo,
}

#[axum::async_trait]
impl FromRequestParts<SharedState> for AuthenticatedApi {
    type Rejection = AuthError;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &SharedState,
    ) -> Result<Self, Self::Rejection> {
        let api_key = parts
            .headers
            .get("x-api-key")
            .and_then(|v| v.to_str().ok())
            .ok_or(AuthError::MissingApiKey)?;

        let keys = state.api_key_state.keys.read().await;
        let key_info = keys
            .get(api_key)
            .ok_or(AuthError::InvalidApiKey)?
            .clone();

        Ok(AuthenticatedApi { key_info })
    }
}

async fn api_endpoint(
    auth: AuthenticatedApi,
) -> Result<Json<serde_json::Value>, AuthError> {
    Ok(Json(serde_json::json!({
        "client": auth.key_info.client_name,
        "permissions": auth.key_info.permissions,
    })))
}
use axum::extract::FromRequestParts;
use axum::http::request::Parts;

pub enum AuthMethod {
    Jwt(AuthUser),
    ApiKey(AuthenticatedApi),
}

pub struct MultiAuth;

#[axum::async_trait]
impl FromRequestParts<SharedState> for AuthMethod {
    type Rejection = AuthError;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &SharedState,
    ) -> Result<Self, Self::Rejection> {
        if parts.headers.contains_key("x-api-key") {
            let api_auth = AuthenticatedApi::from_request_parts(parts, state).await?;
            Ok(AuthMethod::ApiKey(api_auth))
        } else if parts.headers.contains_key("authorization") {
            let jwt_auth = AuthUser::from_request_parts(parts, state).await?;
            Ok(AuthMethod::Jwt(jwt_auth))
        } else {
            Err(AuthError::NoAuthMethod)
        }
    }
}

async fn multi_auth_handler(
    auth: AuthMethod,
) -> Json<serde_json::Value> {
    match auth {
        AuthMethod::Jwt(user) => Json(serde_json::json!({
            "method": "jwt",
            "user_id": user.user_id,
        })),
        AuthMethod::ApiKey(api) => Json(serde_json::json!({
            "method": "api_key",
            "client": api.key_info.client_name,
        })),
    }
}

パターン3:RBACロールベースアクセス制御とenumパーミッション

use strum::{Display, EnumString};
use std::collections::HashSet;

#[derive(Debug, Clone, PartialEq, Eq, Hash, Display, EnumString)]
pub enum Permission {
    #[strum(to_string = "users:read")]
    UsersRead,
    #[strum(to_string = "users:write")]
    UsersWrite,
    #[strum(to_string = "users:delete")]
    UsersDelete,
    #[strum(to_string = "products:read")]
    ProductsRead,
    #[strum(to_string = "products:write")]
    ProductsWrite,
    #[strum(to_string = "orders:read")]
    OrdersRead,
    #[strum(to_string = "orders:write")]
    OrdersWrite,
    #[strum(to_string = "admin:full")]
    AdminFull,
}

#[derive(Debug, Clone, PartialEq, Eq, Display, EnumString)]
pub enum Role {
    #[strum(to_string = "viewer")]
    Viewer,
    #[strum(to_string = "editor")]
    Editor,
    #[strum(to_string = "admin")]
    Admin,
    #[strum(to_string = "superadmin")]
    SuperAdmin,
}

impl Role {
    pub fn permissions(&self) -> HashSet<Permission> {
        match self {
            Role::Viewer => HashSet::from([
                Permission::UsersRead,
                Permission::ProductsRead,
                Permission::OrdersRead,
            ]),
            Role::Editor => HashSet::from([
                Permission::UsersRead,
                Permission::ProductsRead,
                Permission::ProductsWrite,
                Permission::OrdersRead,
                Permission::OrdersWrite,
            ]),
            Role::Admin => HashSet::from([
                Permission::UsersRead,
                Permission::UsersWrite,
                Permission::ProductsRead,
                Permission::ProductsWrite,
                Permission::OrdersRead,
                Permission::OrdersWrite,
            ]),
            Role::SuperAdmin => HashSet::from([Permission::AdminFull]),
        }
    }

    pub fn has_permission(&self, permission: &Permission) -> bool {
        let perms = self.permissions();
        perms.contains(&Permission::AdminFull) || perms.contains(permission)
    }
}
use axum::extract::{FromRequestParts, Path};
use axum::http::request::Parts;

pub struct RequirePermission(pub Permission);

#[axum::async_trait]
impl FromRequestParts<SharedState> for RequirePermission {
    type Rejection = AuthError;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &SharedState,
    ) -> Result<Self, Self::Rejection> {
        let auth_user = AuthUser::from_request_parts(parts, state).await?;
        let role: Role = auth_user.role.parse().map_err(|_| AuthError::InvalidRole)?;

        let required = Permission::UsersWrite;
        if !role.has_permission(&required) {
            return Err(AuthError::Forbidden(
                format!("Missing permission: {}", required),
            ));
        }

        Ok(RequirePermission(required))
    }
}

async fn delete_user_handler(
    auth_user: AuthUser,
    Path(user_id): Path<String>,
) -> Result<StatusCode, AuthError> {
    let role: Role = auth_user.role.parse().map_err(|_| AuthError::InvalidRole)?;
    if !role.has_permission(&Permission::UsersDelete) {
        return Err(AuthError::Forbidden("Missing users:delete permission".into()));
    }
    tracing::info!("Deleting user: {}", user_id);
    Ok(StatusCode::NO_CONTENT)
}

パターン4:レート制限ミドルウェアとTower Layer

use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::RwLock;

#[derive(Clone)]
pub struct RateLimitConfig {
    pub max_requests: u32,
    pub window_secs: u64,
}

#[derive(Debug)]
struct RateLimitEntry {
    count: u32,
    window_start: Instant,
}

#[derive(Clone)]
pub struct RateLimitState {
    pub entries: Arc<RwLock<HashMap<String, RateLimitEntry>>>,
    pub config: RateLimitConfig,
}

impl RateLimitState {
    pub fn new(config: RateLimitConfig) -> Self {
        Self {
            entries: Arc::new(RwLock::new(HashMap::new())),
            config,
        }
    }

    pub async fn check_rate_limit(&self, key: &str) -> bool {
        let mut entries = self.entries.write().await;
        let now = Instant::now();

        let entry = entries.entry(key.to_string()).or_insert(RateLimitEntry {
            count: 0,
            window_start: now,
        });

        if now.duration_since(entry.window_start).as_secs() > self.config.window_secs {
            entry.count = 0;
            entry.window_start = now;
        }

        entry.count += 1;
        entry.count <= self.config.max_requests
    }
}

pub async fn rate_limit_middleware(
    request: Request,
    next: Next,
) -> Result<Response, AuthError> {
    let state = request
        .extensions()
        .get::<RateLimitState>()
        .cloned()
        .ok_or(AuthError::InternalServerError("Rate limit state not found".into()))?;

    let key = request
        .headers()
        .get("x-api-key")
        .and_then(|v| v.to_str().ok())
        .or_else(|| {
            request.headers()
                .get("x-forwarded-for")
                .and_then(|v| v.to_str().ok())
        })
        .unwrap_or("anonymous")
        .to_string();

    if !state.check_rate_limit(&key).await {
        return Err(AuthError::RateLimited);
    }

    Ok(next.run(request).await)
}
use axum::Router;
use axum::middleware;
use tower::ServiceBuilder;
use tower_http::limit::RateLimitLayer;
use std::time::Duration;

pub fn create_rate_limited_router(state: SharedState) -> Router<SharedState> {
    let rate_limit_state = RateLimitState::new(RateLimitConfig {
        max_requests: 100,
        window_secs: 60,
    });

    Router::new()
        .route("/api/data", get(data_handler))
        .layer(middleware::from_fn(rate_limit_middleware))
        .layer(middleware::from_fn(auth_middleware))
        .with_state(state)
}

pub fn create_tower_rate_limited_router(state: SharedState) -> Router<SharedState> {
    Router::new()
        .route("/api/data", get(data_handler))
        .layer(
            ServiceBuilder::new()
                .layer(RateLimitLayer::new(100, Duration::from_secs(60)))
                .into_inner(),
        )
        .with_state(state)
}

async fn data_handler() -> &'static str {
    "OK"
}

パターン5:セッション管理とRedisバックエンド

use redis::AsyncCommands;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
    pub session_id: String,
    pub user_id: String,
    pub role: String,
    pub created_at: i64,
    pub expires_at: i64,
    pub ip_address: Option<String>,
    pub user_agent: Option<String>,
}

impl Session {
    pub fn new(user_id: &str, role: &str, ttl_secs: i64, ip: Option<&str>, ua: Option<&str>) -> Self {
        let now = Utc::now().timestamp();
        Self {
            session_id: Uuid::new_v4().to_string(),
            user_id: user_id.to_string(),
            role: role.to_string(),
            created_at: now,
            expires_at: now + ttl_secs,
            ip_address: ip.map(|s| s.to_string()),
            user_agent: ua.map(|s| s.to_string()),
        }
    }

    pub fn is_expired(&self) -> bool {
        Utc::now().timestamp() > self.expires_at
    }
}

#[derive(Clone)]
pub struct SessionStore {
    pub client: redis::Client,
    pub ttl_secs: i64,
    pub key_prefix: String,
}

impl SessionStore {
    pub fn new(redis_url: &str, ttl_secs: i64) -> Result<Self, redis::RedisError> {
        Ok(Self {
            client: redis::Client::open(redis_url)?,
            ttl_secs,
            key_prefix: "session:".to_string(),
        })
    }

    pub async fn create_session(&self, session: &Session) -> Result<(), redis::RedisError> {
        let mut conn = self.client.get_multiplexed_async_connection().await?;
        let key = format!("{}{}", self.key_prefix, session.session_id);
        let value = serde_json::to_string(session).unwrap();
        conn.set_ex(&key, value, self.ttl_secs as u64).await?;
        Ok(())
    }

    pub async fn get_session(&self, session_id: &str) -> Result<Option<Session>, redis::RedisError> {
        let mut conn = self.client.get_multiplexed_async_connection().await?;
        let key = format!("{}{}", self.key_prefix, session_id);
        let value: Option<String> = conn.get(&key).await?;
        match value {
            Some(v) => Ok(Some(serde_json::from_str(&v).unwrap())),
            None => Ok(None),
        }
    }

    pub async fn delete_session(&self, session_id: &str) -> Result<(), redis::RedisError> {
        let mut conn = self.client.get_multiplexed_async_connection().await?;
        let key = format!("{}{}", self.key_prefix, session_id);
        conn.del(&key).await?;
        Ok(())
    }

    pub async fn refresh_session(&self, session_id: &str) -> Result<(), redis::RedisError> {
        let mut conn = self.client.get_multiplexed_async_connection().await?;
        let key = format!("{}{}", self.key_prefix, session_id);
        conn.expire(&key, self.ttl_secs as i64).await?;
        Ok(())
    }
}
use axum::extract::{FromRequestParts, Request};
use axum::http::request::Parts;
use axum::middleware::Next;
use axum::response::Response;
use axum::Extension;

pub struct SessionUser {
    pub session: Session,
}

#[axum::async_trait]
impl FromRequestParts<SharedState> for SessionUser {
    type Rejection = AuthError;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &SharedState,
    ) -> Result<Self, Self::Rejection> {
        let cookie_header = parts
            .headers
            .get("cookie")
            .and_then(|v| v.to_str().ok())
            .ok_or(AuthError::MissingSession)?;

        let session_id = extract_session_id(cookie_header)
            .ok_or(AuthError::MissingSession)?;

        let session = state
            .session_store
            .get_session(&session_id)
            .await
            .map_err(|_| AuthError::InternalServerError("Redis error".into()))?
            .ok_or(AuthError::SessionExpired)?;

        if session.is_expired() {
            state.session_store.delete_session(&session_id).await.ok();
            return Err(AuthError::SessionExpired);
        }

        state.session_store.refresh_session(&session_id).await.ok();

        Ok(SessionUser { session })
    }
}

fn extract_session_id(cookie_header: &str) -> Option<String> {
    cookie_header
        .split(';')
        .find_map(|cookie| {
            let mut parts = cookie.trim().splitn(2, '=');
            let name = parts.next()?.trim();
            if name == "session_id" {
                Some(parts.next()?.trim().to_string())
            } else {
                None
            }
        })
}

async fn login_handler(
    State(state): State<SharedState>,
    Json(payload): Json<LoginRequest>,
) -> Result<Json<serde_json::Value>, AuthError> {
    let user = state.db.verify_user(&payload.username, &payload.password).await
        .ok_or(AuthError::InvalidCredentials)?;

    let session = Session::new(
        &user.id,
        &user.role,
        state.session_store.ttl_secs,
        None,
        None,
    );

    state.session_store.create_session(&session).await
        .map_err(|_| AuthError::InternalServerError("Failed to create session".into()))?;

    Ok(Json(serde_json::json!({
        "session_id": session.session_id,
        "expires_at": session.expires_at,
    })))
}

async fn logout_handler(
    session_user: SessionUser,
    State(state): State<SharedState>,
) -> Result<StatusCode, AuthError> {
    state.session_store
        .delete_session(&session_user.session.session_id)
        .await
        .map_err(|_| AuthError::InternalServerError("Failed to delete session".into()))?;
    Ok(StatusCode::NO_CONTENT)
}

パターン6:プロダクション認証サービスの統合

use axum::{Router, routing::{get, post}, middleware, extract::State};
use tower::ServiceBuilder;
use tower_http::cors::{CorsLayer, Any};
use tower_http::trace::TraceLayer;
use std::time::Duration;

pub struct AppState {
    pub auth_config: AuthConfig,
    pub api_key_state: ApiKeyState,
    pub rate_limit_state: RateLimitState,
    pub session_store: SessionStore,
    pub db: DbPool,
}

pub type SharedState = Arc<AppState>;

pub fn create_production_router(state: SharedState) -> Router {
    let public_routes = Router::new()
        .route("/auth/login", post(login_handler))
        .route("/auth/register", post(register_handler))
        .route("/health", get(health_check));

    let jwt_protected = Router::new()
        .route("/users/me", get(get_profile))
        .route("/users/me", post(update_profile))
        .route("/auth/refresh", get(refresh_token))
        .layer(middleware::from_fn_with_state(
            state.clone(),
            jwt_auth_middleware,
        ));

    let api_key_routes = Router::new()
        .route("/api/v1/data", get(data_handler))
        .route("/api/v1/reports", get(reports_handler))
        .layer(middleware::from_fn_with_state(
            state.clone(),
            api_key_auth_middleware,
        ));

    let admin_routes = Router::new()
        .route("/admin/users", get(list_users_handler))
        .route("/admin/users/{id}", post(delete_user_handler))
        .layer(middleware::from_fn_with_state(
            state.clone(),
            admin_auth_middleware,
        ));

    let session_routes = Router::new()
        .route("/dashboard", get(dashboard_handler))
        .route("/auth/logout", post(logout_handler))
        .layer(middleware::from_fn_with_state(
            state.clone(),
            session_auth_middleware,
        ));

    Router::new()
        .merge(public_routes)
        .nest("/api", jwt_protected)
        .nest("/external", api_key_routes)
        .nest("/manage", admin_routes)
        .nest("/web", session_routes)
        .layer(
            ServiceBuilder::new()
                .layer(TraceLayer::new_for_http())
                .layer(CorsLayer::new().allow_origin(Any).allow_methods(Any).allow_headers(Any))
                .into_inner(),
        )
        .with_state(state)
}

async fn jwt_auth_middleware(
    State(state): State<SharedState>,
    mut request: Request,
    next: Next,
) -> Result<Response, AuthError> {
    let auth_header = request.headers()
        .get("authorization")
        .and_then(|v| v.to_str().ok())
        .ok_or(AuthError::MissingToken)?;

    let token = auth_header
        .strip_prefix("Bearer ")
        .ok_or(AuthError::InvalidTokenFormat)?;

    let claims = Claims::decode(token, &state.auth_config.jwt_secret)
        .map_err(|_| AuthError::InvalidToken)?;

    request.extensions_mut().insert(AuthUser {
        user_id: claims.sub.clone(),
        role: claims.role.clone(),
    });

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

async fn api_key_auth_middleware(
    State(state): State<SharedState>,
    mut request: Request,
    next: Next,
) -> Result<Response, AuthError> {
    let api_key = request.headers()
        .get("x-api-key")
        .and_then(|v| v.to_str().ok())
        .ok_or(AuthError::MissingApiKey)?;

    let keys = state.api_key_state.keys.read().await;
    let key_info = keys.get(api_key).ok_or(AuthError::InvalidApiKey)?.clone();

    if !state.rate_limit_state.check_rate_limit(&key_info.key_id).await {
        return Err(AuthError::RateLimited);
    }

    request.extensions_mut().insert(key_info);
    Ok(next.run(request).await)
}

async fn admin_auth_middleware(
    State(state): State<SharedState>,
    mut request: Request,
    next: Next,
) -> Result<Response, AuthError> {
    let auth_header = request.headers()
        .get("authorization")
        .and_then(|v| v.to_str().ok())
        .ok_or(AuthError::MissingToken)?;

    let token = auth_header.strip_prefix("Bearer ").ok_or(AuthError::InvalidTokenFormat)?;
    let claims = Claims::decode(token, &state.auth_config.jwt_secret)
        .map_err(|_| AuthError::InvalidToken)?;

    let role: Role = claims.role.parse().map_err(|_| AuthError::InvalidRole)?;
    if !role.has_permission(&Permission::UsersWrite) {
        return Err(AuthError::Forbidden("Admin access required".into()));
    }

    request.extensions_mut().insert(AuthUser {
        user_id: claims.sub,
        role: claims.role,
    });

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

async fn session_auth_middleware(
    State(state): State<SharedState>,
    mut request: Request,
    next: Next,
) -> Result<Response, AuthError> {
    let cookie_header = request.headers()
        .get("cookie")
        .and_then(|v| v.to_str().ok())
        .ok_or(AuthError::MissingSession)?;

    let session_id = extract_session_id(cookie_header).ok_or(AuthError::MissingSession)?;
    let session = state.session_store.get_session(&session_id).await
        .map_err(|_| AuthError::InternalServerError("Redis error".into()))?
        .ok_or(AuthError::SessionExpired)?;

    if session.is_expired() {
        state.session_store.delete_session(&session_id).await.ok();
        return Err(AuthError::SessionExpired);
    }

    state.session_store.refresh_session(&session_id).await.ok();
    request.extensions_mut().insert(session);
    Ok(next.run(request).await)
}

落とし穴ガイド

落とし穴1:JWTシークレットのハードコード

// ❌ 間違い:シークレットをソースコードにハードコード
let secret = "my-super-secret-key-123";

// ✅ 正しい:環境変数から読み込み、起動時に検証
let secret = std::env::var("JWT_SECRET")
    .expect("JWT_SECRET must be set");
if secret.len() < 32 {
    panic!("JWT_SECRET must be at least 32 characters");
}

落とし穴2:ミドルウェアでのState型不一致

// ❌ 間違い:from_fnはStateにアクセスできない、コンパイルエラー
.layer(middleware::from_fn(my_auth_middleware))

async fn my_auth_middleware(
    State(state): State<SharedState>, // from_fnはStateパラメータをサポートしない!
    request: Request,
    next: Next,
) -> Result<Response, AuthError> { ... }

// ✅ 正しい:from_fn_with_stateを使用
.layer(middleware::from_fn_with_state(state.clone(), my_auth_middleware))

async fn my_auth_middleware(
    State(state): State<SharedState>,
    request: Request,
    next: Next,
) -> Result<Response, AuthError> { ... }

落とし穴3:JWT Claimsの有効期限検証不足

// ❌ 間違い:手動でValidationを作成したがexpチェックが有効になっていない
let validation = Validation::new(jsonwebtoken::Algorithm::HS256);
// デフォルトではexpをチェックするが、手動構築では見落とす可能性あり

// ✅ 正しい:Validationを明示的に設定
let mut validation = Validation::new(jsonwebtoken::Algorithm::HS256);
validation.leeway = 60; // 60秒のクロックスキューを許容
validation.validate_exp = true;
validation.validate_nbf = true;
let token_data = decode::<Claims>(token, &key, &validation)?;

落とし穴4:RBACで文字列マッチングを使用

// ❌ 間違い:文字列のハードコード、タイプミスがコンパイル時に検出されない
if user.role == "admin" || user.permissions.contains(&"users:write".to_string()) {
    // タイプミスがコンパイラに検出されない
}

// ✅ 正しい:enum + strumを使用、コンパイル時のパーミッション名安全性
#[derive(EnumString)]
pub enum Permission {
    #[strum(to_string = "users:write")]
    UsersWrite,
}

let perm = Permission::UsersWrite;
if role.has_permission(&perm) {
    // コンパイル時安全
}

落とし穴5:Redis接続の再利用不足

// ❌ 間違い:リクエストごとに新しい接続を作成
async fn get_session(session_id: &str) -> Option<Session> {
    let client = redis::Client::open("redis://localhost").unwrap();
    let mut conn = client.get_async_connection().await.unwrap(); // 毎回新規作成!
    conn.get(session_id).await.ok()
}

// ✅ 正しい:multiplexed接続の再利用
pub struct SessionStore {
    client: redis::Client,
}

impl SessionStore {
    pub async fn get_session(&self, session_id: &str) -> Result<Option<Session>, redis::RedisError> {
        let mut conn = self.client.get_multiplexed_async_connection().await?;
        let value: Option<String> = conn.get(session_id).await?;
        // ...
    }
}

エラートラブルシューティング

# エラー 原因 解決方法
1 the trait FromRequestParts is not implemented for AuthUser FromRequestParts traitが未実装 #[axum::async_trait]でtraitを実装、Rejection型がIntoResponseを実装していることを確認
2 mismatched types: expected State<X>, found State<Y> ミドルウェアとRouterのState型が不一致 type SharedState = Arc<AppState>エイリアスを統一使用
3 JWT decode error: InvalidToken トークン形式エラーまたはシークレット不一致 Bearerプレフィックス、シークレットの一貫性、アルゴリズムの一致を確認
4 JWT decode error: ExpiredSignature トークンが期限切れ refresh tokenメカニズムを実装、フロントエンドで自動更新
5 future cannot be sent between threads safely 非Send型がawaitポイントをまたいでいる get_multiplexed_async_connection()get_async_connection()の代わりに使用
6 missing field 'exp' in Claims Claims構造体にexpフィールドが欠落 Claimsにexpとiatフィールドが含まれていることを確認、Validationはデフォルトでチェック
7 Rate limit state not found in extensions Extensionが注入されていない ミドルウェアまたはRouterで.layer(Extension(state))経由で注入
8 Redis: Connection refused Redisサービスが未起動 Redisサービスの状態と接続URL設定を確認
9 handler has too many arguments Handlerのパラメータが4つのExtractorを超過 構造体でマージまたはExtensionで認証情報を渡す
10 Cannot drop a runtime in a context that is already inside a runtime async関数内でRedis接続を同期的に作成 get_multiplexed_async_connection().awaitで非同期接続

高度な最適化

1. JWTブラックリストと能動的失効

use std::collections::HashSet;
use tokio::sync::RwLock;

#[derive(Clone)]
pub struct JwtBlacklist {
    pub revoked_tokens: Arc<RwLock<HashSet<String>>>,
    pub redis_client: Option<redis::Client>,
}

impl JwtBlacklist {
    pub fn new(redis_url: Option<&str>) -> Result<Self, redis::RedisError> {
        let client = redis_url
            .map(redis::Client::open)
            .transpose()?;
        Ok(Self {
            revoked_tokens: Arc::new(RwLock::new(HashSet::new())),
            redis_client: client,
        })
    }

    pub async fn revoke_token(&self, jti: &str, exp_secs: i64) -> Result<(), AuthError> {
        if let Some(client) = &self.redis_client {
            let mut conn = client.get_multiplexed_async_connection().await
                .map_err(|_| AuthError::InternalServerError("Redis connection failed".into()))?;
            let key = format!("jwt:blacklist:{}", jti);
            redis::cmd("SET")
                .arg(&key)
                .arg("1")
                .arg("EX")
                .arg(exp_secs)
                .exec_async(&mut conn)
                .await
                .map_err(|_| AuthError::InternalServerError("Redis SET failed".into()))?;
        } else {
            let mut tokens = self.revoked_tokens.write().await;
            tokens.insert(jti.to_string());
        }
        Ok(())
    }

    pub async fn is_revoked(&self, jti: &str) -> bool {
        if let Some(client) = &self.redis_client {
            if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
                let key = format!("jwt:blacklist:{}", jti);
                if let Ok(exists) = redis::cmd("EXISTS")
                    .arg(&key)
                    .query_async::<i32>(&mut conn)
                    .await
                {
                    return exists > 0;
                }
            }
        }
        let tokens = self.revoked_tokens.read().await;
        tokens.contains(jti)
    }
}

2. 認証ミドルウェアのパフォーマンス最適化

use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
use moka::sync::Cache;
use std::sync::Arc;
use std::time::Duration;

#[derive(Clone)]
pub struct AuthCache {
    pub token_cache: Cache<String, AuthUser>,
    pub api_key_cache: Cache<String, ApiKeyInfo>,
}

impl AuthCache {
    pub fn new(max_entries: usize, ttl_secs: u64) -> Self {
        Self {
            token_cache: Cache::builder()
                .max_capacity(max_entries as u64)
                .time_to_live(Duration::from_secs(ttl_secs))
                .build(),
            api_key_cache: Cache::builder()
                .max_capacity(max_entries as u64)
                .time_to_live(Duration::from_secs(ttl_secs))
                .build(),
        }
    }
}

pub async fn cached_jwt_auth_middleware(
    State(state): State<SharedState>,
    mut request: Request,
    next: Next,
) -> Result<Response, AuthError> {
    let auth_header = request.headers()
        .get("authorization")
        .and_then(|v| v.to_str().ok())
        .ok_or(AuthError::MissingToken)?;

    let token = auth_header.strip_prefix("Bearer ").ok_or(AuthError::InvalidTokenFormat)?;

    if let Some(cached_user) = state.auth_cache.token_cache.get(token) {
        request.extensions_mut().insert(cached_user);
        return Ok(next.run(request).await);
    }

    let claims = Claims::decode(token, &state.auth_config.jwt_secret)
        .map_err(|_| AuthError::InvalidToken)?;

    let auth_user = AuthUser {
        user_id: claims.sub.clone(),
        role: claims.role.clone(),
    };

    state.auth_cache.token_cache.insert(token.to_string(), auth_user.clone());
    request.extensions_mut().insert(auth_user);
    Ok(next.run(request).await)
}

3. OpenTelemetryオブザーバビリティ統合

use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
use opentelemetry::trace::{Span, Tracer};
use opentelemetry::KeyValue;

pub async fn observability_middleware(
    request: Request,
    next: Next,
) -> Response {
    let method = request.method().clone();
    let path = request.uri().path().to_string();
    let auth_method = if request.headers().contains_key("x-api-key") {
        "api_key"
    } else if request.headers().contains_key("authorization") {
        "jwt"
    } else if request.headers().contains_key("cookie") {
        "session"
    } else {
        "none"
    };

    let tracer = opentelemetry::global::tracer("auth-service");
    let mut span = tracer.start(format!("{} {}", method, path));
    span.set_attribute(KeyValue::new("auth.method", auth_method.to_string()));
    span.set_attribute(KeyValue::new("http.method", method.to_string()));
    span.set_attribute(KeyValue::new("http.path", path.clone()));

    let response = next.run(request).await;

    span.set_attribute(KeyValue::new("http.status_code", response.status().as_u16() as i64));
    span.end();

    response
}

比較分析

次元 Axum+Tower Actix-web Guard Go Gin Java Spring Security
認証モデル FromRequestParts+Layer Guard trait+Extractor ミドルウェア関数 Filterチェーン+SecurityContext
型安全性 ✅コンパイル時 ⚠️一部ランタイム ❌ランタイム ❌ランタイム
ミドルウェア組み合わせ ✅Tower ServiceBuilder ⚠️手動ネスト ✅ミドルウェアチェーン ✅Filterチェーン
RBACサポート 自前実装が必要 自前実装が必要 casbin等 ✅内蔵@PreAuthorize
JWTエコシステム jsonwebtoken jsonwebtoken golang-jwt jjwt/nimbus
セッション管理 自前実装が必要 自前実装が必要 gorilla/sessions ✅内蔵Session
パフォーマンス ⭐極高 ⭐極高 ⭐高 ⭐中
学習曲線 ⭐急 ⭐急 ⭐緩やか ⭐非常に急
レート制限統合 tower-http 自前実装 tollbooth等 bucket4j等
プロダクション対応度 ⭐高 ⭐高 ⭐非常に高 ⭐非常に高

まとめ:Axum認証のコアアドバンテージは型安全なエクストラクタにある——FromRequestPartsにより認証情報が通常のパラメータのようにHandlerに注入され、コンパイル時に型不一致を検出できる。2026年のプロダクション実践:FromRequestPartsでJWT/APIキー抽出→enum + strumでRBACパーミッションモデル構築→from_fn_with_stateでステートフルミドルウェア作成→RedisでセッションとJWTブラックリスト管理→mokaキャッシュでトークン検証高速化→Tower ServiceBuilderでミドルウェアパイプライン構成。重要な洞察:Axumにおける認証は「インターセプタ」ではなく「型抽出」である。


オンラインツール推奨

  • Hash計算ツール:/ja/encode/hash — SHA256/SHA512等のハッシュ値を生成、APIキー検証に使用
  • Base64エンコード/デコード:/ja/encode/base64 — JWTのHeaderとPayload部分をデコード
  • JWTデコードツール:/ja/encode/jwt-decode — JWTトークンをオンライン解析、Claims内容を確認

ブラウザローカルツールを無料で試す →

#Rust#Axum#中间件#JWT#认证鉴权#2026#Tower