Secure C# is less about memorizing vulnerability names and more about internalizing a handful of patterns and their anti-patterns. The compiler will happily let you concatenate a shell command, read a file at an attacker-controlled path, or return a full stack trace to the internet. This guide is organized as pairs — the unsafe pattern you'll see in real pull requests, followed by the safe version — across the flaw classes that actually reach production in .NET applications. Read it as a review checklist, not a textbook.
Validate input at the boundary, not deep in the call stack
The most common root cause of C# vulnerabilities is trusting a value because it "came from our own frontend." Treat every value crossing a trust boundary — HTTP, message queue, file, database — as hostile until proven otherwise. Prefer allow-lists over deny-lists, and validate structure (type, length, range, format) as early as possible.
// Anti-pattern: parse-then-hope
var id = long.Parse(Request.Query["id"]); // throws or overflows on bad input
// Pattern: validate and constrain at the edge
if (!long.TryParse(Request.Query["id"], out var id) || id <= 0)
return Results.BadRequest("invalid id");
For richer models, use data annotations plus explicit model validation so bad requests are rejected before any business logic runs. Validation is not sanitization — encode on output for the specific sink (HTML, SQL, shell), and validate on input. Doing only one leaves a gap.
Never build SQL, shell, or paths from raw strings
Three sinks cause most injection: databases, the OS, and the filesystem. The safe pattern is the same in each case — keep untrusted data as data, never let it become code or structure.
For SQL, use parameters (or an ORM that parameterizes for you):
// Anti-pattern: string interpolation into a command
cmd.CommandText = $"SELECT * FROM Users WHERE Email = '{email}'";
// Pattern: bound parameter
cmd.CommandText = "SELECT * FROM Users WHERE Email = @email";
cmd.Parameters.AddWithValue("@email", email);
For OS commands, avoid UseShellExecute and never pass a concatenated string to a shell. Invoke the executable directly and pass arguments as a list so no shell parsing happens:
// Pattern: no shell, explicit argument list
var psi = new ProcessStartInfo("/usr/bin/convert") { UseShellExecute = false };
psi.ArgumentList.Add(userSuppliedFilename); // treated as one literal arg
Process.Start(psi);
For file paths, the classic bug is path traversal — a value like ../../etc/passwd escaping your intended directory. Canonicalize and then verify containment:
var full = Path.GetFullPath(Path.Combine(baseDir, userPath));
if (!full.StartsWith(baseDir + Path.DirectorySeparatorChar, StringComparison.Ordinal))
throw new UnauthorizedAccessException();
Prefer safe framework APIs over hand-rolled logic
.NET ships secure building blocks; the anti-pattern is reinventing them. A few high-value swaps:
| Instead of | Use | Reason |
|---|---|---|
System.Random for tokens | RandomNumberGenerator | Cryptographically secure entropy |
| Manual string compare of secrets | CryptographicOperations.FixedTimeEquals | Constant-time, avoids timing leaks |
| Concatenating HTML | Razor auto-encoding | Contextual output encoding |
BinaryFormatter | System.Text.Json | No arbitrary type instantiation |
| Custom regex email "validation" | System.Net.Mail.MailAddress | Fewer catastrophic-backtracking pitfalls |
That last row matters more than it looks: a poorly written regex over attacker-controlled input can cause ReDoS (regular-expression denial of service). Set a RegexOptions timeout, or use compiled patterns with bounded quantifiers, when a regex has to touch untrusted input.
Handle errors without leaking internals
Exceptions are information disclosure vectors. Returning a full stack trace tells an attacker your framework versions, internal namespaces, and file paths.
// Anti-pattern: echo the exception to the client
catch (Exception ex) { return Results.Text(ex.ToString()); }
// Pattern: log detail server-side, return an opaque id
catch (Exception ex) {
var errorId = Guid.NewGuid();
logger.LogError(ex, "Unhandled error {ErrorId}", errorId);
return Results.Problem(detail: $"Reference {errorId}", statusCode: 500);
}
In ASP.NET Core, wire app.UseExceptionHandler for production and reserve the developer exception page for the Development environment only. Log the details you need for forensics, but scrub secrets and PII from log entries — logs are frequently shipped to third-party systems with wide read access.
Be deliberate about nullability and integer math
C#'s nullable reference types (#nullable enable) are a security feature, not just ergonomics: many logic flaws are missing null checks that let an attacker skip an authorization branch. Turn them on solution-wide. Similarly, unchecked integer arithmetic can wrap around and defeat a bounds check; wrap security-sensitive size or index math in a checked block so an overflow throws instead of silently producing a valid-looking small number.
checked {
int total = itemCount * itemSize; // throws on overflow instead of wrapping
}
A quick C# review checklist
- Untrusted input validated (type, length, range, format) at the boundary
- No string-built SQL, shell commands, or file paths
UseShellExecute = falseandArgumentListfor any process launch- Path traversal blocked with
GetFullPath+ containment check RandomNumberGeneratorandFixedTimeEqualsfor anything secret- Regex over untrusted input has a timeout
- Production error handler hides stack traces; logs scrubbed of secrets
#nullable enableon; overflow-sensitive math ischecked
Where automated analysis fits
Manual review catches a lot, but it doesn't scale to every pull request or every transitive dependency. Static analysis flags the string-built SQL and the BinaryFormatter call; dynamic application security testing exercises the running app to confirm that a path-traversal or injection path is actually reachable through your routing and middleware; and software composition analysis covers the vulnerabilities you inherited from NuGet rather than wrote yourself. The three together give you coverage no single technique provides.
How Safeguard Helps
Safeguard connects those layers so a finding comes with context, not just a line number. When a scanner flags a potential injection or unsafe deserialization sink in your C# code, Griffin AI traces the data flow from the HTTP entry point to the sink and tells your team whether untrusted input can actually reach it — cutting the false positives that make developers ignore security tooling. For the flaws worth fixing, Safeguard's auto-fix engine proposes the corrected pattern as a reviewable pull request, whether that's parameterizing a query or swapping an unsafe API. Teams evaluating options can see how the approach compares to legacy SAST on the Safeguard vs Veracode comparison.
Create a free account at app.safeguard.sh/register and browse the language guides at docs.safeguard.sh.