Webアプリケーション開発のJWT認証の実装で学んだこと

Webアプリケーション開発のJWT認証の実装で学んだこと

注意

この記事は JWT 認証の 基本実装パターン を解説したものです。 本番環境で運用する際は、以下の追加対策を検討してください:

  • 2要素認証(2FA/MFA)
  • IP ベースの異常検知・アラート
  • セッション管理 UI(他デバイスからのログアウト機能)
  • より厳格なレート制限(Redis等を使用した分散レート制限)
  • 定期的なセキュリティ監査・ペネトレーションテスト
  • WAF(Web Application Firewall)の導入

使用するサンプルコードは、私が実際に実装したRustコードから記事用に編集したものです。


はじめに

この記事では、Rust の Web フレームワーク axum と フロントエンドフレームワーク Leptos を使用した JWT 認証の実装パターンを解説します。

認証システムは Web アプリケーションの中核であり、セキュリティ上の脆弱性は致命的な問題につながります。この記事では、OWASP(Open Web Application Security Project)のベストプラクティスに基づいた実装パターンを紹介します。


JWT とは

JWT(JSON Web Token)は、RFC 7519 で定義された、当事者間で情報を安全に伝達するためのコンパクトで自己完結型のトークン形式です。

JWT の構造

JWT は 3 つのパートで構成されています。

例えば

ヘッダー.ペイロード.署名
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U
パート内容
Headerアルゴリズム(HS256 等)とトークンタイプ
Payloadユーザー情報や有効期限などのクレーム
Signatureヘッダーとペイロードの署名

Access Token と Refresh Token の二層構造

セキュアな JWT 認証では、2 種類のトークンを使い分けます。

トークン有効期限用途
Access Token短期間(15分程度)API リクエストの認証
Refresh Token長期間(7日程度)Access Token の更新

Access Token を短命にすることで、漏洩時の被害を最小限に抑えます。


JWT 認証で対策すべき攻撃手法

1. タイミング攻撃

タイミング攻撃は、処理時間の差異から情報を推測する攻撃です。

脆弱な例:ユーザーが存在しない場合に即座にエラーを返すと、処理時間の差からユーザーの存在有無を推測されます。

対策:ユーザーが存在しない場合でも、ダミーのパスワードハッシュで検証処理を実行します。

use bcrypt::verify;

const DUMMY_HASH: &str = "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYWqJkFG0WWm";

pub fn verify_password_secure(
    password: &str,
    user: Option<&User>,
) -> bool {
    // ユーザーが存在しない場合でもダミーハッシュで検証を実行
    // bcrypt の処理時間(約100-200ms)が常に発生するため、
    // ユーザーの存在有無を推測できないようにする
    let hash = user
        .map(|u| u.password_hash.as_str())
        .unwrap_or(DUMMY_HASH);

    verify(password, hash).unwrap_or(false)
}

2. ユーザー列挙攻撃

ユーザー列挙攻撃は、エラーメッセージの違いからユーザーの存在を特定する攻撃です。

脆弱な例

対策:統一されたエラーメッセージを返します。

// ユーザーの存在有無に関わらず同じメッセージを返す
let error_message = "メールアドレスまたはパスワードが正しくありません";

これまで意識せずに「メールアドレスとパスワードどっちが間違っているのか教えてくれよ!!」と思っていましたが、こういう意図があったんだなぁと勉強になりました。

3. ブルートフォース攻撃

ブルートフォース攻撃は、パスワードを総当たりで試行する攻撃です。

対策:ログイン失敗回数に基づくアカウントロック機構を実装します。

3回間違えたら30分ロックするような仕組みだとこのようになります。

const MAX_FAILED_ATTEMPTS: i32 = 3;
const LOCK_DURATION_MINUTES: i64 = 30;

pub async fn check_and_lock_account(
    pool: &PgPool,
    user_id: Uuid,
    failed_attempts: i32,
) -> Result<(), AppError> {
    let new_attempts = failed_attempts + 1;

    if new_attempts >= MAX_FAILED_ATTEMPTS {
        let lock_until = Utc::now() + Duration::minutes(LOCK_DURATION_MINUTES);

        sqlx::query(
            "UPDATE users SET failed_login_attempts = $1, locked_until = $2 WHERE id = $3"
        )
        .bind(new_attempts)
        .bind(lock_until)
        .bind(user_id)
        .execute(pool)
        .await?;
    }

    Ok(())
}

