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:

  1. safe methods (GET, HEAD, OPTIONS) always pass — so they must never change state;

  2. a request whose Origin is on the trusted list passes;

  3. Sec-Fetch-Site: same-origin or none (typed URL, bookmark) passes;

  4. a request with neither Sec-Fetch-Site nor Origin passes (not a browser: curl, server-to-server);

  5. a request whose Origin host matches its Host passes (older browsers without Fetch Metadata);

  6. 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();
}
$ 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

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

Strict-Transport-Security

browsers use HTTPS only for this host for max-age seconds; add preload only when every subdomain is HTTPS

Content-Security-Policy

the strongest XSS mitigation: only scripts, styles and images from the listed sources run; frame-ancestors 'none' replaces X-Frame-Options against clickjacking. Inline scripts need nonces or hashes

X-Content-Type-Options: nosniff

browsers trust the declared Content-Type instead of guessing (no script from a text/plain upload)

Referrer-Policy

limits how much of the URL leaks to other sites in Referer

Permissions-Policy

disables browser features the site does not use (camera, geolocation)

Cache-Control: no-store

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

Output encoding and injection

  • XSS: askama and minijinja escape HTML in {{ value }} by default, maud escapes all interpolations; only the explicit safe/PreEscaped escapes bypass it (see Server-side Templates). Never build HTML with format! from user input, and remember that escaping for HTML is not escaping for JavaScript, URLs or CSS contexts.

  • SQL injection: always bind parameters ($1 with sqlx, the query builders of SeaORM and Diesel). sqlx 0.9’s SqlSafeStr makes string-built SQL an explicit, reviewable AssertSqlSafe (see Databases with sqlx).

  • Path traversal: never join user input into file paths; ServeDir rejects .. 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

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 Origin header in the upgrade handler, because WebSockets are not covered by CORS or SameSite=Lax in 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 of 403/404 responses 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}
  • 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 404 to 403 for 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), route_layer guards, CsrfLayer; SSRF: no redirects on outgoing clients, URL allow-lists

A02 Security Misconfiguration

security headers, no CorsLayer::permissive(), no secrets in images, internal ports for /metrics and debug endpoints

A03 Software Supply Chain Failures

cargo audit, cargo deny (advisories, sources, duplicates), committed Cargo.lock, regular updates

A04 Cryptographic Failures

TLS everywhere (TLS & HTTPS), HSTS, Argon2id, keys from a secret store

A05 Injection

bound SQL parameters, SqlSafeStr, auto-escaping templates, CSP

A06 Insecure Design

explicit AppError mapping, rate limits, separate input and output types, generic registration responses

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

CatchPanicLayer, error responses without internals, fail closed on store or verification errors, timeouts and body limits

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.