HIGH mass assignmentaxumjwt tokens

Mass Assignment in Axum with Jwt Tokens

Mass Assignment in Axum with Jwt Tokens — how this specific combination creates or exposes the vulnerability

Mass assignment occurs when an API binds untrusted user input directly to data models or structs without explicit field filtering. In Axum, this commonly happens when developers use framework features like Form<T>, Json<T>, or custom extractors to deserialize request bodies into Rust structs. If the struct contains sensitive fields such as is_admin, user_id, or permissions, and the developer does not explicitly ignore or validate those fields, an attacker can supply them in the request to escalate privileges or alter behavior.

When JWT tokens are involved, the risk pattern shifts to the token claims and how they are merged with request-bound data. A typical Axum handler might decode a JWT to obtain a Claims struct containing a user ID and role, then bind additional JSON body fields to a separate resource struct (for example, updating a profile or creating a record). If the resource struct is populated from both the decoded JWT-derived data and the request body via automatic deserialization, an attacker can supply extra keys in the JSON payload to overwrite authorized values, such as changing role or tenant_id. This combination of unchecked deserialization and JWT-derived data creates a privilege escalation path that resembles BOLA/IDOR when the token identifies a user but the handler applies insufficient checks on incoming fields.

Another exposure scenario involves logging or error handling that inadvertently reflects token claims alongside user-controlled input. If a handler deserializes a request into a broad struct and passes it to internal logic that also reads JWT claims, an attacker can probe which field names are accepted by the model, effectively mapping the API’s mass assignment surface. Because Axum does not inherently restrict which fields can be bound, the framework will happily populate any struct field for which a matching key exists in the JSON or form data. This makes it essential to explicitly annotate fields with #[serde(skip_deserializing)] or to use dedicated update structs that omit sensitive or privileged attributes.

Real-world attack patterns include an authenticated request with a JWT for a standard user, paired with a JSON body containing {"role": "admin", "user_id": 999}. If the handler uses a single struct for both binding and JWT claims merging, the attacker may gain elevated permissions or impersonate another user. Compounded by weak or missing authorization checks on the updated resource, such a flaw can lead to unauthorized data modification or exposure, aligning with common findings in OWASP API Top 10 around Broken Object Level Authorization.

Developers should treat JWT tokens as one source of identity and never as a substitute for explicit input validation. In Axum, this means designing handler signatures that separate claims from payload, using strict DTOs for mutations, and ensuring that any deserialization step excludes fields that should be server-controlled. Security tests should include attempts to inject unexpected keys into request bodies while using valid and invalid tokens to verify that sensitive attributes cannot be overridden by the client.

Jwt Tokens-Specific Remediation in Axum — concrete code fixes

To mitigate mass assignment in Axum when using JWT tokens, adopt explicit structs for request payloads and apply #[serde] attributes to control deserialization. Keep JWT-derived claims separate from user-supplied input, and never bind request bodies directly to models that contain privileged fields.

use axum::{{
    extract::{Extension, Json},
    routing::post,
    Router,
}};
use serde::{Deserialize, Serialize};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation, TokenData};

#[derive(Debug, Deserialize)]
struct AuthClaims {
    sub: String,
    role: String,
    // Do not include mutable resource fields here
}

#[derive(Debug, Deserialize)]
struct UpdateProfile {
    email: String,
    // Explicitly skip fields that should not be user-set
    #[serde(skip_deserializing)]
    user_id: i64,
    #[serde(skip_deserializing)]
    role: String,
}

#[derive(Debug, Serialize)]
struct ProfileResponse {
    user_id: i64,
    email: String,
    role: String,
}

async fn handle_update_profile(
    TokenData(claims): TokenData<AuthClaims>,
    Json(payload): Json<UpdateProfile>,
) -> ProfileResponse {
    // Use claims.sub or claims.role for authorization, not payload.role
    ProfileResponse {
        user_id: claims.sub.parse().unwrap_or_default(),
        email: payload.email,
        role: claims.role,
    }
}

fn validate_token(token: &str) -> TokenData<AuthClaims> {
    let key = DecodingKey::from_secret(b"secret");
    let validation = Validation::new(Algorithm::HS256);
    decode::<AuthClaims>(token, &key, &validation).expect("Invalid token")
}

// In your router setup, integrate the extractor and token validation as needed
"

Always define update or creation structs that omit sensitive fields, and use #[serde(skip_deserializing)] for server-controlled attributes such as IDs, roles, or permissions. This ensures that even if an attacker includes these keys in the JSON body, the deserializer will ignore them.

#[derive(Debug, Deserialize)]
struct CreateOrder {
    product_id: String,
    quantity: u32,
    // Do not expose pricing or administrative flags
    #[serde(skip_deserializing)]
    price_cents: u64,
    #[serde(skip_deserializing)]
    is_promotion: bool,
}

async fn create_order(Json(payload): Json<CreateOrder>) -> String {
    // Server assigns pricing and promotion logic
    format!("Order for {}", payload.product_id)
}

Where JWT claims influence authorization, perform explicit checks rather than relying on merged data structures. For example, validate that the user role from the token has permission to modify the target resource, and do not allow the request body to alter role or ownership fields.

async fn promote_user(
    TokenData(claims): TokenData<AuthClaims>,
    Json(payload): Json<UpdateRole>,
) -> String {
    // Ensure the requester is authorized to promote
    if claims.role != "admin" {
        return "Forbidden";
    }
    // Do not use payload.role directly; assign based on admin decision logic
    format!("Promotion processed for user_id: {}", payload.target_user_id)
}

#[derive(Debug, Deserialize)]
struct UpdateRole {
    target_user_id: i64,
    // Do not include role here; derive from admin logic
}

Complement these practices with runtime tests that attempt mass assignment by injecting unexpected keys into JSON payloads while using valid and invalid tokens. This confirms that server-controlled fields are ignored and that authorization is consistently enforced.

Related CWEs: propertyAuthorization

CWE IDNameSeverity
CWE-915Mass Assignment HIGH

Frequently Asked Questions

Why does separating JWT claims from request body help prevent mass assignment in Axum?
Keeping claims separate ensures that user-controlled JSON cannot overwrite server-derived identity or privilege data. By using distinct structs and skipping deserialization for sensitive fields, you eliminate accidental binding of attacker-supplied values to privileged attributes.
Can I rely on default Rust struct deserialization safety to prevent mass assignment in Axum?
No. Rust's serde will deserialize any matching key present in the input into the corresponding field. You must explicitly exclude sensitive fields with attributes like #[serde(skip_deserializing)] and design narrow DTOs for mutations to avoid unintended overwrites.