Secure Coding Practices
Security is not a feature bolted on at the end. It is a property of how you write every line of code. These practices prevent the most common classes of web application vulnerabilities: injection, cross-site scripting, cross-site request forgery, and misconfiguration.
Input Validation
Validate all input at the boundary. Use strong types to make invalid states unrepresentable. Reject unexpected input before it reaches business logic.
STRUCTURE CreateUserRequest
email : String // must be valid email format
password : String // length between 8 and 128
name : String // length between 1 and 100
PROCEDURE CREATE_USER(payload) → Result<User>
IF NOT VALIDATE(payload) THEN
RETURN Error(400 BAD REQUEST, "Validation error: " + errors)
END IF
// proceed with validated data
...
Validation rules:
- Length limits on all string inputs to prevent denial of service and buffer abuse
- Format validation (email, URL, phone) using well-tested libraries, not hand-written regex
- Range checks on numeric inputs
- Allowlists over denylists -- define what is permitted rather than trying to block what is dangerous
Rust's type system adds a second layer: use newtypes to enforce invariants at compile time.
STRUCTURE EmailAddress
value : String
PROCEDURE EmailAddress.PARSE(s) → Result<EmailAddress>
// Validate email format
IF s DOES NOT CONTAIN '@' OR LENGTH(s) > 254 THEN
RETURN Error(InvalidEmail)
END IF
RETURN Ok(NEW EmailAddress(LOWERCASE(s)))
Parameterized Queries
Never concatenate user input into SQL strings. This is the single most important rule for preventing injection. Parameterized queries separate code from data.
// BAD: string interpolation creates SQL injection
PROCEDURE SEARCH_BAD(pool, term) → Result<List<Product>>
query ← "SELECT * FROM products WHERE name LIKE '%" + term + "%'"
RETURN EXECUTE_QUERY(pool, query)
// GOOD: parameterized query
PROCEDURE SEARCH(pool, term) → Result<List<Product>>
RETURN EXECUTE_QUERY(pool,
"SELECT * FROM products WHERE name LIKE $1",
BIND "%" + term + "%")
With sqlx, you can go further and use compile-time checked queries that verify your SQL against the actual database schema:
// Compile-time checked query -- catches SQL errors and type mismatches at build time
products ← EXECUTE_CHECKED_QUERY(pool,
"SELECT id, name, price FROM products WHERE name LIKE $1",
BIND "%" + term + "%")
This catches SQL syntax errors and type mismatches at compile time, not at runtime.
Cross-Site Scripting (XSS) Prevention
An attacker injects malicious JavaScript into a page viewed by other users. Three types:
- Stored XSS -- Malicious script saved in the database, served to every user who views the page
- Reflected XSS -- Malicious script in a URL parameter, reflected back in the response
- DOM-based XSS -- Malicious script manipulates the DOM client-side
// BAD: raw user input injected into HTML
PROCEDURE PROFILE_BAD(name) → HTML
RETURN HTML("<h1>Hello, " + name + "</h1>")
// If name is: <script>alert('xss')</script>, it executes
// GOOD: use a template engine that escapes by default
STRUCTURE ProfileTemplate
name : String // Template engine auto-escapes this in the template
// Render using template file "profile.html" with auto-escaping
Prevention checklist:
- Escape all output rendered in HTML. Template engines (Tera, Askama) escape by default.
- Set
Content-Security-Policyheaders to restrict script sources. - Use
HttpOnlycookies so JavaScript cannot steal session tokens. - Sanitize HTML if you must accept rich text input (use a library like
ammoniafor Rust).
Cross-Site Request Forgery (CSRF) Prevention
An attacker tricks a user's browser into making a request to your site while the user is authenticated. The browser automatically includes cookies, so the request looks legitimate.
Prevention strategies (use at least one, preferably combine):
-
SameSite=Strict cookies -- The browser does not send the cookie with cross-origin requests. This is the simplest defense and should be the default.
-
CSRF tokens -- Generate a random token per session, embed it in forms, and verify it on the server.
STRUCTURE TransferForm
to_account : String
amount : Float
csrf_token : String
PROCEDURE TRANSFER(form, session) → Result<StatusCode>
// Verify CSRF token matches the one stored in the session
IF form.csrf_token ≠ session.csrf_token THEN
RETURN Error(403 FORBIDDEN)
END IF
// Process transfer
...
- Custom request headers -- Require a custom header (e.g.,
X-Requested-With) that browsers do not include in cross-origin form submissions or simple requests.
Security Headers
Apply security headers as middleware to every response. These headers instruct the browser to enable security features.
PROCEDURE SECURITY_HEADERS(request, next) → Response
response ← next.RUN(request)
headers ← response.HEADERS()
// Prevent MIME type sniffing
SET headers["X-Content-Type-Options"] ← "nosniff"
// Prevent clickjacking by disallowing framing
SET headers["X-Frame-Options"] ← "DENY"
// Disable the XSS auditor (it can introduce vulnerabilities)
SET headers["X-XSS-Protection"] ← "0"
// Enforce HTTPS for 1 year, including subdomains
SET headers["Strict-Transport-Security"] ← "max-age=31536000; includeSubDomains"
// Restrict content sources
SET headers["Content-Security-Policy"] ← "default-src 'self'; script-src 'self'; style-src 'self'"
// Control referrer information sent to other origins
SET headers["Referrer-Policy"] ← "strict-origin-when-cross-origin"
RETURN response
Header explanations:
X-Content-Type-Options: nosniff-- Prevents the browser from guessing the content type, which can lead to script executionX-Frame-Options: DENY-- Prevents your pages from being embedded in iframes, blocking clickjackingStrict-Transport-Security-- Tells the browser to only connect via HTTPS, even if the user typeshttp://Content-Security-Policy-- The most powerful security header. Restricts which sources can load scripts, styles, images, and other resources. Start strict ('self'only) and relax as needed.Referrer-Policy-- Controls how much URL information is shared when navigating to other sites
Secure Defaults
Design systems so that the secure path is the easy path. Insecurity should require explicit opt-in.
Principles:
- Deny by default. New endpoints should require authentication and authorization until explicitly marked as public.
- Fail closed. If an authorization check fails or throws an error, deny access. Never default to allowing.
- Minimize surface area. Disable unused features, endpoints, and debug tooling in production.
- Environment-aware configuration. Separate development and production configurations. Debug mode, verbose error messages, and permissive CORS belong in development only.
STRUCTURE SecurityConfig
cors_origins : List<String>
debug_errors : Boolean
require_https : Boolean
PROCEDURE SecurityConfig.PRODUCTION() → SecurityConfig
RETURN SecurityConfig {
cors_origins ← ["https://app.example.com"],
debug_errors ← FALSE,
require_https ← TRUE
}
PROCEDURE SecurityConfig.DEVELOPMENT() → SecurityConfig
RETURN SecurityConfig {
cors_origins ← ["http://localhost:3000"],
debug_errors ← TRUE,
require_https ← FALSE
}
Error Handling
Never expose internal details in error responses. Stack traces, database error messages, and internal paths give attackers information about your system.
// BAD: exposes internal details
PROCEDURE GET_USER_BAD(pool, id) → Result<User>
result ← DB_GET_USER(pool, id)
IF result IS ERROR THEN
RETURN Error("Database error: " + error_details) // Leaks DB info
END IF
RETURN Ok(result)
// GOOD: generic error to client, detailed error to logs
PROCEDURE GET_USER(pool, id) → Result<User>
result ← DB_GET_USER(pool, id)
IF result IS ERROR THEN
LOG ERROR "Failed to fetch user " + id + ": " + error_details
RETURN Error(500 INTERNAL SERVER ERROR)
END IF
RETURN Ok(result)
Logging Sensitive Data
Never log passwords, credit card numbers, tokens, or personal data. Logs are often stored with weaker access controls than production databases and may be shipped to third-party aggregation services.
// BAD: logs the password
LOG INFO "Login attempt: email=" + email + ", password=" + password
// GOOD: log the event without sensitive data
LOG INFO "Login attempt: email=" + email
// GOOD: redact sensitive fields in structs
STRUCTURE AuditableRequest
email : String
password : String // should never appear in logs
PROCEDURE AuditableRequest.TO_STRING() → String
RETURN "AuditableRequest { email: " + self.email + ", password: [REDACTED] }"
Common Mistakes
- Overly broad CORS policies.
Access-Control-Allow-Origin: *on an API with cookies or sensitive data allows any website to make authenticated requests. Lock CORS to known frontend origins. - Exposing stack traces in production. Set
debug_errors: falseand return generic error messages to clients. - Logging sensitive data. Sanitize all log output. Never log passwords, tokens, or PII.
- Not validating content types. Accept only expected content types (
application/json) and reject everything else. - Using
unwrap()in request handlers. A panic in a handler crashes the task and may leak information in the error response. Use proper error handling withResult.
Key Takeaways
- Validate all input at the boundary. Use the
validatorcrate and Rust's type system to reject bad data early. - Use parameterized queries for all database access. Never concatenate user input into SQL.
- Apply security headers as middleware. Start with a strict CSP and relax as needed.
- Make security the default. Insecurity should require explicit opt-in, not the other way around.
- Never expose internal error details to clients. Log them for debugging; return generic messages to users.