35. Security Headers & Best Practices
About this chapter
Implement multiple security layers including HTTP security headers, input validation, parameterized queries, and secrets management to protect your API.
- Defense in depth: Multiple security layers working together
- Security headers: HSTS, CSP, X-Frame-Options, and other protective headers
- SQL injection prevention: Using parameterized queries and EF Core
- XSS prevention: Input validation and output encoding
- Secrets management: Protecting API keys, passwords, and connection strings
- Best practices: Following security principles and avoiding common mistakes
Learning outcomes:
- Understand defense-in-depth security strategies
- Implement security headers middleware
- Prevent SQL injection with parameterized queries
- Validate input to prevent XSS attacks
- Manage secrets securely using User Secrets or vaults
- Identify and mitigate common API security vulnerabilities
35.1 Security Defense in Depth
Security isn’t one thing—it’s layers:
Layer 1: HTTPS (encrypt data in transit)
Layer 2: Input validation (reject bad data)
Layer 3: Parameterized queries (prevent SQL injection)
Layer 4: Security headers (browser protection)
Layer 5: Authentication/Authorization (who are you?)
Layer 6: Rate limiting (prevent brute force)
Layer 7: Secrets management (secure API keys)
Remove any layer and you’re vulnerable. This chapter covers layers 4 and 7, plus best practices for 3 and 5.
35.2 Security Headers Middleware
Create a middleware to add security headers to every response:
// Middleware/SecurityHeadersMiddleware.cs
public class SecurityHeadersMiddleware
{
private readonly RequestDelegate _next;
public SecurityHeadersMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// Add security headers before response is sent
AddSecurityHeaders(context);
await _next(context);
}
private void AddSecurityHeaders(HttpContext context)
{
// HSTS: Force HTTPS for future requests
context.Response.Headers.Add("Strict-Transport-Security",
"max-age=31536000; includeSubDomains");
// Prevent MIME type sniffing
context.Response.Headers.Add("X-Content-Type-Options", "nosniff");
// Prevent clickjacking
context.Response.Headers.Add("X-Frame-Options", "DENY");
// XSS Protection (legacy, for older browsers)
context.Response.Headers.Add("X-XSS-Protection", "1; mode=block");
// Content Security Policy
context.Response.Headers.Add("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");
// Referrer Policy
context.Response.Headers.Add("Referrer-Policy", "strict-origin-when-cross-origin");
// Feature Policy (Permissions Policy)
context.Response.Headers.Add("Permissions-Policy",
"geolocation=(), microphone=(), camera=()");
}
}
// In Program.cs
app.UseMiddleware<SecurityHeadersMiddleware>();
35.3 HSTS (HTTP Strict Transport Security)
Problem: User types http://api.example.com (http, not https)
Browser requests http://api.example.com
↓
Attacker intercepts request (no encryption)
↓
Attacker reads API responses, credentials, data
Solution: HSTS
First response tells browser: “Always use HTTPS for this domain”
HTTP/1.1 200 OK
Strict-Transport-Security: max-age=31536000; includeSubDomains
Browser remembers this. Next time:
User types: http://api.example.com
Browser automatically redirects to: https://api.example.com
Configuration:
// Program.cs
app.UseHsts(); // Built-in ASP.NET Core HSTS middleware
// Customize HSTS
var hstsBuilder = app.UseHsts();
hstsBuilder.WithMaxAge(days: 365); // 1 year
hstsBuilder.IncludeSubDomains(); // Apply to subdomains too
35.4 CSP (Content Security Policy)
Problem: XSS attack injects malicious script
<!-- Original page -->
<h1>Commands</h1>
<script src="https://api.example.com/commands"></script>
<!-- Attacker injects script -->
<h1>Commands</h1>
<script src="https://api.example.com/commands"></script>
<script src="https://evil.com/steal-credentials.js"></script>
Browser runs both scripts. Attacker steals data.
Solution: CSP
Content-Security-Policy: default-src 'self'; script-src 'self'
Says: “Only allow scripts from my own domain, nowhere else”
Attacker’s <script src="https://evil.com/..."> is blocked.
Common CSP directives:
default-src 'self' # Default: only same origin
script-src 'self' # Scripts: only same origin
style-src 'self' 'unsafe-inline' # Styles: same origin + inline
img-src * # Images: anywhere
font-src data: # Fonts: only data URIs
connect-src 'self' # XMLHttpRequest/fetch: only same origin
media-src 'none' # Audio/video: nowhere
object-src 'none' # Plugins: nowhere
frame-ancestors 'none' # Embedding: nowhere
In production:
context.Response.Headers.Add("Content-Security-Policy",
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self'; " +
"img-src 'self' data: https:; " +
"font-src 'self'; " +
"connect-src 'self'; " +
"media-src 'none'; " +
"object-src 'none'; " +
"frame-ancestors 'none'");
35.5 X-Frame-Options (Clickjacking Prevention)
Problem: Attacker embeds your API in a hidden iframe
<!-- Attacker's page -->
<iframe src="https://api.example.com/delete?id=123" style="display:none"></iframe>
If user is logged in, the delete request silently executes.
Solution: X-Frame-Options
X-Frame-Options: DENY
Says: “Don’t allow embedding in iframes”
Browser blocks the iframe.
Values:
DENY # Never allow embedding
SAMEORIGIN # Allow embedding only from same origin
ALLOW-FROM https://... # Allow embedding from specific origin (deprecated)
Best practice:
context.Response.Headers.Add("X-Frame-Options", "DENY");
35.6 SQL Injection Prevention
Problem: String concatenation in queries
// ❌ WRONG - Vulnerable to SQL injection
string searchTerm = request.Query["search"];
var commands = _context.Commands
.FromSqlInterpolated($"SELECT * FROM commands WHERE how_to LIKE '%{searchTerm}%'")
.ToList();
// If user enters: ' OR '1'='1
// Query becomes: SELECT * FROM commands WHERE how_to LIKE '% ' OR '1'='1%'
// Returns ALL commands (authentication bypass!)
Solution: Parameterized queries
// ✓ RIGHT - Parameters prevent injection
string searchTerm = request.Query["search"];
var commands = _context.Commands
.Where(c => c.HowTo.Contains(searchTerm)) // EF Core parameterizes this
.ToList();
// Or with raw SQL:
var searchParam = new SqlParameter("@search", $"%{searchTerm}%");
var commands = _context.Commands
.FromSqlInterpolated($"SELECT * FROM commands WHERE how_to LIKE @search", searchParam)
.ToList();
Why it works:
Parameterized query:
QUERY: SELECT * FROM commands WHERE how_to LIKE @search
PARAM: @search = "' OR '1'='1"
Database treats entire string as data, not SQL
Searches for literal string: "' OR '1'='1"
Best practices:
- Always use EF Core, Dapper, or parameterized raw SQL
- Never concatenate user input into SQL strings
- Use an ORM (we use EF Core)
- Validate input length and type before querying
35.7 XSS (Cross-Site Scripting) Prevention
Problem: Output encoding
// User input
var userInput = "<script>alert('Hacked!')</script>";
// Store in database
var command = new Command { HowTo = userInput };
_repository.CreateCommand(command);
// Return in API response
return Ok(command); // Sends JSON with script intact
JavaScript receives:
{
"id": 1,
"howTo": "<script>alert('Hacked!')</script>",
"commandLine": "..."
}
If frontend displays this without escaping, script executes.
Solution: Output encoding in frontend
// In React
<p>{command.howTo}</p> // React escapes by default
// In vanilla JS
const div = document.createElement('div');
div.textContent = command.howTo; // textContent doesn't execute scripts
document.body.appendChild(div);
As API developer:
ASP.NET Core returns JSON by default, which is safe. JSON serializers don’t execute scripts. But:
- Document that frontend should escape HTML
- Use
HtmlEncoderif returning HTML views
using System.Text.Encodings.Web;
var userInput = "<script>alert('Hacked!')</script>";
var encoded = HtmlEncoder.Default.Encode(userInput);
// Returns: <script>alert('Hacked!')</script>
35.8 Input Validation & Sanitization
Problem: Trusting user input
// ❌ WRONG
public async Task<ActionResult> CreateCommand(CommandMutateDto dto)
{
// No validation—what if dto has null fields? Huge negative numbers?
var command = _mapper.Map<Command>(dto);
_repository.CreateCommand(command);
await _repository.SaveChangesAsync();
return CreatedAtRoute(nameof(GetCommandById), new { id = command.Id }, command);
}
Solution: Validate everything
// ✓ RIGHT
public class CommandMutateDto
{
[Required]
[StringLength(200, MinimumLength = 5)]
public string HowTo { get; set; }
[Required]
[StringLength(500)]
public string CommandLine { get; set; }
[Range(1, int.MaxValue)]
public int PlatformId { get; set; }
}
// In controller
[HttpPost]
public async Task<ActionResult> CreateCommand([FromBody] CommandMutateDto dto)
{
// ModelState.IsValid automatically checked (automatic from ASP.NET Core)
if (!ModelState.IsValid)
return BadRequest(ModelState);
var command = _mapper.Map<Command>(dto);
_repository.CreateCommand(command);
await _repository.SaveChangesAsync();
return CreatedAtRoute(nameof(GetCommandById), new { id = command.Id }, command);
}
Use FluentValidation for complex rules:
public class CommandMutateDtoValidator : AbstractValidator<CommandMutateDto>
{
public CommandMutateDtoValidator()
{
RuleFor(x => x.HowTo)
.NotEmpty()
.Length(5, 200)
.Matches(@"^[a-zA-Z0-9\s\-\.]+$") // Only alphanumeric, spaces, dash, dot
.WithMessage("HowTo contains invalid characters");
RuleFor(x => x.CommandLine)
.NotEmpty()
.Length(1, 500)
.Must(x => !x.Contains(";") && !x.Contains("|")) // No dangerous chars
.WithMessage("CommandLine contains dangerous characters");
RuleFor(x => x.PlatformId)
.GreaterThan(0)
.MustAsync(async (id, ct) =>
{
// Verify platform exists
return await _db.Platforms.AnyAsync(p => p.Id == id, ct);
})
.WithMessage("Platform not found");
}
}
35.9 Secrets Management
Problem: Hardcoding secrets
// ❌ WRONG - Secrets in code
var connectionString = "Host=localhost;Username=admin;Password=SuperSecret123!";
var smtpPassword = "gmail-password-123";
var apiKey = "stripe-key-sk_live_abc123xyz";
Commits code to git → Secrets exposed in git history forever.
Solution: Environment-based configuration
// appsettings.json (safe—no secrets)
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Username=admin;Password=***"
},
"Email": {
"SmtpHost": "smtp.gmail.com",
"SmtpPort": 587
}
}
// appsettings.Production.json (doesn't commit secrets either)
{
"ConnectionStrings": {
"DefaultConnection": "Host=prod-db;Username=db-user;Password=***"
}
}
// Program.cs - Load from environment
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
// Secrets come from environment variables/Azure Key Vault/AWS Secrets Manager
// Set at deployment time, NOT in code
Use User Secrets for development:
# Initialize user secrets
dotnet user-secrets init
# Set a secret
dotnet user-secrets set "ConnectionStrings:DefaultConnection" \
"Host=localhost;Username=admin;Password=LocalPassword123"
# Secrets stored in OS-specific secure location, not in git
Use Azure Key Vault for production:
// Program.cs
if (builder.Environment.IsProduction())
{
var keyVaultUrl = new Uri(builder.Configuration["KeyVault:Url"]);
var credential = new DefaultAzureCredential();
builder.Configuration.AddAzureKeyVault(keyVaultUrl, credential);
}
// Access secrets (automatically decrypted)
var dbPassword = builder.Configuration["DbPassword"];
var apiKey = builder.Configuration["StripeApiKey"];
Secrets checklist:
- ✓ Never hardcode secrets
- ✓ Use environment variables/Key Vault
- ✓ Rotate secrets regularly
- ✓ Use least-privilege secrets (minimum permissions needed)
- ✓ Audit who accesses secrets
- ✓ Don’t log secrets (filter from logs)
35.10 HTTPS Enforcement
Problem: Mixed HTTP/HTTPS
// ❌ WRONG - Allows both
app.Run(); // Listens on both 5000 (HTTP) and 5001 (HTTPS)
Users might accidentally hit HTTP endpoint.
Solution: Force HTTPS
// ✓ RIGHT
app.UseHttpsRedirection(); // Redirect HTTP to HTTPS
// Or require HTTPS
app.UseHsts(); // Tell browsers to always use HTTPS
Production:
- Obtain SSL certificate (Let’s Encrypt is free)
- Configure reverse proxy (nginx, IIS) to only accept HTTPS
- Block port 80 at firewall
- Ensure all external URLs use HTTPS
35.11 Authentication & Authorization Review
From Part 5, you implemented JWT authentication. Key reminders:
Store tokens securely:
// ❌ WRONG - Stores in localStorage (vulnerable to XSS)
localStorage.setItem('token', token);
// ✓ RIGHT - Stores in httpOnly cookie (not accessible to JavaScript)
// Set by server in Set-Cookie header with HttpOnly flag
document.cookie = "authToken=...;HttpOnly;Secure;SameSite=Strict";
Validate tokens on every request:
[Authorize] // Validates token
[HttpGet]
public async Task<ActionResult> GetCommands()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier).Value;
// Now safe to use userId
}
Use least privilege:
[Authorize(Roles = "Admin")] // Only admins
[HttpDelete("{id}")]
public async Task<ActionResult> DeleteCommand(int id)
{
// Safe—non-admins can't reach this
}
35.12 Logging Sensitive Data
Problem: Logging secrets
// ❌ WRONG - Logs password
_logger.LogInformation("User {Username} logged in with password {Password}",
username, password);
Logs contain password in plaintext. Very bad.
Solution: Don’t log sensitive data
// ✓ RIGHT
_logger.LogInformation("User {Username} logged in successfully", username);
// If you must log errors with sensitive context:
_logger.LogError("Login failed for user {Username}", username);
// Don't include password, API key, credit card, etc.
Redact secrets from logs:
// Custom logger to filter sensitive fields
public class RedactingLogger : ILogger
{
private readonly ILogger _inner;
private static readonly string[] SensitiveFields =
{ "password", "apikey", "token", "credit", "ssn" };
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
Exception exception, Func<TState, Exception, string> formatter)
{
var message = formatter(state, exception);
// Redact sensitive fields
foreach (var field in SensitiveFields)
{
message = Regex.Replace(message,
$@"{field}[:\s=']*[^,\s']+",
$"{field}=***REDACTED***",
RegexOptions.IgnoreCase);
}
_inner.Log(logLevel, eventId, message, exception, (s, ex) => s);
}
}
35.13 Regular Security Practices
1. Keep dependencies updated:
# Check for vulnerable packages
dotnet list package --vulnerable
# Update packages
dotnet package update
2. Scan code for security issues:
# Using SonarQube
dotnet sonarscanner begin /k:"myproject"
dotnet build
dotnet sonarscanner end
# Using BinSkim (Microsoft)
binskim analyze bin/Release/net10.0/api.dll
3. Penetration testing:
- Regularly test for XSS, SQL injection, broken auth
- Use tools like OWASP ZAP, Burp Suite
- Run in staging environment, not production
4. Security headers checklist:
# Test your API
curl -I https://api.example.com
# Should see:
# Strict-Transport-Security: max-age=31536000
# X-Content-Type-Options: nosniff
# X-Frame-Options: DENY
# Content-Security-Policy: ...
35.14 What’s Next
You now have:
- ✓ Security headers (HSTS, CSP, X-Frame-Options)
- ✓ SQL injection prevention (parameterized queries)
- ✓ XSS protection (output encoding)
- ✓ Input validation and sanitization
- ✓ Secrets management (environment variables, Key Vault)
- ✓ HTTPS enforcement
- ✓ Authentication and authorization review
- ✓ Secure logging practices
Next: Part 13 - Resilience & Reliability—Polly for retries, circuit breakers, and handling failures gracefully.