CSRF & Security Hardening
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against tower-http 0.7.1 with axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06. Rust removes whole classes of vulnerabilities — buffer overflows, use-after-free, data races — but not the web’s logic-level ones: CSRF, XSS, broken access control, injection through string-built queries, leaked secrets. This page collects the controls Bookshelf applies on top of the authentication and authorization pages.
CSRF with CsrfLayer (new in tower-http 0.7)
A cross-site request forgery makes a victim’s browser send a state-changing request — with the victim’s cookies — from a page the attacker controls. SameSite=Lax session cookies already stop most of it (see
Sessions & Cookies), but not same-site attacks from a sibling subdomain
or older browsers. tower-http 0.7’s CsrfLayer adds the token-less defence Go 1.25 introduced, based on headers the
browser sets and scripts cannot forge:
-
safe methods (
GET,HEAD,OPTIONS) always pass — so they must never change state; -
a request whose
Originis on the trusted list passes; -
Sec-Fetch-Site: same-originornone(typed URL, bookmark) passes; -
a request with neither
Sec-Fetch-SitenorOriginpasses (not a browser: curl, server-to-server); -
a request whose
Originhost matches itsHostpasses (older browsers without Fetch Metadata); -
everything else is rejected with
403 Forbidden.
use axum::{
Form, Router,
http::{HeaderName, HeaderValue, header},
response::Html,
routing::{get, post},
};
use tower::ServiceBuilder;
use tower_http::{
csrf::CsrfLayer,
sensitive_headers::SetSensitiveHeadersLayer,
set_header::SetResponseHeaderLayer,
};
#[derive(serde::Deserialize)]
struct Review {
text: String,
}
async fn page() -> Html<&'static str> {
Html("<form method=post action=/reviews><textarea name=text></textarea><button>Send</button></form>")
}
async fn save(Form(r): Form<Review>) -> String {
format!("saved {} bytes\n", r.text.len())
}
#[tokio::main]
async fn main() {
let csrf = CsrfLayer::new().add_trusted_origin("https://bookshelf.example").expect("valid origin");
let app = Router::new()
.route("/reviews/new", get(page))
.route("/reviews", post(save))
.layer(csrf)
.layer(
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::overriding(
header::STRICT_TRANSPORT_SECURITY,
HeaderValue::from_static("max-age=63072000; includeSubDomains"),
))
.layer(SetResponseHeaderLayer::if_not_present(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; \
object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
),
))
.layer(SetResponseHeaderLayer::overriding(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")))
.layer(SetResponseHeaderLayer::overriding(
header::REFERRER_POLICY,
HeaderValue::from_static("strict-origin-when-cross-origin"),
)),
)
.layer(SetSensitiveHeadersLayer::new([
header::AUTHORIZATION,
header::COOKIE,
header::SET_COOKIE,
HeaderName::from_static("x-api-key"),
]));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: tower-http: csrf and set_header
$ curl -s -o /dev/null -w '%{http_code}\n' -d 'text=hi' -H 'sec-fetch-site: cross-site' \
-H 'origin: https://evil.example' localhost:3000/reviews
403
$ curl -s -d 'text=hi' -H 'sec-fetch-site: same-origin' localhost:3000/reviews
saved 2 bytes
$ curl -sI localhost:3000/reviews/new | grep -i -E 'strict-transport|content-security|nosniff|referrer'
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
content-security-policy: default-src 'self'; script-src 'self'; ...
strict-transport-security: max-age=63072000; includeSubDomains
Source: MDN: Sec-Fetch-Site and OWASP HTTP Headers Cheat Sheet
A reverse proxy that rewrites Host or strips Origin
silently weakens CsrfLayer (see TLS & HTTPS).
Token-based CSRF protection
Where you cannot rely on Fetch Metadata — a JSON API used from other origins with cookies, or requirements that
mandate tokens — use the synchronizer token pattern: store a random token in the session, render it into every form
as a hidden field (and into a <meta> tag for htmx/JavaScript), and reject unsafe requests whose submitted token does
not match, comparing in constant time. APIs authenticated with an Authorization header instead of cookies are not
vulnerable to CSRF at all, because browsers never add that header automatically. The htmx variant is on
htmx.
Security headers
| Header | Effect |
|---|---|
|
browsers use HTTPS only for this host for |
|
the strongest XSS mitigation: only scripts, styles and images from the listed sources run;
|
|
browsers trust the declared |
|
limits how much of the URL leaks to other sites in |
|
disables browser features the site does not use (camera, geolocation) |
|
on pages with personal data or tokens |
if_not_present lets a handler override the default (a page that needs a different CSP), while overriding enforces
the value.
Keep secrets out of logs
SetSensitiveHeadersLayer marks headers as sensitive so TraceLayer, Debug output and the http crate’s
formatting print Sensitive instead of the value. Apply it outermost so nothing logs before it runs. The same
discipline applies to your types: implement Debug by hand (or use a Secret<String> wrapper such as the secrecy
crate) for anything holding passwords, tokens or keys — see
Password Authentication.
Robustness controls
-
Body and time limits on every route (see Request Limits & Timeouts) and rate limits on login, registration and search (see Rate Limiting & Load Shedding).
-
CatchPanicLayerso a bug answers500instead of dropping the connection (see Error Handling). -
Error responses without internals: no SQL, paths or stack traces in
5xxbodies.
Output encoding and injection
-
XSS: askama and minijinja escape HTML in
{{ value }}by default, maud escapes all interpolations; only the explicitsafe/PreEscapedescapes bypass it (see Server-side Templates). Never build HTML withformat!from user input, and remember that escaping for HTML is not escaping for JavaScript, URLs or CSS contexts. -
SQL injection: always bind parameters (
$1with sqlx, the query builders of SeaORM and Diesel). sqlx 0.9’sSqlSafeStrmakes string-built SQL an explicit, reviewableAssertSqlSafe(see Databases with sqlx). -
Path traversal: never join user input into file paths;
ServeDirrejects..segments, and uploads are stored under generated names (see File Uploads). -
SSRF: outgoing HTTP clients must not follow redirects or fetch user-supplied URLs without an allow-list (see HTTP Clients & Proxies).
Dependency auditing
Most code in a Rust service is dependencies. Two tools check them in CI:
$ cargo install cargo-audit cargo-deny
$ cargo audit # RustSec advisory database: known vulnerabilities and unmaintained crates
$ cargo deny init # writes deny.toml
$ cargo deny check # advisories + licenses + banned crates + duplicate versions + allowed sources
Source: cargo-audit and cargo-deny book
cargo deny check bans also reports duplicated crates such as the two tower-http or tower-sessions versions discussed
on earlier pages. Commit Cargo.lock for applications, and update dependencies regularly (cargo update, Dependabot or
Renovate).
Authenticating browser WebSockets
Browsers' WebSocket API cannot set an Authorization header (or any custom header) on the upgrade request. Options:
-
rely on the session cookie, which the browser sends with the upgrade request — and then check the
Originheader in the upgrade handler, because WebSockets are not covered by CORS orSameSite=Laxin the same way; -
issue a short-lived, single-use ticket from an authenticated HTTP endpoint and pass it in the WebSocket URL’s query string, validating and consuming it during the upgrade;
-
never put long-lived tokens in URLs: they end up in proxy and server logs.
The implementation is on WebSockets.
Detecting authentication abuse and data exfiltration
Preventive controls fail eventually; the OWASP Top 10:2025 lists Security Logging and Alerting Failures as its own category because breaches are mostly discovered late. Two families of attacks are cheap to detect in an axum service, because they leave a statistical trace in requests the application already handles:
-
Credential stuffing replays username/password pairs leaked elsewhere. Signals: one source (IP address, ASN, TLS fingerprint) trying many different accounts; a login success rate across the whole service that drops sharply; a burst of successful logins from addresses never seen for those accounts.
-
Brute force guesses one account’s password. Signals: many failures for one account, often from many addresses to get around per-IP rate limits.
-
IDOR / BOLA (insecure direct object reference, broken object-level authorization — the first item of the OWASP API Security Top 10) shows up as a user requesting objects that belong to other users, typically by walking ids (
/api/v1/loans/1000,/1001, …): a run of403/404responses on one resource type, or an unusual number of distinct foreign objects read successfully — which is what data exfiltration through a missing ownership check looks like.
The example keeps per-key state in moka caches with a 10-minute time-to-live, increments Prometheus counters that
have no per-user labels (cardinality), and writes a structured security event only when a threshold is crossed.
Usernames are hashed before they are stored:
use std::collections::HashSet;
use std::hash::{BuildHasher, RandomState};
use std::net::IpAddr;
use std::sync::{
Arc, Mutex,
atomic::{AtomicU32, Ordering},
};
use std::time::Duration;
use metrics::counter;
use moka::future::Cache;
use tracing::warn;
/// Thresholds per 10-minute window; tune them from your own traffic.
const DISTINCT_ACCOUNTS_PER_IP: usize = 20; // credential stuffing: one source, many accounts
const FAILURES_PER_ACCOUNT: u32 = 10; // brute force: one account, many guesses
const FOREIGN_OBJECTS_PER_USER: usize = 50; // enumeration/exfiltration: one user, many others' objects
/// In-process abuse signals. Counters go to metrics *without* per-user labels; the per-key
/// state lives in TTL caches, and only threshold crossings are logged, as security events.
#[derive(Clone)]
struct SecuritySignals {
accounts_per_ip: Cache<IpAddr, Arc<Mutex<HashSet<u64>>>>,
failures_per_account: Cache<u64, Arc<AtomicU32>>,
foreign_objects_per_user: Cache<i64, Arc<Mutex<HashSet<i64>>>>,
hasher: RandomState, // usernames are hashed: the detector never stores them
}
impl SecuritySignals {
fn new() -> Self {
let window = Duration::from_secs(600);
Self {
accounts_per_ip: Cache::builder().max_capacity(100_000).time_to_live(window).build(),
failures_per_account: Cache::builder().max_capacity(100_000).time_to_live(window).build(),
foreign_objects_per_user: Cache::builder().max_capacity(100_000).time_to_live(window).build(),
hasher: RandomState::new(),
}
}
/// Call from the login handler after every attempt.
async fn login_attempt(&self, ip: IpAddr, username: &str, success: bool) {
let outcome = if success { "success" } else { "failure" };
counter!("auth_login_attempts_total", "outcome" => outcome).increment(1);
let account = self.hasher.hash_one(username);
let accounts = self.accounts_per_ip.get_with(ip, async { Arc::default() }).await;
let distinct = {
let mut set = accounts.lock().expect("poisoned");
set.insert(account);
set.len()
};
if distinct == DISTINCT_ACCOUNTS_PER_IP {
counter!("security_events_total", "kind" => "credential_stuffing").increment(1);
warn!(target: "security", event = "credential_stuffing_suspected", %ip, distinct_accounts = distinct);
}
if !success {
let failures = self.failures_per_account.get_with(account, async { Arc::default() }).await;
if failures.fetch_add(1, Ordering::Relaxed) + 1 == FAILURES_PER_ACCOUNT {
counter!("security_events_total", "kind" => "brute_force").increment(1);
warn!(target: "security", event = "brute_force_suspected", account = account, %ip);
}
}
}
/// Call wherever an object is loaded on behalf of a user (a loan, a reading list, an export).
async fn object_access(&self, user_id: i64, owner_id: i64, object_id: i64, allowed: bool) {
if !allowed {
// a denied access to someone else's object is the classic IDOR/BOLA probe
counter!("authz_denied_total", "resource" => "loan").increment(1);
warn!(target: "security", event = "access_denied", user_id, object_id);
}
if owner_id == user_id {
return;
}
// allowed or not: how many *other people's* objects has this user touched recently?
let seen = self.foreign_objects_per_user.get_with(user_id, async { Arc::default() }).await;
let distinct = {
let mut set = seen.lock().expect("poisoned");
set.insert(object_id);
set.len()
};
if distinct == FOREIGN_OBJECTS_PER_USER {
counter!("security_events_total", "kind" => "bulk_foreign_access").increment(1);
warn!(target: "security", event = "bulk_foreign_access_suspected", user_id, distinct_objects = distinct);
}
}
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt().with_target(true).without_time().init();
let signals = SecuritySignals::new();
// one address trying a leaked list of username/password pairs
let stuffer: IpAddr = "203.0.113.7".parse().unwrap();
for n in 0..25 {
signals.login_attempt(stuffer, &format!("member{n}@example.com"), false).await;
}
// many addresses guessing one account's password
for n in 0..12u8 {
signals.login_attempt(IpAddr::from([198, 51, 100, n]), "ursula@bookshelf.example", false).await;
}
// a librarian (allowed) or an attacker walking loan ids: /api/v1/loans/1000, /1001, ...
for loan_id in 1000..1060 {
signals.object_access(7, 42, loan_id, loan_id % 2 == 0).await;
}
}
$ NO_COLOR=1 cargo run | grep -v access_denied
WARN security: event="credential_stuffing_suspected" ip=203.0.113.7 distinct_accounts=20
WARN security: event="brute_force_suspected" account=12092160862863416353 ip=198.51.100.9
WARN security: event="bulk_foreign_access_suspected" user_id=7 distinct_objects=50
Source: tracing: warn!
Across replicas, the per-instance caches only see part of the traffic. The aggregate counters are where alerting
belongs; Prometheus rules over them catch service-wide patterns, and the security log events (shipped to the SIEM or
log store) carry the details for investigation:
groups:
- name: bookshelf-security
rules:
- alert: LoginFailureRatioHigh # stuffing across many sources
expr: |
sum(rate(auth_login_attempts_total{outcome="failure"}[10m]))
/ sum(rate(auth_login_attempts_total[10m])) > 0.5
and sum(rate(auth_login_attempts_total[10m])) > 1
for: 10m
labels: {severity: page}
- alert: SecurityEventsDetected # thresholds crossed in any replica
expr: sum by (kind) (increase(security_events_total[15m])) > 0
labels: {severity: ticket}
- alert: AccessDeniedSpike # IDOR/BOLA probing
expr: sum(rate(authz_denied_total[5m])) > 3 * sum(rate(authz_denied_total[1d] offset 1d))
for: 15m
labels: {severity: ticket}
Source: Prometheus: Alerting rules
-
Respond in proportion: a suspected stuffing source gets a CAPTCHA or a tighter rate limit (see Rate Limiting & Load Shedding); a brute-forced account gets a notification and step-up authentication rather than a hard lock-out, which attackers can use to lock users out.
-
Breached-password checks at registration and password change (the k-anonymity range API of Have I Been Pwned) remove most of what stuffing relies on; multi-factor authentication removes the rest.
-
Detection does not replace the ownership check itself: every query for a user-owned object filters by owner (see Authorization). Prefer
404to403for objects the user may not know exist. -
Log security events with the request id and trace id (see Logging & Tracing) but without passwords, tokens or full usernames, and keep them long enough for an investigation.
OWASP Top 10:2025 mapping
The table maps the controls on these pages to the 2025 edition of the OWASP Top 10 (OWASP Top 10:2025), which replaces the 2021 list: it adds Software Supply Chain Failures and Mishandling of Exceptional Conditions, and folds server-side request forgery into broken access control.
| Risk (2025) | Where this section addresses it |
|---|---|
A01 Broken Access Control |
Authorization (role extractors, ownership-scoped
queries), |
A02 Security Misconfiguration |
security headers, no |
A03 Software Supply Chain Failures |
|
A04 Cryptographic Failures |
TLS everywhere (TLS & HTTPS), HSTS, Argon2id, keys from a secret store |
A05 Injection |
bound SQL parameters, |
A06 Insecure Design |
explicit |
A07 Authentication Failures |
sessions with id rotation, JWT validation, generic login errors, abuse detection (above) |
A08 Software or Data Integrity Failures |
signed/private cookies, verified token signatures, reproducible builds |
A09 Security Logging and Alerting Failures |
Logging & Tracing, sensitive headers, security events and alerts (above) |
A10 Mishandling of Exceptional Conditions |
|
Where the books are outdated
Yoon (Beginning Axum, chapter 5) authenticates WebSockets with an Authorization header, which works from command-line
clients but not from browsers — use cookies or a ticket as above. tower-http 0.6 (used by Yoon and by Gangappa &
Rangaraj) had no CSRF layer; CsrfLayer arrived in 0.7.