A Layered Security Architecture for .NET Data Processing
Production security isn't a single checkpoint at the front door. It's a series of independent layers, each designed to catch what the previous one missed. If authentication is compromised, authorization should still block unauthorized actions. If authorization fails, rate limiting should constrain the damage. If rate limiting is bypassed, encryption should protect the data at rest. No single layer is sufficient on its own, and no single layer's failure should be catastrophic.
This is the approach I apply when building .NET data processing systems. This post walks through the six-layer security architecture I use, with working code for each layer and a discussion of the design decisions behind them.
The Six Layers
Request -> [1. Input Validation]
-> [2. Authentication]
-> [3. Authorization]
-> [4. Rate Limiting]
-> [5. Security Headers]
-> [6. Response Security]
-> Response
Each layer operates independently. Removing any one of them weakens the overall security but doesn't collapse the model. This independence is the defining characteristic of defense in depth. It turns security from a single point of failure into a series of obstacles that an attacker has to overcome one at a time.
Layer 1: Input Validation
Every piece of external input should be treated as hostile until proven otherwise. Validation belongs at the boundary of the system, not buried deep in the business logic where malformed data has already propagated through multiple layers:
public class InputValidator
{
public ValidationResult Validate(DataInput input)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(input.UserId))
errors.Add("UserId is required");
if (input.Query?.Length > 10_000)
errors.Add("Query exceeds maximum length");
// Reject known-dangerous patterns
if (ContainsSqlInjectionPatterns(input.Query))
errors.Add("Query contains invalid characters");
return errors.Count == 0
? ValidationResult.Success()
: ValidationResult.Failure(errors);
}
private static bool ContainsSqlInjectionPatterns(string? input)
{
if (input is null) return false;
var patterns = new[] { "';", "--", "/*", "*/", "xp_", "exec " };
return patterns.Any(p =>
input.Contains(p, StringComparison.OrdinalIgnoreCase));
}
}A common objection is that parameterized queries (which should always be used) already prevent SQL injection, making input validation redundant. This misses the point of defense in depth. Parameterized queries protect the database layer specifically. Input validation protects every downstream component from malformed data, including logging systems, message queues, third-party APIs, and any other consumer of user-provided input. Validating at the boundary is cheap insurance against input nobody planned for.
Layer 2: Authentication
For service-to-service communication, JWT tokens provide a stateless authentication mechanism with strict validation:
public class JwtAuthenticationService
{
private readonly SecurityConfig _config;
public async Task<AuthenticationResult> AuthenticateAsync(string token)
{
var handler = new JwtSecurityTokenHandler();
var parameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = _config.JwtIssuer,
ValidAudience = _config.JwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(
Convert.FromBase64String(_config.JwtSecret)),
ClockSkew = TimeSpan.FromMinutes(5)
};
try
{
var principal = handler.ValidateToken(
token, parameters, out var validatedToken);
return AuthenticationResult.Success(principal);
}
catch (SecurityTokenException ex)
{
return AuthenticationResult.Failure(ex.Message);
}
}
}Three decisions here:
- All four validation flags are set to
true. Tutorials frequently disable one or more of these for convenience during development, and that disabled flag sometimes persists into production. Each flag protects against a distinct attack vector: a forged issuer, a token intended for a different service, an expired token, or a token signed with the wrong key. Disabling any one of them creates a real vulnerability. ClockSkewis set to 5 minutes. This accommodates realistic server time drift without being so generous that expired tokens remain valid for an exploitable window. The default in many JWT libraries is also 5 minutes, but setting it explicitly documents the intent.- The catch block targets
SecurityTokenExceptionspecifically, not a bareException. Authentication failures and system errors are fundamentally different: the first is expected and should produce a clean 401 response. The second is unexpected and may warrant alerting, logging, and a 500 response. Catching broadly conflates the two.
Layer 3: Authorization
Authentication establishes who the caller is. Authorization determines what they're permitted to do:
public static class SecurityPolicies
{
public static void ConfigurePolicies(this IServiceCollection services)
{
services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireRole("Administrator"));
options.AddPolicy("DataProcessorAccess", policy =>
policy.RequireRole("Administrator", "DataProcessor", "Operator"));
options.AddPolicy("ReadOnlyAccess", policy =>
policy.RequireRole("Administrator", "DataProcessor",
"Operator", "Viewer"));
});
}
}The policies form a hierarchy: AdminOnly is the most restrictive, ReadOnlyAccess the least. Each policy is cumulative, listing all roles that should have access. Applying them to endpoints is straightforward:
[Authorize(Policy = "DataProcessorAccess")]
[HttpPost("process")]
public async Task<IActionResult> ProcessData([FromBody] DataInput input)
{
// Only Administrator, DataProcessor, and Operator roles reach here
}Defining policies as named constants in a central location, rather than scattering role strings across individual controller actions, makes the access model auditable. When a security review asks "who can access the data processing endpoint?", the answer is in one place.
Layer 4: Rate Limiting
Rate limiting constrains the volume of requests a single client can make within a time window. It serves two purposes: preventing brute-force attacks against authentication endpoints and limiting what compromised credentials can reach:
public class RateLimitingMiddleware
{
private readonly IDistributedCache _cache;
private readonly int _maxRequestsPerMinute;
public async Task InvokeAsync(HttpContext context)
{
string ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
string key = $"rate_limit:{ip}";
// IDistributedCache stores byte[], so serialization is explicit
var cached = await _cache.GetAsync(key);
var requests = cached is null
? new List<DateTime>()
: JsonSerializer.Deserialize<List<DateTime>>(cached) ?? new();
var now = DateTime.UtcNow;
requests.RemoveAll(r => r < now.AddMinutes(-1));
requests.Add(now);
await _cache.SetAsync(key, JsonSerializer.SerializeToUtf8Bytes(requests),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
});
if (requests.Count > _maxRequestsPerMinute)
{
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.Response.Headers.Append("Retry-After", "60");
return;
}
await _next(context);
}
}The sliding window approach (removing requests older than one minute, then counting the remainder) is conceptually simple and effective for moderate-throughput systems. The Retry-After header tells well-behaved clients how long to wait, reducing unnecessary retry storms.
Two things about this sample are not visible in the code. IDistributedCache stores byte[] and nothing else, so anything richer than a string gets serialized at the call site. The interface has no generic GetAsync<T>, and code that appears to call one is relying on a local extension method. The read, modify and write sequence is also not atomic, so two simultaneous requests from the same address can each read the same list and undercount. Where that matters, use the atomic increment the cache backend offers directly (INCR on Redis) rather than a list.
For high-throughput production systems, the built-in System.Threading.RateLimiting APIs introduced in .NET 7 offer more sophisticated strategies: fixed window, sliding window, token bucket, and concurrency limiters. These are designed for scenarios where the per-request overhead of maintaining timestamp lists becomes significant.
Layer 5: Security Headers
HTTP security headers instruct browsers and proxies to enforce security policies on behalf of the server. They cost effectively nothing to add and prevent entire categories of client-side attacks:
public class SecurityHeadersMiddleware
{
public async Task InvokeAsync(HttpContext context)
{
var headers = context.Response.Headers;
// Prevent XSS
headers.Append("Content-Security-Policy",
"default-src 'self'; script-src 'self'");
// Prevent clickjacking
headers.Append("X-Frame-Options", "DENY");
// Disable the legacy browser XSS filter (see the note below)
headers.Append("X-XSS-Protection", "0");
// Prevent MIME-type sniffing
headers.Append("X-Content-Type-Options", "nosniff");
// Enforce HTTPS
headers.Append("Strict-Transport-Security",
"max-age=31536000; includeSubDomains");
// Control referrer information
headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
await _next(context);
}
}X-XSS-Protection is the odd one out, and the value above is deliberate. No current browser has an XSS filter to enable: Chrome removed its XSS Auditor in 2019, Edge followed, and Firefox never shipped one. The header is non-standard and deprecated, and the filters it once switched on introduced vulnerabilities of their own, which is why the current guidance is to send 0 rather than 1; mode=block wherever the header is sent at all. Content Security Policy is what does this job now. Keep the header only because a stale 1; mode=block from an older middleware is worse than an explicit 0.
Register this middleware early in the pipeline so that it covers all responses, including error pages. An error response without security headers is still a response that a browser will render, and an attacker who can trigger a specific error page may be able to exploit the absence of CSP or X-Frame-Options on that response.
Layer 6: Data Encryption
Sensitive data (personally identifiable information, credentials, API keys) should be encrypted at rest. If a data breach occurs despite the preceding layers, encryption ensures that the exfiltrated data isn't directly usable:
public class DataEncryptionService
{
private readonly byte[] _key;
public EncryptedData Encrypt(string plaintext)
{
var nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize);
var plainBytes = Encoding.UTF8.GetBytes(plaintext);
var cipherBytes = new byte[plainBytes.Length];
var tag = new byte[AesGcm.TagByteSizes.MaxSize];
using var aes = new AesGcm(_key, tag.Length);
aes.Encrypt(nonce, plainBytes, cipherBytes, tag);
return new EncryptedData
{
Data = Convert.ToBase64String(cipherBytes),
Nonce = Convert.ToBase64String(nonce),
Tag = Convert.ToBase64String(tag),
Timestamp = DateTimeOffset.UtcNow
};
}
}Two properties make this safe, and the first one alone is not enough.
A fresh nonce per operation. Reusing a nonce with the same key is a well-documented weakness, and under GCM specifically it is catastrophic rather than merely leaky, because it exposes the authentication key as well as the plaintext relationship. The nonce is not secret and must be stored alongside the ciphertext so decryption can reconstruct the correct state.
Authentication. This is the part that a plain Aes.Create() implementation misses. Aes.Create() gives CBC with PKCS7 padding, which encrypts but does not authenticate: an attacker who can reach the stored ciphertext can modify it, and the way the padding fails on decryption is the classic padding oracle. For a threat model where the whole premise is that an attacker already has the data, confidentiality without integrity is the wrong half of the problem. AesGcm produces a tag that decryption verifies before returning anything, so tampered ciphertext throws instead of yielding plaintext.
If a design is already committed to CBC, the equivalent is to compute an HMAC over the IV and ciphertext with a separate key, and verify it before decrypting.
Parameterized Queries: Always
This isn't a layer in the architecture. It's a baseline requirement. String concatenation to build SQL queries has been exploited continuously for decades, and preventable for just as long:
// Always parameterized
public async Task<IEnumerable<DataRecord>> GetRecordsByUserAsync(
string userId, DateTime fromDate)
{
const string sql = """
SELECT Id, Content, CreatedAt, UserId
FROM DataRecords
WHERE UserId = @UserId
AND CreatedAt >= @FromDate
ORDER BY CreatedAt DESC
""";
return await _connection.QueryAsync<DataRecord>(sql,
new { UserId = userId, FromDate = fromDate });
}With Dapper or Entity Framework, parameterized queries are the default path. The risk emerges in hand-written SQL, dynamic query construction, or stored procedure invocations where someone concatenates user input into the command text. Treat any string concatenation in a SQL context as a code review finding that must be resolved before merge.
Testing Security
Security measures that aren't tested are assumptions, not guarantees. Each layer should have dedicated tests that verify both the happy path and the failure modes:
[TestMethod]
public async Task SqlInjection_IsRejectedByValidation()
{
var input = new DataInput { Query = "'; DROP TABLE Users;--" };
var result = _validator.Validate(input);
Assert.IsFalse(result.IsValid);
}
[TestMethod]
public async Task ExpiredToken_IsRejected()
{
var expiredToken = GenerateToken(expiration: DateTime.UtcNow.AddHours(-1));
var result = await _authService.AuthenticateAsync(expiredToken);
Assert.IsFalse(result.IsAuthenticated);
}
[TestMethod]
public async Task RateLimit_Returns429_WhenExceeded()
{
for (int i = 0; i < _maxRequests + 1; i++)
{
var response = await _client.GetAsync("/api/data");
if (i >= _maxRequests)
Assert.AreEqual(HttpStatusCode.TooManyRequests, response.StatusCode);
}
}These tests verify that injection patterns are caught at validation, that expired tokens are rejected by authentication, and that rate limits produce the correct HTTP response. Each test targets a single layer and validates a single failure mode, making failures diagnostic rather than ambiguous.
Summary
| Layer | Protects Against | Cost |
|---|---|---|
| Input validation | Injection, malformed data | Low, validation code only |
| Authentication | Unauthorized access | Medium, JWT infrastructure |
| Authorization | Privilege escalation | Low, policy configuration |
| Rate limiting | Brute force, denial of service | Low, middleware + cache |
| Security headers | XSS, clickjacking, MIME sniffing | Near zero, HTTP headers |
| Data encryption | Data breach exposure | Medium, key management |
The layers are independent and additive. I recommend starting with input validation and parameterized queries. They're effectively free and eliminate the most common vulnerability classes. Add authentication and authorization next, as they define the access model. Then layer on rate limiting, security headers, and encryption as the threat model demands.
The goal is not to build an impenetrable system. No such thing exists. The goal is to ensure that no single failure, no single misconfiguration, and no single compromised component gives an attacker unimpeded access to the data. Each layer buys time, limits scope, and raises the cost of an attack.