Side Channel Attack in Axum with Jwt Tokens
Side Channel Attack in Axum with Jwt Tokens — how this variable-time behavior creates or exposes the vulnerability
A side channel in the context of Axum endpoints using JWT tokens arises when the server’s behavior or timing differs based on whether a token is valid, malformed, or missing, and those differences are observable over the network. For example, if signature verification, claims parsing, or permission checks are not performed in constant time, an attacker can infer validity or hierarchy by measuring response times. This can expose secrets such as issuer constraints or the presence of administrative claims without needing to break cryptography directly.
In Axum, a typical route might extract a JWT from an authorization header, verify it with a library such as jsonwebtoken or jwt-decode, and then decide how to proceed. If the verification path for a valid token takes measurably longer than the path for an invalid token—due to early returns, conditional branches, or data-dependent loops—an attacker can send many crafted requests and statistically infer whether a token is well-formed or which claims are present. Network timing, CPU load, and even the presence of certain claims (like scopes or roles) can become leakage channels when the application’s control flow depends on token content.
Consider an endpoint that decodes a JWT and checks for a specific scope before allowing a sensitive operation. If the scope check short-circuits on missing claims, or if the validation logic branches differently based on the presence of an admin claim, the timing difference can be measurable. Similarly, if the JWT library used in Axum performs signature validation with variable-time algorithms depending on key length or algorithm choice, the server may inadvertently signal information about the token structure through response latency. These timing correlations can be amplified in high-throughput services where an attacker can average many requests to reduce noise.
Another vector involves error handling and introspection. Returning distinct error messages for malformed tokens versus unauthorized claims can allow an attacker to iteratively refine a forged token. Even without explicit messages, the combination of HTTP status codes and response latency forms a side channel. For instance, a 401 for a bad signature versus a 403 for a valid but insufficient-scoped token provides confirmable feedback. When combined with timing, this can allow an attacker to map the decision graph of JWT validation in Axum purely by observing responses and response durations.
To map this to real-world concerns, consider how the validation routine is composed. A route like async fn handler(headers: Header<Authorization>) that pattern-matches on extraction results can produce divergent paths. If the route first checks header presence, then parses the token, then verifies claims, each early exit can create a distinguishable path. Attackers can exploit this by sending tokens that trigger different branches and observing timing and status behavior. This is especially relevant when the same endpoint serves both public and privileged operations, as the branching logic can encode sensitive information about token validity and permissions.
Defensive design in Axum should ensure that the path taken for any token—valid or invalid—executes the same high-level steps, such as a fixed sequence of parsing, verification, and authorization checks, without early branching on sensitive claims. Constant-time comparison for any cryptographic material, avoiding data-dependent loops, and standardizing error responses reduce the observable differences across paths. Instrumentation that measures latency variance across many requests can also help detect anomalous behavior that suggests a side channel is being probed.
Jwt Tokens-Specific Remediation in Axum — concrete code fixes
Remediation centers on making token validation and authorization paths independent of secret or sensitive data. In Axum, this means designing extractors and guards so that timing and branching do not reveal whether a JWT is valid, what claims it contains, or which scopes are present. Below are concrete, idiomatic examples that illustrate these principles.
Constant-time verification stub
Use a fixed-duration verification routine regardless of token validity. The idea is to perform a dummy verification pass that takes roughly the same CPU time as the real verification when the token is well-formed, masking timing differences.
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation, TokenData, Header};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
struct Claims {
sub: String,
scope: String,
exp: usize,
}
async fn verify_jwt_constant(token: &str, key: &[u8]) -> Result {
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = true;
// Always decode and validate with the same steps, even on malformed input
decode::(token, &DecodingKey::from_secret(key), &validation)
}
async fn dummy_verify(key: &[u8]) {
// Perform a lightweight, fixed-cost operation to consume comparable time
let _ = decode::(
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwic2NvcGUiOiJwcm9maWxlIiwiZXhwIjoxOTk5OTk5OTk5fQ.dummy",
&DecodingKey::from_secret(key),
&Validation::new(Algorithm::HS256),
);
}
async fn secure_handler(
headers: Option<headers::Authorization<headers::Bearer>>,
key: std::sync::Arc<[u8]>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let token = match headers {
Some(h) => h.token(),
None => return Err((StatusCode::UNAUTHORIZED, "Missing authorization".to_string())),
};
// Always run verification; avoid early returns based on claim content
let token_data = verify_jwt_constant(token, &key).await.map_err(|e| {
// Return a generic error to avoid signaling specific failure modes
dummy_verify(&key).await; // keep timing consistent
(StatusCode::UNAUTHORIZED, "Invalid token".to_string())
})?;
// Authorization checks after verification, using constant-time comparisons
let required = "admin";
let has_scope = subtle::ConstantTimeEq::ct_eq(token_data.claims.scope.as_bytes(), required.as_bytes()).into();
if !has_scope {
return Err((StatusCode::FORBIDDEN, "Insufficient scope".to_string()));
}
Ok(format!("Hello {}", token_data.claims.sub))
} Jwt Tokens-Specific Remediation in Axum — concrete code fixes (continued)
The following patterns further reduce side-channel leakage by standardizing responses and avoiding branching on sensitive data.
Standardized error responses and fixed-latency guards
Ensure that all failure modes return the same HTTP status and a constant-duration routine before responding, so timing does not indicate which step failed.
use axum::{routing::get, Router, http::StatusCode};
use tower_http::trace::TraceLayer;
use std::net::SocketAddr;
async fn admin_route(
claims: Claims,
) -> Result<String, (StatusCode, String)> {
// Perform authorization in a data-independent manner
let is_admin = subtle::ConstantTimeEq::ct_eq(claims.scope.as_bytes(), b"admin").into();
if !is_admin {
// Use a fixed sleep to mask timing differences (example only; prefer work-equivalent dummy ops)
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
Ok("Admin access granted".to_string())
}
#[tokio::main]
async fn main() {
let key = std::sync::Arc::new([0u8; 32]);
let app = Router::new()
.route("/admin", get(move |headers: Option<headers::Authorization<headers::Bearer>>| async move {
let token = match headers {
Some(h) => h.token(),
None => return Err((StatusCode::UNAUTHORIZED, "Missing authorization".to_string())),
};
let token_data = verify_jwt_constant(token, &key).await.map_err(|_| {
dummy_verify(&key).await;
(StatusCode::UNAUTHORIZED, "Invalid token".to_string())
})?;
admin_route(token_data.claims).await
}))
.layer(TraceLayer::new_for_http());
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}
Avoid branching on claims during verification
Move claim checks outside the verification step and ensure that parsing and validation always follow the same code path. This prevents an attacker from inferring claim presence via timing or error differences.
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation, TokenData};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Claims {
sub: String,
scope: String,
exp: usize,
}
fn always_verify(token: &str, key: &[u8]) -> TokenData<Claims> {
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = true;
decode(token, &DecodingKey::from_secret(key), &validation)
.expect("Verification should not leak reasons for failure")
}
fn authorize_scope(claims: &Claims, required: &str) -> bool {
subtle::ConstantTimeEq::ct_eq(claims.scope.as_bytes(), required.as_bytes()).into()
}
// In route code:
// let token_data = always_verify(token, key.as_ref());
// if !authorize_scope(&token_data.claims, "admin") { ... }
Operational guidance
- Use constant-time comparison for any claim or key material.
- Avoid early returns that change code path length based on token content.
- Standardize error responses and consider a fixed-duration dummy verification step to mask timing variance.
- Instrument response time distributions in production to detect anomalous patterns that suggest probing.
These practices help ensure that JWT handling in Axum does not expose sensitive information through timing or error-based side channels.