.NET & C# Development · Lesson 41 of 92
Milestone: Secure the Entire OrderFlow API End-to-End
What We're Securing
The OrderFlow API has endpoints for orders, products, and admin operations. By the end of this milestone, it will have:
- JWT auth with refresh tokens on all protected endpoints
- Role-based access:
Admincan delete and manage;Userreads their own orders - API key auth for the webhook receiver endpoint
- Rate limiting on auth endpoints
- CORS scoped to the React frontend origin
- Security headers middleware (CSP, HSTS, X-Frame-Options)
- Global exception handler that never leaks stack traces
- Audit logging on all write operations
Complete Program.cs Security Setup
C#
// Program.cs
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
// ── Authentication ──────────────────────────────────────────────────────────
var jwtSettings = builder.Configuration.GetSection("Jwt");
var signingKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtSettings["SecretKey"]!));
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtSettings["Issuer"],
ValidateAudience = true,
ValidAudience = jwtSettings["Audience"],
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = signingKey,
ClockSkew = TimeSpan.FromSeconds(30)
};
opt.Events = new JwtBearerEvents
{
OnChallenge = ctx =>
{
ctx.HandleResponse();
ctx.Response.StatusCode = 401;
ctx.Response.ContentType = "application/problem+json";
return ctx.Response.WriteAsJsonAsync(new
{
type = "https://tools.ietf.org/html/rfc9110#section-15.5.2",
title = "Unauthorized",
status = 401
});
}
};
});
// ── Authorization Policies ──────────────────────────────────────────────────
builder.Services.AddAuthorization(opt =>
{
opt.AddPolicy("AdminOnly", p => p.RequireRole("Admin"));
opt.AddPolicy("UserOrAdmin", p => p.RequireRole("User", "Admin"));
opt.AddPolicy("ApiKey", p => p.AddRequirements(new ApiKeyRequirement()));
});
builder.Services.AddSingleton<IAuthorizationHandler, ApiKeyHandler>();
// ── Rate Limiting ───────────────────────────────────────────────────────────
builder.Services.AddRateLimiter(o =>
{
o.AddFixedWindowLimiter("auth", cfg =>
{
cfg.Window = TimeSpan.FromMinutes(1);
cfg.PermitLimit = 10;
cfg.QueueLimit = 0;
});
o.AddFixedWindowLimiter("api", cfg =>
{
cfg.Window = TimeSpan.FromSeconds(10);
cfg.PermitLimit = 100;
});
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// ── CORS ────────────────────────────────────────────────────────────────────
builder.Services.AddCors(opt =>
opt.AddPolicy("Frontend", policy =>
policy.WithOrigins(builder.Configuration["Cors:AllowedOrigin"]!)
.WithMethods("GET", "POST", "PUT", "PATCH", "DELETE")
.WithHeaders("Content-Type", "Authorization")
.WithExposedHeaders("X-Pagination")
.AllowCredentials()));
// ── Problem Details ─────────────────────────────────────────────────────────
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddControllers();
builder.Services.AddScoped<AuditService>();
var app = builder.Build();
// ── Middleware Order Matters ────────────────────────────────────────────────
app.UseExceptionHandler(); // must be first
app.UseSecurityHeaders(); // custom middleware below
app.UseHttpsRedirection();
app.UseCors("Frontend");
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();JWT + Refresh Token Service
C#
// Services/TokenService.cs
public class TokenService
{
private readonly IConfiguration _cfg;
private readonly AppDbContext _db;
public TokenService(IConfiguration cfg, AppDbContext db)
{
_cfg = cfg; _db = db;
}
public string GenerateAccessToken(ApplicationUser user, IList<string> roles)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id),
new(ClaimTypes.Email, user.Email!),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_cfg["Jwt:SecretKey"]!));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _cfg["Jwt:Issuer"],
audience: _cfg["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(15), // short-lived
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public async Task<string> GenerateRefreshTokenAsync(string userId)
{
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
_db.RefreshTokens.Add(new RefreshToken
{
Token = token,
UserId = userId,
ExpiresAt = DateTime.UtcNow.AddDays(30),
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
return token;
}
public async Task<(string AccessToken, string RefreshToken)?> RefreshAsync(string refreshToken)
{
var stored = await _db.RefreshTokens
.Include(t => t.User)
.FirstOrDefaultAsync(t => t.Token == refreshToken
&& !t.IsRevoked
&& t.ExpiresAt > DateTime.UtcNow);
if (stored is null) return null;
stored.IsRevoked = true; // rotate: old token invalidated
var user = stored.User!;
var roles = await _userManager.GetRolesAsync(user);
var newAccess = GenerateAccessToken(user, roles);
var newRefresh = await GenerateRefreshTokenAsync(user.Id);
await _db.SaveChangesAsync();
return (newAccess, newRefresh);
}
}Role-Based Access on Endpoints
C#
[ApiController]
[Route("api/orders")]
[Authorize] // all endpoints require auth
public class OrdersController : ControllerBase
{
[HttpGet]
[Authorize(Policy = "UserOrAdmin")]
public async Task<IActionResult> GetMyOrders()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
// Users only see their own orders; Admins see all
var query = _db.Orders.AsQueryable();
if (!User.IsInRole("Admin"))
query = query.Where(o => o.UserId == userId);
return Ok(await query.AsNoTracking().ToListAsync());
}
[HttpDelete("{id}")]
[Authorize(Policy = "AdminOnly")]
public async Task<IActionResult> Delete(Guid id)
{
var order = await _db.Orders.FindAsync(id);
if (order is null) return NotFound();
_db.Orders.Remove(order);
await _db.SaveChangesAsync();
return NoContent();
}
}API Key Auth for the Webhook Receiver
C#
// Authorization/ApiKeyRequirement.cs
public class ApiKeyRequirement : IAuthorizationRequirement { }
public class ApiKeyHandler : AuthorizationHandler<ApiKeyRequirement>
{
private readonly IConfiguration _cfg;
public ApiKeyHandler(IConfiguration cfg) => _cfg = cfg;
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext ctx, ApiKeyRequirement req)
{
if (ctx.Resource is HttpContext http)
{
var provided = http.Request.Headers["X-Api-Key"].FirstOrDefault();
var expected = _cfg["Webhooks:ApiKey"];
if (!string.IsNullOrEmpty(provided)
&& CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(provided),
Encoding.UTF8.GetBytes(expected!)))
ctx.Succeed(req);
}
return Task.CompletedTask;
}
}
// Usage
[HttpPost("webhooks/incoming")]
[Authorize(Policy = "ApiKey")]
public IActionResult ReceiveWebhook([FromBody] WebhookPayload payload) { ... }Security Headers Middleware
C#
// Middleware/SecurityHeadersMiddleware.cs
public class SecurityHeadersMiddleware
{
private readonly RequestDelegate _next;
public SecurityHeadersMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext ctx)
{
var h = ctx.Response.Headers;
h["X-Content-Type-Options"] = "nosniff";
h["X-Frame-Options"] = "DENY";
h["X-XSS-Protection"] = "0"; // disabled in favour of CSP
h["Referrer-Policy"] = "strict-origin-when-cross-origin";
h["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()";
h["Content-Security-Policy"] =
"default-src 'none'; frame-ancestors 'none'";
if (!ctx.Request.IsHttps)
h.Remove("Strict-Transport-Security");
else
h["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains";
await _next(ctx);
}
}
public static class SecurityHeadersExtensions
{
public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app)
=> app.UseMiddleware<SecurityHeadersMiddleware>();
}Global Exception Handler (No Stack Traces)
C#
// Middleware/GlobalExceptionHandler.cs
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _log;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> log) => _log = log;
public async ValueTask<bool> TryHandleAsync(
HttpContext ctx, Exception ex, CancellationToken ct)
{
_log.LogError(ex, "Unhandled exception on {Method} {Path}",
ctx.Request.Method, ctx.Request.Path);
var (status, title) = ex switch
{
KeyNotFoundException => (404, "Resource not found"),
UnauthorizedAccessException => (403, "Forbidden"),
BadHttpRequestException e => (e.StatusCode, e.Message),
_ => (500, "An unexpected error occurred")
};
ctx.Response.StatusCode = status;
await ctx.Response.WriteAsJsonAsync(new
{
type = $"https://httpstatuses.io/{status}",
title,
status,
traceId = ctx.TraceIdentifier
// NO: ex.Message, ex.StackTrace, inner exceptions
}, ct);
return true;
}
}Audit Logging on All Mutations
C#
// Services/AuditService.cs
public class AuditService
{
private readonly AppDbContext _db;
private readonly IHttpContextAccessor _http;
public AuditService(AppDbContext db, IHttpContextAccessor http)
{
_db = db; _http = http;
}
public async Task LogAsync(string action, string entityType, string entityId, object? changes = null)
{
var userId = _http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
_db.AuditLogs.Add(new AuditLog
{
Id = Guid.NewGuid(),
UserId = userId,
Action = action, // "Create", "Update", "Delete"
EntityType = entityType, // "Order", "Product"
EntityId = entityId,
Changes = changes is null ? null : JsonSerializer.Serialize(changes),
OccurredAt = DateTime.UtcNow,
IpAddress = _http.HttpContext?.Connection.RemoteIpAddress?.ToString()
});
await _db.SaveChangesAsync();
}
}
// Usage in controller
await _audit.LogAsync("Delete", "Order", id.ToString());Test It With curl
Bash
# 1. Login
TOKEN=$(curl -s -X POST https://localhost:5001/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"Password1!"}' \
| jq -r '.accessToken')
# 2. List orders (requires auth)
curl -s https://localhost:5001/api/orders \
-H "Authorization: Bearer $TOKEN" | jq
# 3. Delete as admin
curl -s -X DELETE https://localhost:5001/api/orders/some-guid \
-H "Authorization: Bearer $TOKEN" -w "\n%{http_code}"
# 4. Attempt without token — expect 401
curl -s https://localhost:5001/api/orders -w "\n%{http_code}"
# 5. Exceed rate limit — expect 429
for i in {1..15}; do
curl -s -X POST https://localhost:5001/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"x","password":"y"}' -o /dev/null -w "%{http_code}\n"
done
# 6. Webhook with API key
curl -s -X POST https://localhost:5001/webhooks/incoming \
-H "X-Api-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '{"event":"order.shipped"}' -w "\n%{http_code}"All 7 security layers — auth, roles, API key, rate limiting, CORS, security headers, exception handler, and audit log — are now active and tested.