Auth Bypass in Axum with Postgresql
Auth Bypass in Axum with Postgresql — how this specific combination creates or exposes the vulnerability
Auth bypass occurs when an attacker accesses protected endpoints without valid authentication. In an Axum application using Postgresql as the identity store, the risk typically arises from improper session handling, missing authorization checks, or unsafe deserialization of user-supplied tokens. Even when routes are annotated with guards, developers may inadvertently allow access when database queries return unexpected rows or when error paths leak information that can be exploited.
Consider an Axum handler that retrieves a user by email from Postgresql and creates a session token without verifying role or revocation status:
// Example of a vulnerable Axum handler with Postgresql
async fn login(
pool: web::Data,
form: web::Form,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let row = sqlx::query(
"SELECT id, password_hash, is_active FROM users WHERE email = $1"
)
.bind(&form.email)
.fetch_optional(&pool)
.await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db error".to_string()))?;
let Some(row) = row else {
return Err((StatusCode::UNAUTHORIZED, "invalid credentials".to_string()));
};
// Vulnerable: no check for is_active or role; weak token generation
let token = encode(&Claims::new(row.get(0)));
Ok(Json(LoginResponse { token }))
}
If the is_active column is not enforced at the query or application layer, an attacker who compromises an inactive or suspended account may still authenticate. Additionally, if the token is accepted without validating scope or session revocation state in Postgresql, an attacker can reuse tokens after password reset or account disablement. This becomes an auth bypass when protected routes rely solely on token presence instead of verifying the latest state from Postgresql.
Another common pattern is deserializing a JWT and trusting claims without rechecking user status in Postgresql. For example:
// JWT validated but user state not rechecked in Postgresql
async fn protected_route(
token: String,
pool: web::Data<Pool>
) -> Result<impl IntoResponse, (StatusCode, String)> {
let claims = decode_verify(&token).map_err(|_| ...)?;
// Missing: verify that the user still exists and is_active = true in Postgresql
let user_id: i32 = claims.sub.parse().unwrap();
// Proceed with user_id assuming authentication is sufficient
...
}
In this scenario, an attacker who obtained a token before deactivation can bypass controls because the handler does not revalidate the user’s active status against Postgresql on every request. The combination of Axum’s flexible guard system and Postgresql’s role as the source of truth becomes a vulnerability when the application skips revalidation or does not enforce row-level policies in queries.
BOLA/IDOR can also emerge when object-level references (e.g., user_id) are taken from the token and used directly in Postgresql queries without confirming that the requesting subject owns the resource. If the authorization check is omitted, an attacker can change the ID in the request and access other users’ data.
Postgresql-Specific Remediation in Axum — concrete code fixes
Remediation focuses on strict revalidation against Postgresql, enforcing row ownership, and using parameterized queries to prevent injection and data leaks. Always recheck critical attributes (active, roles, consent) on each request or use short-lived tokens combined with state stored in Postgresql.
1) Enforce active status and roles in the login query:
// Secure login with Postgresql state validation
async fn login(
pool: web::Data<Pool>,
form: web::Form<LoginInput>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let row = sqlx::query(
"SELECT id, password_hash, role FROM users WHERE email = $1 AND is_active = TRUE"
)
.bind(&form.email)
.fetch_optional(&pool)
.await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db error".to_string()))?;
let Some(row) = row else {
return Err((StatusCode::UNAUTHORIZED, "invalid credentials".to_string()));
};
// Verify password, then issue token with minimal claims
if !verify_password(&form.password, &row.get::<_, String>("password_hash")) {
return Err((StatusCode::UNAUTHORIZED, "invalid credentials".to_string()));
}
let token = encode(&Claims::new_with_role(row.get(0), row.get(1)));
Ok(Json(LoginResponse { token }))
}
2) Re-validate user state on each protected request and enforce ownership:
// Example of Postgresql revalidation and BOLA prevention in Axum
async fn get_user_data(
user_id: i32,
token_claims: Claims,
pool: web::Data<Pool>
) -> Result<Json<UserData>, (StatusCode, String)> {
// Ensure the requesting user matches the resource owner
let row = sqlx::query(
"SELECT id, email, name FROM users WHERE id = $1 AND id = $2 AND is_active = TRUE"
)
.bind(token_claims.sub.parse().unwrap_or(0))
.bind(user_id)
.fetch_optional(&pool)
.await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db error".to_string()))?;
let row = row.ok_or((StatusCode::FORBIDDEN, "access denied".to_string()))?;
Ok(Json(UserData {
id: row.get(0),
email: row.get(1),
name: row.get(2),
}))
}
3) Use parameterized queries and avoid dynamic SQL to prevent injection and ensure consistent execution plans:
// Safe Postgresql usage in Axum
async fn search_profiles(
query: web::Query<SearchParams>,
pool: web::Data<Pool>
) -> Result<Json<Vec<Profile>>, (StatusCode, String)> {
let rows = sqlx::query(
"SELECT id, display_name FROM profiles WHERE user_id = $1 AND status = $2"
)
.bind(query.user_id)
.bind(&query.status)
.fetch_all(&pool)
.await
.map_err(|e| {
// Log without exposing details
(StatusCode::INTERNAL_SERVER_ERROR, "data unavailable".to_string())
})?;
Ok(Json(
rows.iter().map(|r| Profile {
id: r.get(0),
display_name: r.get(1),
}).collect()
))
}
4) Store revocation state in Postgresql and check it on token use:
// Example revocation check
async fn verify_token_with_revocation(
token: &str,
pool: web::Data<Pool>
) -> Result<Claims, (StatusCode, String)> {
let (header, claims, signature) = decode_secret(token).map_err(|_| ...)?;
let jti: String = claims.get_jti().ok_or(...)?;
let revoked: bool = sqlx::query_scalar(
"SELECT revoked FROM auth_revocation WHERE jti = $1"
)
.bind(jti)
.fetch_one(&pool)
.await
.unwrap_or(false);
if revoked {
return Err((StatusCode::UNAUTHORIZED, "token revoked".to_string()));
}
Ok(claims)
}
These patterns ensure that Axum routes place explicit trust in Postgresql for identity and authorization, reducing the chance of auth bypass through stale or incorrect application state.
Related CWEs: authentication
| CWE ID | Name | Severity |
|---|---|---|
| CWE-287 | Improper Authentication | CRITICAL |
| CWE-306 | Missing Authentication for Critical Function | CRITICAL |
| CWE-307 | Brute Force | HIGH |
| CWE-308 | Single-Factor Authentication | MEDIUM |
| CWE-309 | Use of Password System for Primary Authentication | MEDIUM |
| CWE-347 | Improper Verification of Cryptographic Signature | HIGH |
| CWE-384 | Session Fixation | HIGH |
| CWE-521 | Weak Password Requirements | MEDIUM |
| CWE-613 | Insufficient Session Expiration | MEDIUM |
| CWE-640 | Weak Password Recovery | HIGH |