Rust eliminates entire classes of memory safety vulnerabilities, but web application security is broader than memory safety. Cross-site scripting, SQL injection, missing security headers, and misconfigured policies are all possible in Rust applications. This section covers the web-specific security concerns that require explicit attention in the Axum/Maud/htmx/SQLx stack.
For CSRF protection, session cookie configuration, and rate limiting on authentication endpoints, see Authentication. For input validation and sanitisation, see Form Handling and Validation.
OWASP Top 10 in this stack
The OWASP Top 10 (2021) is the standard classification of web application security risks. Not all categories require equal attention in every stack. This table maps each category to its relevance in a Rust/Axum/Maud/SQLx application.
| # | Category | Relevance | Notes |
|---|---|---|---|
| A01 | Broken Access Control | High | Application logic. Rust provides no automatic protection. Every route needs explicit authorisation checks. |
| A02 | Cryptographic Failures | Medium | Depends on crate choices. Use audited crates (ring, rustls, argon2). Never roll custom cryptography. |
| A03 | Injection | Low | SQLx uses parameterized queries. Maud auto-escapes HTML. Both mitigate the primary injection vectors by default. Risk remains if you bypass either. |
| A04 | Insecure Design | Medium | Architecture-level concern. Applies equally to all stacks. Threat modelling and secure design reviews are the mitigations. |
| A05 | Security Misconfiguration | High | Missing security headers, permissive CORS, debug output in production, default session secrets. Requires explicit configuration. |
| A06 | Vulnerable and Outdated Components | Medium | Rust crates can have vulnerabilities. Run cargo audit in CI. Use cargo deny for policy enforcement. |
| A07 | Identification and Authentication Failures | Medium | Covered in Authentication: argon2 hashing, session management, rate limiting. |
| A08 | Software and Data Integrity Failures | Low | Cargo.lock pins exact dependency versions. Cargo verifies checksums. CI/CD pipeline security is the residual risk. |
| A09 | Security Logging and Monitoring Failures | Medium | Operational concern. Use the tracing crate for structured logging. |
| A10 | Server-Side Request Forgery | Low | Only relevant if your application makes outbound HTTP requests based on user-supplied URLs. Validate URL schemes and destinations if so. |
The categories that need the most attention in this stack are A01 (Broken Access Control) and A05 (Security Misconfiguration). The Rust type system and the crate ecosystem handle A03 (Injection) and A08 (Integrity) well by default, but only if you use them correctly.
XSS prevention with Maud
Maud’s html! macro HTML-entity-escapes all interpolated values by default. The characters <, >, &, ", and ' are converted to their entity equivalents. This prevents the most common XSS vector: injecting <script> tags through user input.
use maud::html;
// Safe: user_input is escaped automatically
html! {
p { (user_input) }
}
// If user_input = "<script>alert('xss')</script>"
// Renders: <p><script>alert('xss')</script></p>What bypasses escaping
PreEscaped() disables escaping for its argument. It exists for cases where you have trusted HTML that should be rendered as-is (content from a Markdown renderer, for example).
use maud::PreEscaped;
// DANGEROUS if content is untrusted:
html! {
(PreEscaped(user_provided_html))
}
Treat every use of PreEscaped as a security boundary. If the input is not fully trusted, do not use it. If you must render user-supplied rich text, sanitise it with a dedicated HTML sanitiser (such as ammonia) before passing it to PreEscaped.
Risks that remain with auto-escaping
Maud performs HTML-entity escaping only. It does not do context-aware escaping, which means certain attack vectors survive even with escaping enabled.
javascript: URLs. If user input is used as an href or src value, HTML-entity escaping does not prevent javascript: scheme attacks because no < or > characters need escaping:
// VULNERABLE: user_url could be "javascript:alert(1)"
html! {
a href=(user_url) { "Click here" }
}
Validate URLs on the server before rendering them. Only allow http:// and https:// schemes:
fn is_safe_url(url: &str) -> bool {
url.starts_with("https://") || url.starts_with("http://") || url.starts_with("/")
}
CSS injection in style attributes. Setting a style attribute from user input can enable CSS-based attacks (data exfiltration via background-image URLs, UI redressing) even with HTML escaping. Do not interpolate user input into style attributes. Use CSS classes instead.
<script> and <style> element bodies. The HTML specification does not process entity escapes inside <script> and <style> elements. Maud will escape the content, which either mangles valid JavaScript/CSS or requires PreEscaped to work correctly. Never interpolate user input into <script> or <style> blocks. Pass data to JavaScript via data- attributes on HTML elements, where Maud’s escaping is effective.
Maud’s structured syntax makes it difficult to accidentally construct event handler attributes like onclick from user input, unlike string-based template engines where concatenation errors can create attribute injection. This is a genuine safety advantage of the macro approach.
SQL injection prevention with SQLx
SQLx uses bind parameters for all user-provided values. The database driver sends the query structure and parameter values separately over the wire, making injection impossible at the protocol level.
Compile-time checked queries
The sqlx::query! and sqlx::query_as! macros accept only string literals. You cannot pass a String or the result of format!(). The macro verifies the query against a live database at compile time, checking column names, types, and placeholder counts. SQL injection is structurally impossible in compile-time checked queries because there is no way to interpolate user input into the query string.
// Safe: query is a string literal, user_id is a bind parameter
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", user_id)
.fetch_one(&pool)
.await?;Runtime queries
The sqlx::query() function accepts a &str query string. Parameters are bound via .bind() calls. The API deliberately accepts &str rather than String to create friction against format!() usage.
// Safe: parameterized query
let users = sqlx::query("SELECT * FROM users WHERE email = $1")
.bind(&email)
.fetch_all(&pool)
.await?;How to accidentally bypass parameterization
The primary risk is using format!() to build query strings:
// VULNERABLE: format! injects user input directly into the query string
let query = format!("SELECT * FROM users WHERE name = '{}'", user_input);
let result = sqlx::query(&query).fetch_all(&pool).await?;
This is the Rust equivalent of string concatenation in PHP or Python SQL. SQLx’s design discourages it (.query() takes &str, not String), but does not make it impossible.
Dynamic table or column names present a legitimate challenge because SQL bind parameters only work for values, not identifiers. If you need dynamic identifiers (user-selected sort column, for example), validate them against an allowlist:
fn validated_sort_column(input: &str) -> &str {
match input {
"name" | "email" | "created_at" => input,
_ => "created_at", // safe default
}
}
let order_by = validated_sort_column(¶ms.sort);
let query = format!("SELECT * FROM users ORDER BY {order_by}");
let users = sqlx::query(&query).fetch_all(&pool).await?;
The format!() here is safe because order_by can only be one of the validated values. The principle: bind parameters for values, allowlists for identifiers.
Content Security Policy
A Content Security Policy (CSP) header tells the browser which sources of content are permitted. A well-configured CSP is the strongest defence against XSS after output escaping, because it prevents the browser from executing injected scripts even if they make it into the HTML.
A baseline CSP for this stack
For an HDA application serving HTML from Axum, with htmx loaded from a same-origin file and CSS in external stylesheets:
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
form-action 'self';
frame-ancestors 'none';
base-uri 'self';
object-src 'none'
This policy allows scripts, styles, images, fonts, and connections only from the same origin. It blocks framing (frame-ancestors 'none'), restricts form targets (form-action 'self'), and disallows plugins (object-src 'none'). The data: source for images permits inline data URIs (common for small icons and placeholder images).
htmx and CSP complications
htmx creates three specific CSP challenges that you need to plan for.
Inline indicator styles. By default, htmx injects a <style> element into the page for its loading indicator CSS. This violates a style-src 'self' policy. Disable this by setting the includeIndicatorStyles configuration to false and providing the indicator CSS in your own stylesheet:
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
The indicator CSS you need to include in your own stylesheet:
.htmx-indicator {
opacity: 0;
transition: opacity 200ms ease-in;
}
.htmx-request .htmx-indicator {
opacity: 1;
}
.htmx-request.htmx-indicator {
opacity: 1;
}
hx-on:* attributes. htmx’s hx-on:* attributes (e.g., hx-on:click, hx-on:htmx:after-swap) are functionally equivalent to inline event handlers. They require 'unsafe-inline' in script-src, which defeats the purpose of CSP for script control. Avoid hx-on:* attributes entirely. Use hx-trigger with server-driven patterns instead, or attach event listeners in external JavaScript files.
Nonce propagation on AJAX responses. htmx automatically copies the nonce attribute from inline scripts it finds in AJAX responses. This is intended as a convenience but undermines the CSP nonce security model: if an attacker can inject a <script> tag into a server response (via stored XSS), htmx will propagate the nonce to it, and the browser will execute it. The defence is to not rely on nonces for htmx-loaded content. Instead, serve all JavaScript from external files (script-src 'self') and do not use inline scripts in htmx responses.
The practical approach
The combination of these constraints points to a clear policy:
- Serve all JavaScript from same-origin files. No inline scripts.
- Serve all CSS from same-origin stylesheets. Disable htmx’s built-in indicator styles.
- Do not use
hx-on:*attributes. - Use
script-src 'self'andstyle-src 'self'without nonces or'unsafe-inline'.
This approach is simpler and more secure than a nonce-based policy. The trade-off is that you cannot use inline scripts or styles at all, which in an HDA application is rarely a limitation.
Security headers middleware
Beyond CSP, several HTTP response headers improve security. Rather than pulling in a dependency, write a middleware function that sets them all. This keeps the headers visible in your codebase and avoids relying on a third-party crate’s defaults.
use axum::{extract::Request, middleware::Next, response::Response};
use http::{header, HeaderValue};
pub async fn security_headers(req: Request, next: Next) -> Response {
let mut res = next.run(req).await;
let headers = res.headers_mut();
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
headers.insert(
header::X_FRAME_OPTIONS,
HeaderValue::from_static("DENY"),
);
headers.insert(
header::STRICT_TRANSPORT_SECURITY,
HeaderValue::from_static("max-age=63072000; includeSubDomains"),
);
headers.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
headers.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(
"default-src 'self'; script-src 'self'; style-src 'self'; \
img-src 'self' data:; font-src 'self'; connect-src 'self'; \
form-action 'self'; frame-ancestors 'none'; base-uri 'self'; \
object-src 'none'"
),
);
headers.insert(
HeaderName::from_static("permissions-policy"),
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
);
headers.insert(
HeaderName::from_static("cross-origin-opener-policy"),
HeaderValue::from_static("same-origin"),
);
res
}
Apply the middleware to your router:
use axum::{middleware, Router};
let app = Router::new()
.route("/", get(index))
// ... other routes
.layer(middleware::from_fn(security_headers));
Add use http::HeaderName; for the headers that are not in the http crate’s built-in constants (permissions-policy, cross-origin-opener-policy).
What each header does
| Header | Value | Purpose |
|---|---|---|
X-Content-Type-Options | nosniff | Prevents browsers from MIME-type sniffing responses away from the declared Content-Type. Stops attacks that trick browsers into treating HTML as JavaScript. |
X-Frame-Options | DENY | Prevents the page from being embedded in <iframe>, <frame>, or <object> elements. Blocks clickjacking. Superseded by CSP frame-ancestors but still needed for older browsers. |
Strict-Transport-Security | max-age=63072000; includeSubDomains | Forces HTTPS for two years, including all subdomains. Only set this once you are committed to HTTPS (it is difficult to undo). |
Referrer-Policy | strict-origin-when-cross-origin | Sends the full URL as referrer for same-origin requests, but only the origin (no path) for cross-origin requests. Prevents leaking internal URL paths to external sites. |
Content-Security-Policy | See above | Controls which sources of content the browser will load. The primary defence against XSS beyond output escaping. |
Permissions-Policy | camera=(), microphone=(), geolocation=() | Disables browser APIs your application does not use. Prevents third-party scripts (if any) from accessing the camera, microphone, or location. |
Cross-Origin-Opener-Policy | same-origin | Isolates the browsing context from cross-origin popups. Prevents Spectre-style side-channel attacks from cross-origin windows. |
HSTS caution
Strict-Transport-Security with includeSubDomains cannot be easily reversed once browsers cache it. Before deploying, confirm that every subdomain supports HTTPS. Start with a shorter max-age (e.g., 300 for 5 minutes) during testing and increase it once you are confident.
Dependency auditing
Rust crates can have known vulnerabilities. Two tools catch them in CI.
cargo audit checks your Cargo.lock against the RustSec Advisory Database, which tracks reported vulnerabilities in Rust crates:
cargo install cargo-audit
cargo audit
cargo deny is broader. It checks advisories (same database as cargo audit), licence compliance, duplicate dependency versions, and can ban specific crates:
cargo install cargo-deny
cargo deny init # creates deny.toml
cargo deny check
Run both in CI on every pull request. cargo audit is fast and catches known CVEs. cargo deny enforces policy (no GPL dependencies, no duplicate versions of security-critical crates, ban specific crates you have decided against).
Secure coding with AI agents
Empirical research shows that AI coding assistants introduce security vulnerabilities at measurable rates. A 2021 study by NYU researchers found that roughly 40% of GitHub Copilot’s generated code samples contained vulnerabilities across the MITRE CWE Top 25 categories. A Stanford user study found that participants using AI assistance wrote less secure code and were simultaneously more confident in its security.
These findings are relevant to this stack specifically:
unsafeblocks. AI agents may introduceunsafewhen a safe alternative exists. Audit everyunsafeblock in AI-generated code for necessity and correctness.PreEscapedwith untrusted input. An agent solving an HTML rendering problem may reach forPreEscapedwithout considering the XSS implications.format!()in SQL. An agent may build dynamic queries with string formatting instead of bind parameters, especially for complex queries with optional filters.- Error messages that leak internals. AI-generated error handlers often pass raw database errors or file paths through to HTTP responses.
- Overly permissive defaults. CORS set to
*,SameSite::None,Secure: false, missing security headers. AI tends to generate code that works rather than code that is secure.
The Building with AI Coding Agents section covers review practices, a security checklist, and workflow patterns for catching these issues systematically.
Gotchas
PreEscaped is the primary XSS risk in Maud. Search your codebase for every use of PreEscaped and verify that the input is either trusted or sanitised. This is the single most impactful security audit you can do in a Maud application.
format!() is the primary SQL injection risk in SQLx. Search for format! near sqlx::query calls. Prefer query! (compile-time checked, string literal only) over query() (runtime, accepts &str) wherever possible.
CSP breaks htmx if you do not plan for it. Deploy CSP early, before you have built features that depend on inline scripts or hx-on:* attributes. Retrofitting a strict CSP onto an existing application is significantly harder than building with one from the start.
HSTS is sticky. Once a browser sees a Strict-Transport-Security header with a long max-age, it will refuse HTTP connections to your domain until the max-age expires. Test with short values first.
Security headers are not set by default. Axum sends no security headers out of the box. The middleware above is not optional; without it, your application is missing basic protections that browsers check for.