Safeguard
Security Guides

C# Secure Coding Guide: Patterns That Prevent Real Bugs

A hands-on C# secure coding guide covering input validation, safe APIs, path traversal, injection, and the language-level patterns that keep vulnerabilities out of your code.

Marcus Chen
AppSec Engineer
6 min read

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 ofUseReason
System.Random for tokensRandomNumberGeneratorCryptographically secure entropy
Manual string compare of secretsCryptographicOperations.FixedTimeEqualsConstant-time, avoids timing leaks
Concatenating HTMLRazor auto-encodingContextual output encoding
BinaryFormatterSystem.Text.JsonNo arbitrary type instantiation
Custom regex email "validation"System.Net.Mail.MailAddressFewer 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 = false and ArgumentList for any process launch
  • Path traversal blocked with GetFullPath + containment check
  • RandomNumberGenerator and FixedTimeEquals for anything secret
  • Regex over untrusted input has a timeout
  • Production error handler hides stack traces; logs scrubbed of secrets
  • #nullable enable on; overflow-sensitive math is checked

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.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.