June 22, 2026
Secure Coding Practices — Writing Code That Doesn’t Break
Turning Threat Model Findings into Secure Implementation
Ali Sadri
11 min read
Turning Threat Model Findings into Secure Implementation
Introduction: From Design to Code
In our previous article, we established that threat modeling identifies what could go wrong at the design level. But a secure design doesn't guarantee secure code. The most well-architected system can be undermined by common coding mistakes: trusting user input, hardcoding secrets, using weak cryptography, or failing to encode output.
This article bridges the gap between threat identification and secure implementation. We'll cover the essential secure coding practices that every developer must adopt, turning threat model findings into actionable code-level controls.
The Core Challenge: Trusting the Untrusted
Modern applications handle data from countless sources:
- User inputs (forms, URLs, file uploads)
- External APIs (third-party services, microservices)
- Databases (legacy data, user-generated content)
- Message queues (async jobs, event streams)
- Cloud services (S3 buckets, queues, functions)
The golden rule:
"All external data is hostile until proven otherwise."
1. Input Validation & Sanitization
The Problem
Attackers exploit applications that blindly trust user-supplied data. Whether it's SQL Injection, Cross-Site Scripting (XSS), or Command Injection, the root cause is almost always insufficient input validation.
The Solution: Defense in Depth for Inputs
Validate Early — Validate input as soon as it enters the system at the boundary. In ASP.NET Core, this means using middleware, action filters, or model validation attributes before any business logic executes.
Use Allow-Lists, Not Block-Lists — Define what is allowed, not what is forbidden. Block-lists are always incomplete. For example, allow only [a-zA-Z0-9@.] for an email field rather than trying to block <script> and other dangerous patterns.
Enforce Type Checking — Parse inputs to their expected data types (int, Guid, DateTime) before using them. This naturally rejects malformed input.
Restrict Length Limits — Limit input length to prevent buffer overflows and denial-of-service attacks. A username field shouldn't accept 10,000 characters.
Apply Semantic Validation — Validate meaning, not just format. A birthdate must be in the past, an age must be between 0 and 120, and an email must have a valid domain.
C# Code Example
// ❌ BAD – No validation
[HttpGet]
public IActionResult GetUser(string username)
{
var query = $"SELECT * FROM Users WHERE Username = '{username}'";
// SQL Injection vulnerability!
return Ok();
}
// ✅ GOOD – Validation + Parameterized Query
[HttpGet]
public IActionResult GetUser(string username)
{
// 1. Validate with allow-list regex
if (string.IsNullOrWhiteSpace(username) ||
!Regex.IsMatch(username, @"^[a-zA-Z0-9_]{3,20}$"))
{
return BadRequest("Invalid username format. Use 3-20 alphanumeric characters.");
}
// 2. Parameterized query (safe)
using var connection = new SqlConnection(_connectionString);
var query = "SELECT * FROM Users WHERE Username = @Username";
var user = connection.QuerySingleOrDefault<User>(query, new { Username = username });
return Ok(user);
}
// ✅ EVEN BETTER – Using built-in validation attributes
public class UserRequest
{
[Required]
[StringLength(20, MinimumLength = 3)]
[RegularExpression(@"^[a-zA-Z0-9_]+$")]
public string Username { get; set; }
}
[HttpPost]
public IActionResult CreateUser([FromBody] UserRequest request)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
// Process validated request
return Created();
}// ❌ BAD – No validation
[HttpGet]
public IActionResult GetUser(string username)
{
var query = $"SELECT * FROM Users WHERE Username = '{username}'";
// SQL Injection vulnerability!
return Ok();
}
// ✅ GOOD – Validation + Parameterized Query
[HttpGet]
public IActionResult GetUser(string username)
{
// 1. Validate with allow-list regex
if (string.IsNullOrWhiteSpace(username) ||
!Regex.IsMatch(username, @"^[a-zA-Z0-9_]{3,20}$"))
{
return BadRequest("Invalid username format. Use 3-20 alphanumeric characters.");
}
// 2. Parameterized query (safe)
using var connection = new SqlConnection(_connectionString);
var query = "SELECT * FROM Users WHERE Username = @Username";
var user = connection.QuerySingleOrDefault<User>(query, new { Username = username });
return Ok(user);
}
// ✅ EVEN BETTER – Using built-in validation attributes
public class UserRequest
{
[Required]
[StringLength(20, MinimumLength = 3)]
[RegularExpression(@"^[a-zA-Z0-9_]+$")]
public string Username { get; set; }
}
[HttpPost]
public IActionResult CreateUser([FromBody] UserRequest request)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
// Process validated request
return Created();
}2. Parameterized Queries (SQL Injection Prevention)
The Problem
SQL Injection (SQLi) remains the number one web application vulnerability according to OWASP Top 10. It occurs when unsanitized user input is concatenated into SQL queries, allowing attackers to manipulate the query structure and execute arbitrary SQL commands.
The Solution: Parameterized Queries (Prepared Statements)
Parameterized queries separate SQL logic from data. The database engine treats the query structure as immutable and data as parameters. This means user input can never change the structure of the query.
Best Practices
Never concatenate user input into SQL queries, even after validation. Use ORM frameworks like Entity Framework Core which parameterize by default. For stored procedures, still use parameterized inputs. When dynamic table or column names are required, use a whitelist of allowed values rather than accepting user input directly.
C# Code Examples
// ❌ BAD – String concatenation (SQL Injection!)
public User GetUserById_Bad(string userId)
{
using var connection = new SqlConnection(_connectionString);
var query = $"SELECT * FROM Users WHERE Id = '{userId}'";
// Attacker could input: ' OR '1'='1' --
return connection.QueryFirstOrDefault<User>(query);
}
// ✅ GOOD – Parameterized query with Dapper
public User GetUserById_Good(string userId)
{
// Parse to GUID to ensure type safety
if (!Guid.TryParse(userId, out var userGuid))
{
throw new ArgumentException("Invalid user ID format");
}
using var connection = new SqlConnection(_connectionString);
var query = "SELECT * FROM Users WHERE Id = @UserId";
return connection.QueryFirstOrDefault<User>(query, new { UserId = userGuid });
}
// ✅ BEST – Entity Framework Core (auto-parameterized)
public async Task<User> GetUserByIdAsync(Guid userId)
{
using var context = new AppDbContext();
return await context.Users
.FirstOrDefaultAsync(u => u.Id == userId);
// EF Core automatically parameterizes the query
}
// ✅ For dynamic table names – use a whitelist
public IEnumerable<User> GetUsersByTable(string tableName)
{
var allowedTables = new HashSet<string> { "Users", "Admins", "Employees" };
if (!allowedTables.Contains(tableName))
{
throw new SecurityException("Invalid table name requested.");
}
using var connection = new SqlConnection(_connectionString);
var query = $"SELECT * FROM {tableName} WHERE IsActive = 1";
// Table name is from whitelist, so it's safe
return connection.Query<User>(query);
}// ❌ BAD – String concatenation (SQL Injection!)
public User GetUserById_Bad(string userId)
{
using var connection = new SqlConnection(_connectionString);
var query = $"SELECT * FROM Users WHERE Id = '{userId}'";
// Attacker could input: ' OR '1'='1' --
return connection.QueryFirstOrDefault<User>(query);
}
// ✅ GOOD – Parameterized query with Dapper
public User GetUserById_Good(string userId)
{
// Parse to GUID to ensure type safety
if (!Guid.TryParse(userId, out var userGuid))
{
throw new ArgumentException("Invalid user ID format");
}
using var connection = new SqlConnection(_connectionString);
var query = "SELECT * FROM Users WHERE Id = @UserId";
return connection.QueryFirstOrDefault<User>(query, new { UserId = userGuid });
}
// ✅ BEST – Entity Framework Core (auto-parameterized)
public async Task<User> GetUserByIdAsync(Guid userId)
{
using var context = new AppDbContext();
return await context.Users
.FirstOrDefaultAsync(u => u.Id == userId);
// EF Core automatically parameterizes the query
}
// ✅ For dynamic table names – use a whitelist
public IEnumerable<User> GetUsersByTable(string tableName)
{
var allowedTables = new HashSet<string> { "Users", "Admins", "Employees" };
if (!allowedTables.Contains(tableName))
{
throw new SecurityException("Invalid table name requested.");
}
using var connection = new SqlConnection(_connectionString);
var query = $"SELECT * FROM {tableName} WHERE IsActive = 1";
// Table name is from whitelist, so it's safe
return connection.Query<User>(query);
}3. Secure Secrets Management
The Problem
Hardcoded secrets are one of the most common and dangerous developer mistakes. API keys, passwords, certificates, and tokens embedded in source code are exposed in version control (even private repos), leaked in logs, shared among developers without rotation, and discovered by automated scanners.
The Solution: Secrets Management Tools
Modern .NET applications use multiple tools to manage secrets across different environments:
- Azure Key Vault is the preferred cloud-native solution for Azure deployments, providing hardware-backed HSMs and fine-grained access policies.
- HashiCorp Vault offers dynamic secrets and fine-grained auditing for multi-cloud and on-premises environments.
- AWS Secrets Manager is ideal for AWS-native workloads with automatic rotation.
- .NET User Secrets is a development-only tool for local development, never used in production.
- Environment Variables are the simplest approach for containerized deployments following the twelve-factor app methodology.
Best Practices
Never hardcode secrets into your source code. Use environment variables or a secrets management service. Rotate secrets regularly to limit damage if compromised. Audit secret access to know who accessed what and when. Use short-lived credentials such as IAM roles, OAuth tokens, or STS temporary credentials. Scan your repository for secrets using tools like gitleaks, trufflehog, or GitHub's built-in secret scanning.
C# Code Examples
// ❌ BAD – Hardcoded secret in code
public class StripeService_Bad
{
private const string API_KEY = "sk_live_1234567890abcdef"; // NEVER DO THIS!
public void ProcessPayment()
{
// ...
}
}
// ✅ GOOD – Load from environment variable
public class StripeService_Environment
{
private readonly string _apiKey;
public StripeService_Environment()
{
_apiKey = Environment.GetEnvironmentVariable("STRIPE_API_KEY");
if (string.IsNullOrEmpty(_apiKey))
{
throw new InvalidOperationException("STRIPE_API_KEY environment variable not set.");
}
}
}
// ✅ BETTER – Load from configuration with Azure Key Vault
public class StripeService_KeyVault
{
private readonly string _apiKey;
public StripeService_KeyVault(IConfiguration configuration)
{
// configuration is connected to Azure Key Vault via AddAzureKeyVault()
_apiKey = configuration["Secrets:StripeApiKey"];
if (string.IsNullOrEmpty(_apiKey))
{
throw new InvalidOperationException("Stripe API key not found in Key Vault.");
}
}
}
// ✅ BEST – Development secrets with User Secrets
// In development: dotnet user-secrets set "Stripe:ApiKey" "sk_test_..."
// In production: Azure Key Vault or environment variables
// Program.cs
using Microsoft.Extensions.Configuration;
var builder = WebApplication.CreateBuilder(args);
// Development: User Secrets
// Production: Key Vault or Environment Variables
if (builder.Environment.IsDevelopment())
{
builder.Configuration.AddUserSecrets<Program>();
}
else
{
builder.Configuration.AddAzureKeyVault(
new Uri(Environment.GetEnvironmentVariable("AZURE_KEY_VAULT_URI")),
new DefaultAzureCredential());
}
var app = builder.Build();
var stripeKey = app.Configuration["Stripe:ApiKey"];// ❌ BAD – Hardcoded secret in code
public class StripeService_Bad
{
private const string API_KEY = "sk_live_1234567890abcdef"; // NEVER DO THIS!
public void ProcessPayment()
{
// ...
}
}
// ✅ GOOD – Load from environment variable
public class StripeService_Environment
{
private readonly string _apiKey;
public StripeService_Environment()
{
_apiKey = Environment.GetEnvironmentVariable("STRIPE_API_KEY");
if (string.IsNullOrEmpty(_apiKey))
{
throw new InvalidOperationException("STRIPE_API_KEY environment variable not set.");
}
}
}
// ✅ BETTER – Load from configuration with Azure Key Vault
public class StripeService_KeyVault
{
private readonly string _apiKey;
public StripeService_KeyVault(IConfiguration configuration)
{
// configuration is connected to Azure Key Vault via AddAzureKeyVault()
_apiKey = configuration["Secrets:StripeApiKey"];
if (string.IsNullOrEmpty(_apiKey))
{
throw new InvalidOperationException("Stripe API key not found in Key Vault.");
}
}
}
// ✅ BEST – Development secrets with User Secrets
// In development: dotnet user-secrets set "Stripe:ApiKey" "sk_test_..."
// In production: Azure Key Vault or environment variables
// Program.cs
using Microsoft.Extensions.Configuration;
var builder = WebApplication.CreateBuilder(args);
// Development: User Secrets
// Production: Key Vault or Environment Variables
if (builder.Environment.IsDevelopment())
{
builder.Configuration.AddUserSecrets<Program>();
}
else
{
builder.Configuration.AddAzureKeyVault(
new Uri(Environment.GetEnvironmentVariable("AZURE_KEY_VAULT_URI")),
new DefaultAzureCredential());
}
var app = builder.Build();
var stripeKey = app.Configuration["Stripe:ApiKey"];4. Output Encoding (XSS Prevention)
The Problem
Cross-Site Scripting (XSS) occurs when user-supplied input is rendered in the browser without proper encoding. An attacker can inject malicious JavaScript, stealing session cookies, defacing websites, or redirecting users to malicious sites.
The Solution: Context-Sensitive Output Encoding
Different contexts require different encoding strategies. In HTML body content, use HTML entity encoding where <script> becomes <script>. In HTML attributes, encode quotes and special characters. In JavaScript strings, escape single quotes and backslashes. In URL parameters, use percent-encoding.
Best Practices
Use modern templating engines and front-end frameworks that auto-encode. In ASP.NET Core, Razor syntax @ automatically HTML-encodes output. Never use Html.Raw() or @Html.Raw() with untrusted data. Use textContent instead of innerHTML when setting content with JavaScript. Implement a strong Content Security Policy (CSP) as a defense-in-depth measure. Encode at output time, not at input time – data should be stored in its raw form and encoded when rendered.
C# and JavaScript Code Examples
// ❌ BAD – Vulnerable to XSS (Razor example)
<div>
@Html.Raw(Model.UserInput) // NEVER use with untrusted data!
</div>
// ✅ GOOD – Razor auto-encodes
<div>
@Model.UserInput // Automatically HTML-encoded
</div>
// ❌ BAD – If you must use JavaScript
<script>
var userInput = '@Model.UserInput'; // Vulnerable to script injection
</script>
// ✅ GOOD – Properly encoded for JavaScript
<script>
// Using JSON serialization for safe embedding
var userInput = @Html.Raw(JsonSerializer.Serialize(Model.UserInput));
// Now it's JSON-encoded and safe
</script>
// ✅ BEST – JavaScript safe approach
<script>
var userInput = document.createElement('div');
userInput.textContent = '@Model.UserInput'; // textContent escapes automatically
document.getElementById('container').appendChild(userInput);
</script>
// C# back-end encoding helper
public static string SanitizeForHtml(string input)
{
if (string.IsNullOrEmpty(input)) return input;
// Use System.Text.Encodings.Web
return System.Text.Encodings.Web.HtmlEncoder.Default.Encode(input);
}
public static string SanitizeForJavaScript(string input)
{
if (string.IsNullOrEmpty(input)) return input;
return System.Text.Encodings.Web.JavaScriptEncoder.Default.Encode(input);
}
// In your controller
public IActionResult DisplayUserInput(string userComment)
{
var sanitized = SanitizeForHtml(userComment);
ViewBag.SafeComment = sanitized;
return View();
}// ❌ BAD – Vulnerable to XSS (Razor example)
<div>
@Html.Raw(Model.UserInput) // NEVER use with untrusted data!
</div>
// ✅ GOOD – Razor auto-encodes
<div>
@Model.UserInput // Automatically HTML-encoded
</div>
// ❌ BAD – If you must use JavaScript
<script>
var userInput = '@Model.UserInput'; // Vulnerable to script injection
</script>
// ✅ GOOD – Properly encoded for JavaScript
<script>
// Using JSON serialization for safe embedding
var userInput = @Html.Raw(JsonSerializer.Serialize(Model.UserInput));
// Now it's JSON-encoded and safe
</script>
// ✅ BEST – JavaScript safe approach
<script>
var userInput = document.createElement('div');
userInput.textContent = '@Model.UserInput'; // textContent escapes automatically
document.getElementById('container').appendChild(userInput);
</script>
// C# back-end encoding helper
public static string SanitizeForHtml(string input)
{
if (string.IsNullOrEmpty(input)) return input;
// Use System.Text.Encodings.Web
return System.Text.Encodings.Web.HtmlEncoder.Default.Encode(input);
}
public static string SanitizeForJavaScript(string input)
{
if (string.IsNullOrEmpty(input)) return input;
return System.Text.Encodings.Web.JavaScriptEncoder.Default.Encode(input);
}
// In your controller
public IActionResult DisplayUserInput(string userComment)
{
var sanitized = SanitizeForHtml(userComment);
ViewBag.SafeComment = sanitized;
return View();
}5. Cryptographic Agility
The Problem
Cryptographic algorithms become obsolete over time. MD5 and SHA-1 are broken. Even AES-128 may become vulnerable as computational power increases. Hardcoding algorithms in your code makes upgrading difficult and forces emergency deployments when algorithms are deprecated.
The Solution: Cryptographic Agility
Cryptographic agility means designing your system to easily replace cryptographic algorithms without code changes. This involves storing algorithm metadata alongside encrypted data and using configuration-driven algorithm selection.
What to Use Today
For symmetric encryption, use AES-256-GCM which provides strong encryption with built-in authentication (GCM provides both confidentiality and integrity). For asymmetric encryption, use RSA-3072 or ECDSA with P-256 curves, with ECC being faster and more efficient. For hash functions, SHA-256 or SHA-384 are recommended. For password hashing, use Argon2id or bcrypt which are specifically designed for passwords and include salting and work factor iteration. For key exchange, use ECDHE (Elliptic Curve Diffie-Hellman) which provides Perfect Forward Secrecy. For TLS, use TLS 1.2 or TLS 1.3 which support modern ciphers and forward secrecy.
Best Practices
Use established libraries, never roll your own cryptography. In .NET, use System.Security.Cryptography which provides FIPS-compliant implementations. Parameterize algorithms by storing algorithm type in configuration, not hardcoded. Version your encryption by adding a cipher_version field to encrypted data. Support multiple algorithms to allow migration from old to new. Plan for deprecation and migrate gracefully when algorithms become weak. Use authenticated encryption modes like GCM (AES-GCM) which provide both encryption and integrity.
C# Code Examples
// ❌ BAD – Hardcoded algorithm
public class EncryptionService_Bad
{
private static readonly byte[] Key = Convert.FromBase64String("...");
public string Encrypt(string plaintext)
{
// Hardcoded AES-128-CBC
using var aes = Aes.Create();
aes.Key = Key;
aes.Mode = CipherMode.CBC;
// Hardcoded, cannot upgrade
return Convert.ToBase64String(aes.EncryptCbc(...));
}
}
// ✅ GOOD – Configurable algorithm with versioning
public class EncryptionService
{
private readonly IConfiguration _configuration;
private readonly string _algorithm;
private readonly int _keySize;
public EncryptionService(IConfiguration configuration)
{
_configuration = configuration;
_algorithm = configuration["Security:EncryptionAlgorithm"] ?? "AES-256-GCM";
_keySize = int.Parse(configuration["Security:KeySize"] ?? "256");
}
public EncryptedPayload Encrypt(string plaintext)
{
using var aes = Aes.Create();
aes.KeySize = _keySize;
aes.Mode = CipherMode.GCM;
aes.GenerateKey();
aes.GenerateIV();
// Create a payload with version metadata
var payload = new EncryptedPayload
{
Version = "1.0",
Algorithm = _algorithm,
KeySize = _keySize,
IV = Convert.ToBase64String(aes.IV),
Ciphertext = Convert.ToBase64String(
aes.EncryptCbc(Encoding.UTF8.GetBytes(plaintext), aes.IV)
)
};
return payload;
}
}
public class EncryptedPayload
{
public string Version { get; set; }
public string Algorithm { get; set; }
public int KeySize { get; set; }
public string IV { get; set; }
public string Ciphertext { get; set; }
}
// ✅ Password hashing with Argon2 (using Konsole library)
public class PasswordService
{
private readonly Argon2PasswordHasher _hasher;
public PasswordService(IConfiguration configuration)
{
var saltSize = int.Parse(configuration["Security:PasswordSaltSize"] ?? "16");
var memorySize = int.Parse(configuration["Security:PasswordMemorySize"] ?? "65536");
var iterations = int.Parse(configuration["Security:PasswordIterations"] ?? "3");
_hasher = new Argon2PasswordHasher(
saltSize: saltSize,
memorySize: memorySize,
iterations: iterations);
}
public string HashPassword(string password)
{
return _hasher.Hash(password);
}
public bool VerifyPassword(string password, string hash)
{
return _hasher.Verify(password, hash);
}
}// ❌ BAD – Hardcoded algorithm
public class EncryptionService_Bad
{
private static readonly byte[] Key = Convert.FromBase64String("...");
public string Encrypt(string plaintext)
{
// Hardcoded AES-128-CBC
using var aes = Aes.Create();
aes.Key = Key;
aes.Mode = CipherMode.CBC;
// Hardcoded, cannot upgrade
return Convert.ToBase64String(aes.EncryptCbc(...));
}
}
// ✅ GOOD – Configurable algorithm with versioning
public class EncryptionService
{
private readonly IConfiguration _configuration;
private readonly string _algorithm;
private readonly int _keySize;
public EncryptionService(IConfiguration configuration)
{
_configuration = configuration;
_algorithm = configuration["Security:EncryptionAlgorithm"] ?? "AES-256-GCM";
_keySize = int.Parse(configuration["Security:KeySize"] ?? "256");
}
public EncryptedPayload Encrypt(string plaintext)
{
using var aes = Aes.Create();
aes.KeySize = _keySize;
aes.Mode = CipherMode.GCM;
aes.GenerateKey();
aes.GenerateIV();
// Create a payload with version metadata
var payload = new EncryptedPayload
{
Version = "1.0",
Algorithm = _algorithm,
KeySize = _keySize,
IV = Convert.ToBase64String(aes.IV),
Ciphertext = Convert.ToBase64String(
aes.EncryptCbc(Encoding.UTF8.GetBytes(plaintext), aes.IV)
)
};
return payload;
}
}
public class EncryptedPayload
{
public string Version { get; set; }
public string Algorithm { get; set; }
public int KeySize { get; set; }
public string IV { get; set; }
public string Ciphertext { get; set; }
}
// ✅ Password hashing with Argon2 (using Konsole library)
public class PasswordService
{
private readonly Argon2PasswordHasher _hasher;
public PasswordService(IConfiguration configuration)
{
var saltSize = int.Parse(configuration["Security:PasswordSaltSize"] ?? "16");
var memorySize = int.Parse(configuration["Security:PasswordMemorySize"] ?? "65536");
var iterations = int.Parse(configuration["Security:PasswordIterations"] ?? "3");
_hasher = new Argon2PasswordHasher(
saltSize: saltSize,
memorySize: memorySize,
iterations: iterations);
}
public string HashPassword(string password)
{
return _hasher.Hash(password);
}
public bool VerifyPassword(string password, string hash)
{
return _hasher.Verify(password, hash);
}
}6. Error Handling and Logging
The Problem
Poor error handling exposes sensitive information through verbose error messages, stack traces, and database errors. Attackers use this information to learn about system internals and craft targeted attacks.
The Solution: Secure Error Handling
Show generic error messages to users and log detailed errors internally. Use structured logging that doesn't include sensitive data. Never expose stack traces, database error details, or internal paths to end users.
C# Code Examples
// ❌ BAD – Exposing internal details
[HttpGet]
public IActionResult GetUser(string id)
{
try
{
// If this fails, stack trace might be exposed
return Ok(_userService.GetById(id));
}
catch (Exception ex)
{
// This exposes sensitive internal details!
return BadRequest($"Error: {ex.Message}\nStack: {ex.StackTrace}");
}
}
// ✅ GOOD – Generic error messages + internal logging
[HttpGet]
public IActionResult GetUser(string id)
{
try
{
return Ok(_userService.GetById(id));
}
catch (UserNotFoundException)
{
return NotFound(new { Message = "User not found" });
}
catch (Exception ex)
{
// Log the full exception internally
_logger.LogError(ex, "Failed to retrieve user with ID: {UserId}", id);
// Return generic message to user
return StatusCode(500, new { Message = "An error occurred. Please try again later." });
}
}
// ✅ Secure logging – never log secrets or PII
public class SecureLogger
{
private readonly ILogger<SecureLogger> _logger;
public void LogUserAction(string username, string action)
{
// Never log passwords, credit cards, or session tokens
_logger.LogInformation("User performed action: {Action}", action);
// If you must log usernames, ensure they're not sensitive
// Consider hashing or redacting in production
}
public void LogAuthenticationAttempt(string username, bool success)
{
// Log only non-sensitive information
_logger.LogInformation(
"Auth attempt by user: {Username}, Success: {Success}",
username, success);
}
}// ❌ BAD – Exposing internal details
[HttpGet]
public IActionResult GetUser(string id)
{
try
{
// If this fails, stack trace might be exposed
return Ok(_userService.GetById(id));
}
catch (Exception ex)
{
// This exposes sensitive internal details!
return BadRequest($"Error: {ex.Message}\nStack: {ex.StackTrace}");
}
}
// ✅ GOOD – Generic error messages + internal logging
[HttpGet]
public IActionResult GetUser(string id)
{
try
{
return Ok(_userService.GetById(id));
}
catch (UserNotFoundException)
{
return NotFound(new { Message = "User not found" });
}
catch (Exception ex)
{
// Log the full exception internally
_logger.LogError(ex, "Failed to retrieve user with ID: {UserId}", id);
// Return generic message to user
return StatusCode(500, new { Message = "An error occurred. Please try again later." });
}
}
// ✅ Secure logging – never log secrets or PII
public class SecureLogger
{
private readonly ILogger<SecureLogger> _logger;
public void LogUserAction(string username, string action)
{
// Never log passwords, credit cards, or session tokens
_logger.LogInformation("User performed action: {Action}", action);
// If you must log usernames, ensure they're not sensitive
// Consider hashing or redacting in production
}
public void LogAuthenticationAttempt(string username, bool success)
{
// Log only non-sensitive information
_logger.LogInformation(
"Auth attempt by user: {Username}, Success: {Success}",
username, success);
}
}7. HTTP Security Headers
The Problem
Missing security headers expose applications to a range of attacks including XSS, clickjacking, and MIME-sniffing attacks.
The Solution: Security Headers Middleware
Set security headers at the server level using middleware. In ASP.NET Core, this is done through the UseHsts, UseHttpsRedirection, and custom middleware for headers like Content-Security-Policy and X-Frame-Options.
C# Code Examples
// Program.cs – Comprehensive security headers
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHsts(options =>
{
options.Preload = true;
options.IncludeSubDomains = true;
options.MaxAge = TimeSpan.FromDays(365);
});
var app = builder.Build();
// Force HTTPS (production only)
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
app.UseHsts();
}
// Custom security headers middleware
app.Use(async (context, next) =>
{
context.Response.Headers.Append("Content-Security-Policy",
"default-src 'self'; script-src 'self' https://trusted-cdn.com; style-src 'self'");
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
context.Response.Headers.Append("X-Frame-Options", "DENY");
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
context.Response.Headers.Append("Permissions-Policy",
"geolocation=(), microphone=(), camera=(), payment=()");
await next();
});
// Or use a package like NetEscapades.AspNetCore.SecurityHeaders
builder.Services.AddSecurityHeaders(opt =>
{
opt.AddHeader("Content-Security-Policy",
"default-src 'self'; script-src 'self'");
opt.AddHeader("X-Frame-Options", "DENY");
});
app.UseSecurityHeaders();// Program.cs – Comprehensive security headers
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHsts(options =>
{
options.Preload = true;
options.IncludeSubDomains = true;
options.MaxAge = TimeSpan.FromDays(365);
});
var app = builder.Build();
// Force HTTPS (production only)
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
app.UseHsts();
}
// Custom security headers middleware
app.Use(async (context, next) =>
{
context.Response.Headers.Append("Content-Security-Policy",
"default-src 'self'; script-src 'self' https://trusted-cdn.com; style-src 'self'");
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
context.Response.Headers.Append("X-Frame-Options", "DENY");
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
context.Response.Headers.Append("Permissions-Policy",
"geolocation=(), microphone=(), camera=(), payment=()");
await next();
});
// Or use a package like NetEscapades.AspNetCore.SecurityHeaders
builder.Services.AddSecurityHeaders(opt =>
{
opt.AddHeader("Content-Security-Policy",
"default-src 'self'; script-src 'self'");
opt.AddHeader("X-Frame-Options", "DENY");
});
app.UseSecurityHeaders();8. Memory Security
The Problem
Sensitive data such as passwords, credit card numbers, and private keys may remain in memory longer than necessary, potentially being exposed to other processes, memory dumps, or attacks.
The Solution: Secure Memory Handling
Zero-out sensitive data in memory after use. Use SecureString or Memory APIs that allow clearing buffers. Never store sensitive data in immutable strings (C# strings are immutable and cannot be securely cleared).
C# Code Examples
// ❌ BAD – Password remains in memory (string is immutable)
public void ProcessPassword_Bad(string password)
{
// String is immutable – it remains in memory until GC
var hash = ComputeHash(password);
// password cannot be securely cleared
}
// ✅ GOOD – Use SecureString or char arrays that can be zeroed
public void ProcessPassword_Good()
{
using var securePassword = new SecureString();
// Append characters to SecureString
foreach (char c in ReadPasswordFromConsole())
{
securePassword.AppendChar(c);
}
// Use the password
var hash = ComputeHash(securePassword);
// SecureString automatically zeroes memory when disposed
}
// ✅ GOOD – Use char array and clear it
public void ProcessPassword_CharArray()
{
char[] password = ReadPasswordFromConsole().ToCharArray();
try
{
var hash = ComputeHash(password);
}
finally
{
// Explicitly zero the password buffer
Array.Clear(password, 0, password.Length);
}
}
// ✅ BEST – Using Span<T> for memory safety
public void ProcessSensitiveData(ReadOnlySpan<char> sensitiveData)
{
// Use the data
var hash = ComputeHash(sensitiveData);
// Span is stack-allocated and doesn't persist
// If heap-allocated, use buffer clearing
}
public void SecureBufferClear(byte[] buffer)
{
// Zero out buffer contents
Array.Clear(buffer, 0, buffer.Length);
// Or use CryptoOperations.ZeroMemory(buffer);
}// ❌ BAD – Password remains in memory (string is immutable)
public void ProcessPassword_Bad(string password)
{
// String is immutable – it remains in memory until GC
var hash = ComputeHash(password);
// password cannot be securely cleared
}
// ✅ GOOD – Use SecureString or char arrays that can be zeroed
public void ProcessPassword_Good()
{
using var securePassword = new SecureString();
// Append characters to SecureString
foreach (char c in ReadPasswordFromConsole())
{
securePassword.AppendChar(c);
}
// Use the password
var hash = ComputeHash(securePassword);
// SecureString automatically zeroes memory when disposed
}
// ✅ GOOD – Use char array and clear it
public void ProcessPassword_CharArray()
{
char[] password = ReadPasswordFromConsole().ToCharArray();
try
{
var hash = ComputeHash(password);
}
finally
{
// Explicitly zero the password buffer
Array.Clear(password, 0, password.Length);
}
}
// ✅ BEST – Using Span<T> for memory safety
public void ProcessSensitiveData(ReadOnlySpan<char> sensitiveData)
{
// Use the data
var hash = ComputeHash(sensitiveData);
// Span is stack-allocated and doesn't persist
// If heap-allocated, use buffer clearing
}
public void SecureBufferClear(byte[] buffer)
{
// Zero out buffer contents
Array.Clear(buffer, 0, buffer.Length);
// Or use CryptoOperations.ZeroMemory(buffer);
}Integration with Threat Modeling
Remember our threat modeling outputs from Article #1? Here's how they map to secure coding practices in C#:
When STRIDE identified Spoofing as a threat, your code must implement OAuth2, JWT validation, and strong session management. In .NET, this means using Microsoft.AspNetCore.Authentication.JwtBearer and proper token validation.
When Tampering was identified, your code should use HMAC signatures, digital signatures, and enforce TLS 1.3. In .NET, use System.Security.Cryptography.HMACSHA256 for message integrity.
When Repudiation was a concern, implement comprehensive audit logging with tamper-proof storage. Use Serilog or NLog with structured logging to capture security events.
When Information Disclosure was identified, encrypt data at rest using AES-256 via System.Security.Cryptography.Aes, encrypt in transit with TLS via HttpClientHandler configuration, and implement output encoding for all user-generated content.
When Denial of Service was identified, implement input validation, rate limiting with AspNetCoreRateLimit, memory limits using MemoryPool, and circuit breakers with Polly.
When Elevation of Privilege was identified, implement input validation (to prevent SQLi), parameterized queries (using Dapper or EF Core), and role-based access control (RBAC) with [Authorize(Roles="Admin")].
What's Next?
In the next article, we'll cover Supply Chain & Dependency Management — securing the vast ecosystem of third-party code you rely on. We'll discuss:
- Software Bill of Materials (SBOM)
- Dependency scanning (SCA)
- Private registries
- Container security
References
- OWASP Top 10 (2023) — Injection, XSS, Security Misconfiguration
- OWASP Cheat Sheets — Input Validation, SQL Injection, Secrets Management
- NIST SP 800–63B — Password hashing and authentication
- Microsoft Security Documentation — ASP.NET Core security best practices
- CWE-89 (SQL Injection), CWE-79 (XSS), CWE-798 (Hardcoded Credentials)