What’s the most common security problem I find in code reviews? It’s not some exotic cryptographic flaw. It’s string concatenation in a SQL query.
Over the years, I’ve reviewed a lot of code, and the same handful of mistakes keeps showing up. Not because developers don’t care about security, but because the dangerous patterns often look harmless. They compile, the tests pass, and the feature works. The vulnerability only reveals itself when someone sends input you didn’t expect.
Here are the four issues I flag most often, with examples of what to do instead.
1. Unvalidated Input (Especially from HTTP Requests)
The most important thing to understand about input validation is where the trust boundary is. If data comes from a user, an API caller, a query string, a form post, or a file upload, it’s untrusted. Full stop.
I’ve seen code that validates input at one layer and then passes it through three more layers where nobody checks again. I’ve also seen code where the type system provides the validation (the parameter is already an int), but the developer adds redundant parsing that doesn’t actually protect against anything.
Real input validation means checking untrusted string data before it touches your database, your file system, or your HTML output. In ASP.NET Core, model validation with data annotations is the first line of defense:
public class CreateOrderRequest
{
[Required]
[StringLength(200, MinimumLength = 1)]
public string ProductName { get; set; }
[Range(1, 10000)]
public int Quantity { get; set; }
[RegularExpression(@"^[A-Z]{2}-\d{6}$", ErrorMessage = "Invalid SKU format.")]
public string Sku { get; set; }
}
[HttpPost]
public IActionResult CreateOrder([FromBody] CreateOrderRequest request)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
// request.ProductName, Quantity, and Sku are now validated
// Proceed with business logic
}
The key principle: validate at the boundary where untrusted data enters your system. Don’t validate things the type system already guarantees, and don’t trust that a prior layer already checked.
2. SQL Injection (It’s Still Happening)
I know. It’s been on the OWASP Top 10 for over two decades. You’d think we’d be past this by now. We’re not.
The problem isn’t that developers don’t know about SQL injection in the abstract. It’s that Entity Framework and other ORMs make it easy to think you’re protected when you’re not. EF Core’s LINQ queries are parameterized by default, and that’s great. But the moment someone uses FromSqlRaw or ExecuteSqlRaw with string concatenation, the protection disappears.
Here’s the mistake I see:
// VULNERABLE: string concatenation in a raw SQL query
var results = context.Users
.FromSqlRaw($"SELECT * FROM Users WHERE LastName = '{searchTerm}'")
.ToList();
And here’s what it should look like:
// SAFE: parameterized query
var results = context.Users
.FromSqlRaw("SELECT * FROM Users WHERE LastName = {0}", searchTerm)
.ToList();
// ALSO SAFE: using FromSqlInterpolated (preferred in EF Core)
var results = context.Users
.FromSqlInterpolated($"SELECT * FROM Users WHERE LastName = {searchTerm}")
.ToList();
The difference between FromSqlRaw with an interpolated string and FromSqlInterpolated is subtle and dangerous. The first one bakes the value into the SQL string. The second one parameterizes it. If you’re going to use raw SQL in EF Core, FromSqlInterpolated is the safer choice because it’s harder to accidentally concatenate.
Better yet, stick to LINQ whenever possible. If you find yourself reaching for raw SQL frequently, it’s worth asking whether the ORM is the right tool for that particular query.
3. Leaking Information Through Error Messages
This one comes up in almost every code review where someone has written custom error handling. The instinct is reasonable: something went wrong, show the user what happened. The problem is that exception messages often contain things you don’t want an attacker to see — stack traces, database connection strings, internal file paths, table names.
Here’s the pattern I flag:
// VULNERABLE: exposes internal details to the client
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>();
if (exception != null)
{
// Never do this — exception.Error.Message can contain
// SQL errors, file paths, or connection strings
await context.Response.WriteAsync(exception.Error.Message);
}
});
});
Instead, return a generic message to the user and log the details server-side:
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>();
if (exception != null)
{
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogError(exception.Error, "Unhandled exception");
}
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { error = "An unexpected error occurred." });
});
});
In development, ASP.NET Core’s UseDeveloperExceptionPage() gives you the full stack trace in the browser. That’s fine locally. Just make sure it’s gated behind an environment check and never reaches production.
One more thing: don’t forget about the default server headers. ASP.NET Core can send headers like Server and X-Powered-By that tell an attacker exactly what stack you’re running. Strip those in production.
4. Secrets in Source Code
The last mistake I want to call out is the one that makes me most nervous when I see it: secrets committed to source control.
Connection strings, API keys, client secrets, encryption keys — I’ve found all of them in appsettings.json files checked into Git. Sometimes with comments like “TODO: move to environment variable.” The TODO never gets done, and now your production database password is in your repo history forever.
For local development, use the .NET Secret Manager:
dotnet user-secrets set "Database:ConnectionString" "Server=localhost;Database=myapp;..."
This stores secrets outside your project directory, so they never end up in source control.
For production, use a dedicated secrets manager. If you’re on Azure, that’s Azure Key Vault. AWS has Secrets Manager. The point is: secrets should be injected at runtime by infrastructure, not stored in configuration files that get committed.
// In Program.cs, pull secrets from Key Vault
builder.Configuration.AddAzureKeyVault(
new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/"),
new DefaultAzureCredential());
And please, if you find a secret in your Git history, don’t just delete it in a new commit. It’s still in the history. Rotate the secret first, then clean the history if needed.
What I Didn’t Cover
This post focused on the four issues I flag most often in reviews. But secure coding is a bigger topic than four patterns. Things I deliberately left out but you should know about:
- Cross-Site Scripting (XSS): ASP.NET Core’s Razor engine encodes output by default, which handles most cases. But if you use
Html.Rawor build HTML strings manually, you’re exposed. Know when encoding is happening and when it’s not. - Cross-Site Request Forgery (CSRF): ASP.NET Core includes anti-forgery token support out of the box. Make sure it’s enabled — it usually is if you’re using tag helpers, but verify.
- Dependency vulnerabilities: Run
dotnet list package --vulnerableregularly, enable Dependabot or GitHub’s security alerts, and don’t ignore the warnings. Supply chain attacks (like Log4Shell in the Java world) are not theoretical. - HTTPS enforcement: Call
app.UseHttpsRedirection()and configure HSTS. In production, there’s no reason to serve anything over plain HTTP.
The OWASP Top 10 is the best starting point if you want to go deeper. Their .NET Security Cheat Sheet maps each item to specific .NET guidance.


Leave a Reply