.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: Admin can delete and manage; User reads 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.