3回連続で間違った場合というケースはよく見ますね。サポートセンターに連絡してから復旧みたいなサービスも多いです。

4. CSRF(クロスサイトリクエストフォージェリ)

CSRF は、ログイン済みユーザーに意図しない操作を実行させる攻撃です。

対策SameSite Cookie 属性を設定します。

use axum_extra::extract::cookie::{Cookie, SameSite};

pub fn create_secure_cookie(name: &str, value: String) -> Cookie<'static> {
    Cookie::build((name, value))
        .path("/")
        .http_only(true)           // JavaScript からアクセス不可
        .secure(true)              // HTTPS でのみ送信
        .same_site(SameSite::Lax)  // クロスサイトリクエストを制限
        .build()
}

OWASP の CSRF Prevention Cheat Sheet によると、SameSite=Lax と CORS Origin 検証の組み合わせで、多くの CSRF 攻撃を防御できます。

5. XSS(クロスサイトスクリプティング)

XSS は、悪意のあるスクリプトを注入する攻撃です。XSS が成功すると、Cookie の窃取や CSRF 対策の無効化が可能になります。

対策

// HttpOnly 属性により、XSS でも Cookie を窃取できない
Cookie::build(("access_token", token))
    .http_only(true)  // 重要:JavaScript からアクセス不可
    .build()

6. トークン漏洩・リプレイ攻撃

トークンが漏洩した場合、攻撃者はそのトークンを再利用できます。

対策:トークンローテーションを実装します。

pub async fn refresh_token(
    pool: &PgPool,
    old_token_hash: &str,
    user_id: Uuid,
) -> Result<(String, String), AppError> {
    // 1. 古いトークンを無効化
    sqlx::query(
        "UPDATE refresh_tokens SET revoked = true WHERE token_hash = $1"
    )
    .bind(old_token_hash)
    .execute(pool)
    .await?;

    // 2. 新しいトークンを生成
    let new_access_token = create_access_token(&user_id.to_string())?;
    let new_refresh_token = create_refresh_token(&user_id.to_string())?;

    // 3. 新しいトークンを保存(ハッシュ化して保存)
    let token_hash = hash_token(&new_refresh_token);
    sqlx::query(
        "INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)"
    )
    .bind(user_id)
    .bind(&token_hash)
    .bind(Utc::now() + Duration::days(7))
    .execute(pool)
    .await?;

    Ok((new_access_token, new_refresh_token))
}

Rust(axum)での実装パターン

JWT トークンの生成と検証

jsonwebtoken クレートを使用します。

use jsonwebtoken::{encode, decode, Header, Algorithm, Validation, EncodingKey, DecodingKey};
use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    pub sub: String,        // ユーザーID
    pub exp: i64,           // 有効期限(Unix timestamp)
    pub iat: i64,           // 発行時刻
    pub token_type: String, // "access" or "refresh"
}

pub fn create_access_token(user_id: &str) -> Result<String, AppError> {
    let secret = std::env::var("ACCESS_TOKEN_SECRET")?;

    let claims = Claims {
        sub: user_id.to_string(),
        exp: (Utc::now() + Duration::minutes(15)).timestamp(),
        iat: Utc::now().timestamp(),
        token_type: "access".to_string(),
    };

    encode(
        &Header::new(Algorithm::HS256),
        &claims,
        &EncodingKey::from_secret(secret.as_bytes()),
    )
    .map_err(|e| AppError::TokenError(e.to_string()))
}

pub fn verify_access_token(token: &str) -> Result<Claims, AppError> {
    let secret = std::env::var("ACCESS_TOKEN_SECRET")?;

    let token_data = decode::<Claims>(
        token,
        &DecodingKey::from_secret(secret.as_bytes()),
        &Validation::new(Algorithm::HS256),
    )?;

    // トークンタイプの検証
    if token_data.claims.token_type != "access" {
        return Err(AppError::InvalidTokenType);
    }

    Ok(token_data.claims)
}

パスワードハッシュ

bcrypt クレートを使用します。bcrypt は USENIX 1999 の論文で発表された、アダプティブハッシュ関数です。

use bcrypt::{hash, verify, DEFAULT_COST};

pub fn hash_password(password: &str) -> Result<String, AppError> {
    hash(password, DEFAULT_COST)
        .map_err(|e| AppError::HashError(e.to_string()))
}

pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
    verify(password, hash)
        .map_err(|e| AppError::HashError(e.to_string()))
}

