Secure coding is not a one-time task — it is a continuous practice woven into how you write and review code. The most impactful vulnerabilities in web APIs are SQL injection (bypassed by always using parameterised queries or ORM), XSS in rendered content (bypassed by proper output encoding), and insecure deserialization (bypassed by validating all inputs). In an ASP.NET Core Web API, EF Core’s parameterised queries eliminate most SQL injection risk, and structured model binding with validation eliminates most input manipulation risks. Understanding why these mitigations work and what they protect against makes them second nature.
SQL Injection Prevention
// ── SQL injection: ALWAYS use parameterised queries ───────────────────────
// ❌ CRITICAL VULNERABILITY — string concatenation in SQL
// An attacker passes: title = "'; DROP TABLE Posts; --"
public async Task<List<Post>> SearchUnsafe(string title)
{
string sql = $"SELECT * FROM Posts WHERE Title LIKE '%{title}%'"; // INJECTION!
return await _db.Posts.FromSqlRaw(sql).ToListAsync();
}
// ✅ SAFE — EF Core LINQ (always parameterised — no injection possible)
public async Task<List<Post>> SearchSafe(string title, CancellationToken ct)
=> await _db.Posts
.Where(p => p.Title.Contains(title)) // translates to WHERE Title LIKE @p0
.ToListAsync(ct);
// ✅ SAFE — raw SQL with FormattableString (parameterised)
public async Task<List<Post>> SearchWithRawSql(string title, CancellationToken ct)
=> await _db.Posts
.FromSql($"SELECT * FROM Posts WHERE Title LIKE '%' + {title} + '%'")
.ToListAsync(ct);
// EF Core automatically extracts {title} as a SqlParameter — NOT string concatenation
// ❌ WRONG — FromSqlRaw with string interpolation bypasses parameterisation
// .FromSqlRaw($"SELECT * FROM Posts WHERE Title = '{title}'") // INJECTION!
@p0, @p1 placeholders and passes values separately to the database driver. No user input ever appears directly in the SQL string. This makes EF Core LINQ the single most effective SQL injection defence in .NET: if you use LINQ for all database access, SQL injection is not possible. The only risk arises when using raw SQL with FromSqlRaw or ExecuteSqlRaw with string interpolation — use FromSql (C# interpolated string, automatically parameterised) instead of FromSqlRaw with manual string building.dotnet list package --vulnerable checks against the NuGet vulnerability database and returns non-zero on findings — add it as a CI step.[Range], [MaxLength], [RegularExpression] DataAnnotations or FluentValidation rules to enforce constraints. A route like /api/posts/{id} with id:int constraint prevents string injection at the routing level, but additional validation in the service layer is still needed for business rule checks. Defense in depth — multiple validation layers — is the correct approach.Input Validation Best Practices
// ── FluentValidation — comprehensive request validation ────────────────────
public class CreatePostRequestValidator : AbstractValidator<CreatePostRequest>
{
public CreatePostRequestValidator()
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("Title is required.")
.MaximumLength(200).WithMessage("Title must not exceed 200 characters.")
.Matches(@"^[\w\s\-\.\,\!\?]+$").WithMessage("Title contains invalid characters.");
RuleFor(x => x.Body)
.NotEmpty().WithMessage("Body is required.")
.MaximumLength(50_000).WithMessage("Body must not exceed 50,000 characters.");
RuleFor(x => x.Slug)
.NotEmpty()
.MaximumLength(100)
.Matches(@"^[a-z0-9\-]+$").WithMessage("Slug must be lowercase alphanumeric with hyphens only.");
RuleForEach(x => x.Tags)
.MaximumLength(50)
.Matches(@"^[a-z0-9\-]+$");
}
}
// ── Output encoding — prevent stored XSS when rendering user content ───────
// In Razor pages/views: @model.Title automatically HTML-encodes
// In API JSON responses: return data as-is (Angular handles encoding)
// If you generate HTML strings server-side (e.g., email templates):
using System.Text.Encodings.Web;
var encoded = HtmlEncoder.Default.Encode(userProvidedTitle);
var emailHtml = $"<h1>{encoded}</h1>";
Security Scanning in CI
// ── Add to CI pipeline (GitHub Actions, Azure DevOps) ─────────────────────
// 1. Dependency vulnerability check
// dotnet list package --vulnerable --include-transitive
// Fails if any high/critical CVEs found in dependencies
// 2. Code analysis with Roslyn Security Guards
// dotnet add package RoslynSecurityGuard
// Reports SQL injection patterns, path traversal, weak crypto in code
// 3. Secret scanning
// Use GitHub Secret Scanning or truffleHog to detect committed secrets
// Add .gitignore rules for appsettings.Production.json
// 4. SAST scanning
// Use Microsoft Security DevOps GitHub Action for comprehensive SAST
// Wraps Bandit, ESLint security, BinSkim, and other tools
// ── Minimal CI security step (dotnet) ────────────────────────────────────
// - run: dotnet restore
// - run: dotnet list package --vulnerable --include-transitive
// # Exit code 1 if vulnerabilities found — fails the build
Common Mistakes
Mistake 1 — Using FromSqlRaw with string interpolation (SQL injection)
❌ Critical vulnerability:
_db.Posts.FromSqlRaw($"SELECT * FROM Posts WHERE Title = '{userInput}'");
✅ Correct — use LINQ or FromSql (automatically parameterised interpolated string).
Mistake 2 — Not validating input length (ReDoS, memory exhaustion)
❌ Wrong — no MaxLength on string inputs; attacker sends 100MB body or catastrophic regex input.
✅ Correct — always validate MaxLength; configure request size limits in Kestrel and in model binding.