OWASP Password Storage Cheat Sheet では、bcrypt、Argon2、scrypt が推奨されています。

Refresh Token のハッシュ保存

Refresh Token はデータベースに保存しますが、平文ではなくハッシュ化して保存します。

use sha2::{Sha256, Digest};

pub fn hash_refresh_token(token: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(token.as_bytes());
    format!("{:x}", hasher.finalize())
}

JWT シークレットの強度検証

アプリケーション起動時にシークレットの強度を検証します。

const MIN_SECRET_LENGTH: usize = 64;
const MIN_UNIQUE_CHARS: usize = 20;

pub fn validate_jwt_secrets() -> Result<(), AppError> {
    let access_secret = std::env::var("ACCESS_TOKEN_SECRET")?;
    let refresh_secret = std::env::var("REFRESH_TOKEN_SECRET")?;

    validate_secret_strength(&access_secret, "ACCESS_TOKEN_SECRET")?;
    validate_secret_strength(&refresh_secret, "REFRESH_TOKEN_SECRET")?;

    Ok(())
}

fn validate_secret_strength(secret: &str, name: &str) -> Result<(), AppError> {
    // 長さチェック(512bit = 64文字以上)
    if secret.len() < MIN_SECRET_LENGTH {
        return Err(AppError::WeakSecret(
            format!("{} は最低 {} 文字以上必要です", name, MIN_SECRET_LENGTH)
        ));
    }

    // ユニーク文字数チェック(繰り返しパターン防止)
    let unique_chars: std::collections::HashSet<char> = secret.chars().collect();
    if unique_chars.len() < MIN_UNIQUE_CHARS {
        return Err(AppError::WeakSecret(
            format!("{} のエントロピーが不足しています", name)
        ));
    }

    Ok(())
}

監査ログ

セキュリティイベントを記録することで、インシデント発生時の調査が可能になります。

pub async fn log_audit_event(
    pool: &PgPool,
    user_id: Uuid,
    event_type: &str,        // "login", "logout", "login_failed" など
    ip_address: Option<String>,
    user_agent: Option<String>,
    metadata: Option<serde_json::Value>,
) -> Result<(), AppError> {
    sqlx::query(
        r#"
        INSERT INTO audit_logs (user_id, event_type, ip_address, user_agent, metadata)
        VALUES ($1, $2, $3, $4, $5)
        "#
    )
    .bind(user_id)
    .bind(event_type)
    .bind(ip_address)
    .bind(user_agent)
    .bind(metadata)
    .execute(pool)
    .await?;

    Ok(())
}

データベーススキーマ例

-- ユーザーテーブル
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email TEXT UNIQUE NOT NULL,
    password_hash TEXT NOT NULL,
    failed_login_attempts INTEGER DEFAULT 0,
    locked_until TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- リフレッシュトークンテーブル
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 VARCHAR(255) NOT NULL,  -- SHA256 ハッシュ
    expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
    revoked BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- 監査ログテーブル
CREATE TABLE audit_logs (
    id SERIAL PRIMARY KEY,
    user_id UUID REFERENCES users(id),
    event_type TEXT NOT NULL,
    ip_address INET,
    user_agent TEXT,
    metadata JSONB,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- インデックス
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_event_type ON audit_logs(event_type);

セキュリティチェックリスト

実装時に確認すべき項目:

認証・認可

攻撃対策

監視・ログ


本番環境で追加検討すべき対策

この記事で紹介した実装は基本パターンです。本番環境では以下の追加対策を検討してください。

2要素認証(2FA/MFA)

OWASP Multi-Factor Authentication Cheat Sheet を参照してください。TOTP(Time-based One-Time Password)や WebAuthn の導入を検討してください。

レート制限の強化

Redis 等を使用した分散レート制限を実装し、DDoS 攻撃に対する耐性を向上させてください。

セッション管理 UI

ユーザーが「他のデバイスからログアウト」できる機能を提供することで、デバイス紛失時のリスクを軽減できます。

異常検知

以下のような異常を検知してアラートを発行する仕組みを検討してください:

セキュリティヘッダー

OWASP Secure Headers Project を参照し、適切なセキュリティヘッダーを設定してください。

おわりに

この記事は、私自身の備忘録です。

以下の参考文献が非常に役に立ちました。


参考文献

RFC・標準仕様

OWASP

MDN Web Docs

Rust クレート