Build a Bookmark Manager API in .NET 9
Build a hardened REST API with rate limiting and API key auth in .NET 9.
Introduction
30 Second Summary
Every web service you use daily relies on APIs that limit how often you can call them and reject unauthorized requests. These protections are invisible when they work, but without them, any API would be vulnerable to abuse.
In this project, you will build a Bookmark Manager API from scratch using .NET 9 Minimal APIs. You will progressively harden it with input validation, structured error responses, rate limiting, and API key authentication.
What You'll Build
A locally running REST API where you can create, search, and manage bookmarks, then watch it reject excessive requests and block unauthorized callers in real time through an auto-generated OpenAPI document in your browser.
By the end of this project, you'll have:
- A Bookmark Manager API with full create, read, update, and delete operations that you can test by browsing the auto-generated OpenAPI document served at /openapi/v1.json.
- Rate limiting enforced on every endpoint, so you can send a burst of requests and watch the API return a 429 Too Many Requests response with a Retry-After header.
- API key authentication protecting write operations, where requests without a valid key receive a clean 401 Unauthorized response while read endpoints remain public.
- Secret Mission: Add a token bucket rate limiting policy for write operations and configure a restricted CORS policy.
Are there any prerequisites?
This project assumes intermediate C# knowledge. You should have Visual Studio installed and the .NET 9 SDK ready on your machine.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters to you. Taking a moment to set your intention helps you stay focused through the hands-on steps ahead.
Create the Project and Configure EF Core with SQLite
Every API needs a foundation before you can build endpoints on top of it. Your Bookmark Manager needs a running project with a database to store its data.
In this step, you'll scaffold a .NET 9 Minimal API project in Visual Studio, wire up a SQLite database using Entity Framework Core, and confirm that the built-in OpenAPI document generation is working.
In this step, get ready to:
- Create an ASP.NET Core Web API project with OpenAPI enabled.
- Install EF Core packages and define a Bookmark entity with a SQLite database.
- Run the app and confirm the OpenAPI document is served.
Create the ASP.NET Core Web API project
The ASP.NET Core Web API template gives you a Minimal API project with OpenAPI support out of the box. No external Swagger packages needed.
- Press the Windows key to open your search bar.
- Type Visual Studio and press Enter to open it.
- In the start window, click Create a new project.
- Search for ASP.NET Core Web API in the template search box.
- Select the ASP.NET Core Web API template (C#) and click Next.
- Enter BookmarkApi as the project name and click Next.
- On the Additional information page, set the Framework to .NET 9.0.
- Uncheck Use controllers (this gives you Minimal APIs).
- Make sure Enable OpenAPI support is checked.
- Click Create.
Visual Studio generates your project with a sample weatherforecast endpoint and OpenAPI already configured.
What did that just create?
The template produces a Program.cs file with a Minimal API setup, appsettings.json for configuration, and a .csproj project file.
The AddOpenApi() and MapOpenApi() calls are the native .NET 9 approach to generating an OpenAPI document. No third-party packages required.
Now install the EF Core packages your project needs for SQLite database access.
- Open the Package Manager Console by clicking Tools in the top menu bar.
- Select NuGet Package Manager, then Package Manager Console.
- Install the SQLite provider by running this command in the console:
Install-Package Microsoft.EntityFrameworkCore.Sqlite -Version 9.0.18
- Install the EF Core tools package (needed for creating migrations) by running:
Install-Package Microsoft.EntityFrameworkCore.Tools -Version 9.0.18
What are these packages?
Microsoft.EntityFrameworkCore.Sqlite lets EF Core talk to a SQLite database file. No database server to install.
Microsoft.EntityFrameworkCore.Tools gives you the Add-Migration and Update-Database commands in the Package Manager Console.
Confirm both packages installed by checking for "Successfully installed" messages in the console output.
Package install failing?
Make sure you have an internet connection and that NuGet.org is reachable. If you see a version error, confirm your project targets net9.0 in the .csproj file.
Still stuck? Help me fix my NuGet package installation error.
Before making any changes, run the project to confirm the template works out of the box.
- Press Ctrl+F5 to run the app without debugging.
- A browser window opens. Look at the URL in the address bar and note the port number (e.g. https://localhost:7xxx).
- Navigate to https://localhost:{port}/openapi/v1.json (replace {port} with your actual port number).
You should see a JSON document describing the default weatherforecast endpoint. This proves OpenAPI is working.
- Close the browser and stop the app by closing the console window.
Define the Bookmark entity and register the database
With the project confirmed working, it's time to replace the sample code with your own data model. You'll define a Bookmark entity, a DbContext that manages it, and wire everything to a SQLite database file.
- Open Program.cs in the editor (double-click it in Solution Explorer).
- Select all the existing code (Ctrl+A) and delete it.
- Replace it with the following program code:
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Services
builder.Services.AddDbContext<BookmarkDb>(opt =>
opt.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
// Ensure database is created
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BookmarkDb>();
db.Database.Migrate();
}
app.Run();
What does this code do?
AddDbContext<BookmarkDb> registers your database context with dependency injection, reading the connection string from configuration.
The using (var scope = ...) block runs any pending migrations automatically when the app starts. This creates (or updates) the SQLite database file for you.
- Below app.Run();, add the Bookmark entity class:
// Entity
class Bookmark
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public string Category { get; set; } = "Uncategorized";
public DateTime CreatedAt { get; set; }
}
What does this code do?
This class defines the shape of a single bookmark record in the database. Each property maps to a column. EF Core uses Id as the primary key by convention.
Default values like string.Empty and "Uncategorized" prevent null reference issues when creating new bookmarks.
- Below the Bookmark class, add the DbContext class with seed data:
// DbContext
class BookmarkDb : DbContext
{
public BookmarkDb(DbContextOptions<BookmarkDb> options) : base(options) { }
public DbSet<Bookmark> Bookmarks => Set<Bookmark>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Bookmark>().HasData(
new Bookmark { Id = 1, Title = "Microsoft Learn", Url = "https://learn.microsoft.com", Category = "Documentation", CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc) },
new Bookmark { Id = 2, Title = "GitHub", Url = "https://github.com", Category = "Development", CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc) },
new Bookmark { Id = 3, Title = "NuGet Gallery", Url = "https://nuget.org", Category = "Development", CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc) }
);
}
}
What does this code do?
BookmarkDb is your database context. It tells EF Core about your Bookmarks table and how to create it.
The HasData() call seeds three bookmarks into the database when the migration runs. This means your API returns real data immediately on first launch.
- Save Program.cs (Ctrl+S).
Now add the SQLite connection string to your configuration.
- Open appsettings.json in Solution Explorer.
- Replace the entire contents with:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Data Source=bookmarks.db"
}
}
What does this connection string do?
Data Source=bookmarks.db tells SQLite to create a file called bookmarks.db in your project's output directory. No database server to install or configure.
- Save appsettings.json (Ctrl+S).
With the entity and context defined, create the initial EF Core migration that builds your database schema.
- In the Package Manager Console, create the migration by running:
Add-Migration InitialCreate
You should see output ending with a message confirming the migration was scaffolded. A new Migrations folder appears in Solution Explorer with your migration files.
Build errors when creating the migration?
The Add-Migration command compiles your project first. If you see build errors, check that Program.cs has no typos and that both NuGet packages installed successfully.
Make sure the using Microsoft.EntityFrameworkCore; directive is at the top of Program.cs.
Help me fix my EF Core migration error.
✔️ Awesome, I've got everything!
Great. Make sure you've saved both Program.cs and appsettings.json.
ⓧ I'd like to double check the full code
Your Program.cs should contain these sections in order:
- The using directive, builder setup, services, app pipeline, and database migration block.
- The Bookmark entity class with five properties.
- The BookmarkDb DbContext class with seed data for three bookmarks.
Scroll up and confirm each chunk matches what you pasted. Your appsettings.json should look like this:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Data Source=bookmarks.db"
}
}
Run the app and verify the OpenAPI document
Everything is wired up. Time to prove it works.
- Before you run this, what do you think will happen when the app starts for the first time with the db.Database.Migrate() call?
- Press Ctrl+F5 to run the app.
- Navigate to https://localhost:{port}/openapi/v1.json in your browser (use the same port from earlier).
You should see a JSON OpenAPI document. The paths object will be empty because you haven't added any endpoints yet. The important thing is that the document loads successfully.
The db.Database.Migrate() call created bookmarks.db and applied your migration (including the three seed bookmarks) automatically on startup.
Seeing a build error or blank page?
If the project won't build, check that the ConnectionStrings section in appsettings.json has valid JSON syntax (no trailing commas, matching braces).
If the browser shows a blank page or 404, make sure the URL path is exactly /openapi/v1.json (case-sensitive).
Help me fix my app startup error.
Your project is set up with a working database and OpenAPI document generation. Next up, you'll build the CRUD endpoints that let you create, read, update, and delete bookmarks through this API.
Build the CRUD Endpoints
Your project runs and serves an OpenAPI document, but there are no bookmark endpoints yet. The whole point of this API is to let clients create, read, update, and delete bookmarks.
In this step, you will build five endpoints behind a single route group. Route groups let you define a shared URL prefix once and apply cross-cutting concerns (like rate limiting or auth) to every endpoint in the group later.
In this step, get ready to:
- Define a route group at /api/bookmarks with GET, POST, PUT, and DELETE endpoints.
- Confirm the seed data from Step 1 populates the API on first run.
- Test all five endpoints and verify correct HTTP status codes.
Create the route group and GET all endpoint
A route group defines a shared URL prefix so every endpoint inside it inherits that prefix automatically. You will start with the GET endpoint that lists all bookmarks, plus the data-transfer records your endpoints need.
- In Program.cs, find the line that says app.Run(); and add the following record definitions directly above it, but below the closing } of the migration block:
// Records
record CreateBookmarkRequest(string Title, string Url, string? Category);
record UpdateBookmarkRequest(string Title, string Url, string? Category);
record BookmarkDto(int Id, string Title, string Url, string Category, DateTime CreatedAt);
What are these records?
Records define the shape of data flowing in and out of your API. CreateBookmarkRequest and UpdateBookmarkRequest describe what clients send. BookmarkDto describes what clients receive back.
Separating these from the Bookmark entity means you control exactly which fields are exposed over HTTP.
- Now, move those records BELOW app.Run(); (between app.Run(); and the // Entity comment). In C# top-level statements, type declarations must appear after all executable statements.
- Next, add the route group and GET all endpoint ABOVE app.Run(); by pasting this code after the migration block's closing }:
// Endpoints
var bookmarks = app.MapGroup("/api/bookmarks");
bookmarks.MapGet("/", async (BookmarkDb db, string? category) =>
{
var query = db.Bookmarks.AsQueryable();
if (!string.IsNullOrWhiteSpace(category))
{
query = query.Where(b => b.Category == category);
}
return TypedResults.Ok(await query.Select(b => new BookmarkDto(b.Id, b.Title, b.Url, b.Category, b.CreatedAt)).ToListAsync());
})
.WithName("GetBookmarks");
What does this code do?
app.MapGroup("/api/bookmarks") creates a route group. Every endpoint you attach to bookmarks automatically gets the /api/bookmarks prefix.
The GET handler accepts an optional category query parameter. If provided, results are filtered. TypedResults.Ok(...) returns HTTP 200 and automatically feeds response metadata into the OpenAPI document.
- Save Program.cs.
- Run the app with Ctrl+F5.
- Open your browser and navigate to https://localhost:{port}/api/bookmarks (use the port shown in your console output).
You should see a JSON array containing three seeded bookmarks: Microsoft Learn, GitHub, and NuGet Gallery.
Where does this data come from?
In Step 1, you configured HasData(...) inside OnModelCreating. When db.Database.Migrate() runs at startup, EF Core applies the migration that inserts those three rows. The seed data is already working for you.
Seeing an empty array or a 404?
- If you see [], delete the bookmarks.db file in your project folder and re-run. The migration will recreate the database with seed data.
- If you see a 404, double-check the URL path is /api/bookmarks (not /api/bookmark without the s).
- Make sure the route group code is placed above app.Run();.
help me fix my GET /api/bookmarks endpoint
Add the GET by ID endpoint
- In Program.cs, add the following code directly below the .WithName("GetBookmarks"); line:
bookmarks.MapGet("/{id:int}", async (int id, BookmarkDb db) =>
{
var bookmark = await db.Bookmarks.FindAsync(id);
return bookmark is null
? Results.Problem(statusCode: 404, title: "Bookmark not found")
: Results.Ok(new BookmarkDto(bookmark.Id, bookmark.Title, bookmark.Url, bookmark.Category, bookmark.CreatedAt));
})
.WithName("GetBookmarkById");
What does this code do?
The /{id:int} route constraint ensures only integer IDs match this endpoint. If no bookmark is found, Results.Problem(...) returns a structured error response instead of a silent null.
- Save Program.cs and re-run the app with Ctrl+F5.
- Navigate to https://localhost:{port}/api/bookmarks/1 in your browser.
You should see a single JSON object for the Microsoft Learn bookmark.
Build the write endpoints
Read endpoints are working. Now you need POST, PUT, and DELETE so clients can create, update, and remove bookmarks.
- In Program.cs, add the POST endpoint directly below the .WithName("GetBookmarkById"); line:
bookmarks.MapPost("/", async (CreateBookmarkRequest request, BookmarkDb db) =>
{
var bookmark = new Bookmark
{
Title = request.Title,
Url = request.Url,
Category = request.Category ?? "Uncategorized",
CreatedAt = DateTime.UtcNow
};
db.Bookmarks.Add(bookmark);
await db.SaveChangesAsync();
return Results.Created($"/api/bookmarks/{bookmark.Id}",
new BookmarkDto(bookmark.Id, bookmark.Title, bookmark.Url, bookmark.Category, bookmark.CreatedAt));
})
.WithName("CreateBookmark");
What does this code do?
The POST handler deserializes the request body into a CreateBookmarkRequest record. It creates a new Bookmark entity, saves it to SQLite, and returns HTTP 201 Created with a Location header pointing to the new resource.
If the client omits Category, it defaults to "Uncategorized".
- Save Program.cs and re-run with Ctrl+F5.
- Open a new PowerShell terminal and test the POST endpoint by running:
Invoke-RestMethod -Method Post -Uri "https://localhost:{port}/api/bookmarks" -ContentType "application/json" -Body '{"title": "Stack Overflow", "url": "https://stackoverflow.com", "category": "Community"}'
You should see a response object containing the new bookmark with an Id of 4 and the Category set to Community.
Getting a connection error?
- Replace {port} in the URI with the actual port number shown in your Visual Studio console output (e.g., 7042).
- If PowerShell rejects the self-signed certificate, add -SkipCertificateCheck to the command.
help me test my POST endpoint in PowerShell
- Now add the PUT endpoint below .WithName("CreateBookmark");:
bookmarks.MapPut("/{id:int}", async (int id, UpdateBookmarkRequest request, BookmarkDb db) =>
{
var bookmark = await db.Bookmarks.FindAsync(id);
if (bookmark is null)
{
return Results.Problem(statusCode: 404, title: "Bookmark not found");
}
bookmark.Title = request.Title;
bookmark.Url = request.Url;
bookmark.Category = request.Category ?? bookmark.Category;
await db.SaveChangesAsync();
return Results.NoContent();
})
.WithName("UpdateBookmark");
- Add the DELETE endpoint below .WithName("UpdateBookmark");:
bookmarks.MapDelete("/{id:int}", async (int id, BookmarkDb db) =>
{
var bookmark = await db.Bookmarks.FindAsync(id);
if (bookmark is null)
{
return Results.Problem(statusCode: 404, title: "Bookmark not found");
}
db.Bookmarks.Remove(bookmark);
await db.SaveChangesAsync();
return Results.NoContent();
})
.WithName("DeleteBookmark");
What do PUT and DELETE do?
Both endpoints look up the bookmark by ID first. If it does not exist, they return a structured 404 error using Results.Problem(...). PUT replaces the bookmark fields with new values. DELETE removes the bookmark entirely. Both return HTTP 204 No Content on success.
- Save Program.cs.
Test all five endpoints
Before you run this, think about what you expect to happen when you send a DELETE for bookmark ID 1, then try to GET that same ID.
- Re-run the app with Ctrl+F5.
- Test PUT by running:
Invoke-RestMethod -Method Put -Uri "https://localhost:{port}/api/bookmarks/1" -ContentType "application/json" -Body '{"title": "Microsoft Learn Docs", "url": "https://learn.microsoft.com", "category": "Documentation"}'
- Confirm it returns no content (empty response with 204 status).
- Verify the update took effect by navigating to https://localhost:{port}/api/bookmarks/1 in your browser. The title should now show Microsoft Learn Docs.
- Test DELETE by running:
Invoke-RestMethod -Method Delete -Uri "https://localhost:{port}/api/bookmarks/1"
- Navigate to https://localhost:{port}/api/bookmarks/1 again. You should see a JSON problem response with a 404 status and the title Bookmark not found.
- Finally, verify the category filter by navigating to https://localhost:{port}/api/bookmarks?category=Development. You should see only the GitHub and NuGet Gallery bookmarks.
PUT or DELETE returning 405 Method Not Allowed?
- Make sure the PUT and DELETE code blocks are attached to the bookmarks variable (not app).
- Confirm the route template uses /{id:int} with the colon constraint.
- Check that all five endpoint blocks appear above app.Run();.
help me debug my PUT or DELETE endpoint returning 405
✔️ Awesome, I've got everything!
Great. Double check you have saved Program.cs and all five endpoints are responding correctly.
ⓧ I'd like to double check the full code
Your Program.cs should contain these sections in order. Scroll up through your file and confirm each is present:
- Using directive: using Microsoft.EntityFrameworkCore;
- Builder and services (AddDbContext, AddOpenApi).
- App build, MapOpenApi(), UseHttpsRedirection().
- Migration scope block.
- Route group: var bookmarks = app.MapGroup("/api/bookmarks");
- Five endpoints: GetBookmarks, GetBookmarkById, CreateBookmark, UpdateBookmark, DeleteBookmark.
- app.Run();
- Three records: CreateBookmarkRequest, UpdateBookmarkRequest, BookmarkDto.
- Bookmark entity class and BookmarkDb context with HasData seed.
You now have a fully working CRUD API backed by SQLite. Next up, you will add validation and structured error handling so the API rejects bad input with clean, machine-readable responses instead of crashing.
Add Validation and Structured Error Handling
Your CRUD endpoints work, but they accept anything you throw at them. Send an empty title or a malformed URL and the API either saves garbage data or throws an unhelpful 500 error.
Real APIs validate input and return consistent, machine-readable error responses. In this step, you will add the RFC 7807 ProblemDetails standard to your API so every error response follows the same predictable shape.
In this step, get ready to:
- Register ProblemDetails services and middleware for structured error responses.
- Add input validation to POST and PUT endpoints with clear field-level errors.
- Test that invalid requests return a proper 400 response with machine-readable error details.
Register ProblemDetails services and middleware
ProblemDetails is an industry standard (RFC 7807) that defines a JSON shape for API errors. Instead of every endpoint inventing its own error format, ProblemDetails gives you a consistent structure with type, title, status, and errors fields.
- In Program.cs, find the services section where you have builder.Services.AddOpenApi();.
- Add the following line directly below it:
builder.Services.AddProblemDetails();
What does AddProblemDetails do?
This registers the IProblemDetailsService with the dependency injection container. ASP.NET Core middleware can now automatically format error responses into the RFC 7807 JSON structure instead of returning empty bodies or plain-text messages.
- Now find the middleware section where you have app.UseHttpsRedirection();.
- Add two new middleware calls directly below it:
app.UseStatusCodePages();
app.UseExceptionHandler();
What do these middleware lines do?
- UseStatusCodePages() catches responses that have an error status code (4xx/5xx) but no body, and fills in a ProblemDetails JSON body automatically.
- UseExceptionHandler() catches unhandled exceptions in non-development environments and returns a ProblemDetails 500 response instead of leaking a stack trace.
- Save Program.cs.
- Run the app with Ctrl+F5.
- In your browser, navigate to a URL that does not match any endpoint, such as https://localhost:{port}/api/nonexistent (replace {port} with the port shown in your browser or terminal output).
You should see a JSON response with a ProblemDetails body containing "status": 404 and a "title" field instead of a blank page.
Still seeing a blank page or HTML error?
- Make sure app.UseStatusCodePages(); appears after app.UseHttpsRedirection(); and before the endpoint mappings.
- Confirm you saved Program.cs before running.
Why isn't my API returning ProblemDetails JSON for 404 responses?
Add input validation to POST and PUT endpoints
Now that the error infrastructure is in place, you can write validation logic that returns structured field-level errors when clients send bad data.
- In Program.cs, scroll down to the bottom of the file and find the comment // Records.
- Add the following validation helper method directly above the // Records comment:
// Validation helpers
static Dictionary<string, string[]> ValidateCreateRequest(CreateBookmarkRequest request)
{
var errors = new Dictionary<string, string[]>();
if (string.IsNullOrWhiteSpace(request.Title))
errors["Title"] = ["Title is required."];
if (string.IsNullOrWhiteSpace(request.Url))
errors["Url"] = ["Url is required."];
else if (!Uri.TryCreate(request.Url, UriKind.Absolute, out _))
errors["Url"] = ["Url must be a valid absolute URL."];
if (request.Title?.Length > 200)
errors["Title"] = ["Title must be 200 characters or fewer."];
return errors;
}
What does this validation check?
- Title is required and cannot exceed 200 characters.
- URL is required and must be a valid absolute URI (e.g. starting with https://).
- The method returns a dictionary mapping field names to error messages. An empty dictionary means the input is valid.
- Now find the POST endpoint lambda. Look for the line:
bookmarks.MapPost("/", async (CreateBookmarkRequest request, BookmarkDb db) =>
{
- Add the validation call as the first thing inside the lambda, so it looks like this:
bookmarks.MapPost("/", async (CreateBookmarkRequest request, BookmarkDb db) =>
{
var errors = ValidateCreateRequest(request);
if (errors.Count > 0)
{
return Results.ValidationProblem(errors);
}
var bookmark = new Bookmark
What does ValidationProblem return?
Results.ValidationProblem(errors) returns an HTTP 400 response with a ProblemDetails body that includes the errors dictionary. Clients get a machine-readable list of exactly which fields failed and why.
- Save Program.cs and run the app with Ctrl+F5.
- Open a terminal (View > Terminal in Visual Studio) and test the POST endpoint with invalid data by running:
try {
Invoke-RestMethod -Uri "https://localhost:[[PORT="your localhost port number"]]/api/bookmarks" -Method POST -ContentType "application/json" -Body '{"title":"","url":"not-a-url"}'
} catch {
$_.ErrorDetails.Message
}
You should see a JSON response containing validation errors for both the Title and Url fields.
Seeing a 500 error instead of 400?
- Make sure you added the var errors = ValidateCreateRequest(request); line at the very top of the POST lambda, before the new Bookmark creation.
- Check that your validation helper is defined as a static method outside the endpoint lambdas.
Help me debug why my POST endpoint is returning 500 instead of a validation error.
- Now add the matching validation helper for the PUT endpoint. Add this method directly below ValidateCreateRequest:
static Dictionary<string, string[]> ValidateUpdateRequest(UpdateBookmarkRequest request)
{
var errors = new Dictionary<string, string[]>();
if (string.IsNullOrWhiteSpace(request.Title))
errors["Title"] = ["Title is required."];
if (string.IsNullOrWhiteSpace(request.Url))
errors["Url"] = ["Url is required."];
else if (!Uri.TryCreate(request.Url, UriKind.Absolute, out _))
errors["Url"] = ["Url must be a valid absolute URL."];
if (request.Title?.Length > 200)
errors["Title"] = ["Title must be 200 characters or fewer."];
return errors;
}
- Find the PUT endpoint lambda. Look for:
bookmarks.MapPut("/{id:int}", async (int id, UpdateBookmarkRequest request, BookmarkDb db) =>
{
var bookmark = await db.Bookmarks.FindAsync(id);
if (bookmark is null)
{
return Results.Problem(statusCode: 404, title: "Bookmark not found");
}
- Add the validation call directly after the null check, so the middle of the PUT lambda looks like this:
var errors = ValidateUpdateRequest(request);
if (errors.Count > 0)
{
return Results.ValidationProblem(errors);
}
bookmark.Title = request.Title;
- Save Program.cs.
Test with invalid data
Before you run the final test, think about what you expect to happen when you send a POST with an empty title and a malformed URL.
- What status code do you expect? What fields should appear in the error response?
- Run the app with Ctrl+F5.
- In the terminal, send a request with deliberately invalid data:
try {
Invoke-RestMethod -Uri "https://localhost:[[PORT="your localhost port number"]]/api/bookmarks" -Method POST -ContentType "application/json" -Body '{"title":"","url":"not-a-url"}'
} catch {
$_.ErrorDetails.Message
}
You should see an HTTP 400 response with a JSON body like this:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Title": ["Title is required."],
"Url": ["Url must be a valid absolute URL."]
}
}
What does this response tell you?
- type links to the HTTP spec section describing 400 Bad Request.
- title gives a human-readable summary of the problem.
- status confirms the HTTP status code as an integer.
- errors maps each field name to an array of error messages. Clients can use this to highlight specific form fields.
✔️ Awesome, I've got everything!
Great. Double check you have saved Program.cs and the validation is working for both POST and PUT.
ⓧ I'd like to double check the full code
Your Program.cs is now quite long. Scroll through and confirm these sections are present in this order:
- Service registration: AddDbContext, AddOpenApi(), AddProblemDetails().
- Middleware: UseHttpsRedirection(), UseStatusCodePages(), UseExceptionHandler().
- Database migration block.
- Route group /api/bookmarks with GET all, GET by ID, POST (with ValidateCreateRequest call), PUT (with ValidateUpdateRequest call), and DELETE.
- Validation helpers: ValidateCreateRequest and ValidateUpdateRequest static methods.
- Records: CreateBookmarkRequest, UpdateBookmarkRequest, BookmarkDto.
- Entity class Bookmark and BookmarkDb DbContext with seed data.
Your API now rejects bad input with clear, structured errors that any client can parse. Next up, you will add rate limiting to prevent clients from hammering the API with unlimited requests.
Implement Rate Limiting
Your CRUD endpoints work and return structured errors, but there is nothing stopping a client from sending thousands of requests per second. A single abusive caller could exhaust your server's resources and degrade the experience for everyone else.
ASP.NET Core ships with built-in rate limiting middleware that lets you enforce request quotas with zero external dependencies. In this step, you will configure a fixed window limiter that caps requests at 10 per minute and returns a proper HTTP 429 response when the limit is exceeded.
In this step, get ready to:
- Configure the fixed window rate limiter service with a rejection callback.
- Add the rate limiting middleware to the pipeline and apply it to the route group.
- Test by exceeding the request quota and observing a 429 response.
Add the rate limiter service configuration
The rate limiting middleware needs two things: a policy that defines the rules, and a rejection handler that tells clients when they have been throttled. You will register both inside AddRateLimiter in the service collection.
- In Program.cs, find the existing using statement at the top of the file:
using Microsoft.EntityFrameworkCore;
- Add this second using directive directly below it:
using System.Threading.RateLimiting;
- Now find the line builder.Services.AddProblemDetails(); and add the following rate limiter configuration directly below it:
// Rate Limiting
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = (context, cancellationToken) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter =
((int)retryAfter.TotalSeconds).ToString();
}
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
return new ValueTask();
};
options.AddFixedWindowLimiter("fixed", opt =>
{
opt.PermitLimit = 10;
opt.Window = TimeSpan.FromSeconds(60);
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
opt.QueueLimit = 0;
});
});
What does this code do?
RejectionStatusCode overrides the default status (which is 503 Service Unavailable) to the correct 429 Too Many Requests.
OnRejected fires whenever a request is rejected. It reads the RetryAfter metadata from the rate limiter's lease and sets a Retry-After response header so the client knows how long to wait.
AddFixedWindowLimiter creates a policy named "fixed" that allows 10 requests per 60-second window with no queuing. Once a client exceeds 10 requests in the window, every subsequent request is immediately rejected.
- Save Program.cs.
- Build the project by pressing Ctrl+Shift+B in Visual Studio. Confirm the build succeeds with no errors in the Output window.
Build failing with a missing namespace?
Make sure the using System.Threading.RateLimiting; directive is at the very top of Program.cs, alongside the existing using Microsoft.EntityFrameworkCore; line. The QueueProcessingOrder and MetadataName types live in that namespace.
help me fix a namespace error in my rate limiter configuration
Enable the middleware and apply it to the route group
Registering the service is only half the picture. You also need to insert the rate limiting middleware into the request pipeline and tell it which endpoints to protect.
- In Program.cs, find this line in the middleware pipeline:
app.UseExceptionHandler();
- Add app.UseRateLimiter(); on a new line directly below it, so the pipeline now reads:
app.UseExceptionHandler();
app.UseRateLimiter();
Why this position in the pipeline?
UseRateLimiter must be called after routing is resolved so the middleware knows which endpoint the request targets. In Minimal APIs without an explicit UseRouting call, routing runs at the beginning of the pipeline by default. Placing the rate limiter after UseExceptionHandler ensures rejected requests still get proper error handling.
- Now find the route group declaration:
var bookmarks = app.MapGroup("/api/bookmarks");
- Add .RequireRateLimiting("fixed") to apply the policy to all endpoints in the group, so the line becomes:
var bookmarks = app.MapGroup("/api/bookmarks")
.RequireRateLimiting("fixed");
- Save Program.cs.
- Run the project with Ctrl+F5. Confirm the app starts without errors and you can still reach GET /api/bookmarks in your browser.
App won't start after adding UseRateLimiter?
Verify that app.UseRateLimiter() appears after app.UseExceptionHandler() and before the database migration block. Also confirm the policy name "fixed" in RequireRateLimiting exactly matches the name in AddFixedWindowLimiter.
help me debug why my app crashes after enabling rate limiting
Test rate limiting by exceeding the quota
The policy allows 10 requests per 60-second window. You can prove it works by firing off more than 10 requests in quick succession and watching the response change.
Before you run the test, what do you think will happen to requests 11 and 12?
- With the app still running, open a new PowerShell terminal.
- Send 12 rapid requests to the bookmarks endpoint by running this command:
1..12 | ForEach-Object { Invoke-WebRequest https://localhost:[[PORT="your localhost port number"]]/api/bookmarks }
The first 10 requests succeed with status 200. Requests 11 and 12 fail with HTTP 429 Too Many Requests and a Retry-After header telling the client how many seconds to wait.
What is the Retry-After header?
Retry-After is a standard HTTP response header that tells clients exactly how many seconds to wait before sending another request. Well-behaved clients read this value and back off automatically, preventing further wasted round trips.
All 12 requests succeed?
If every request returns 200, the rate limiter is not being applied. Confirm that app.UseRateLimiter() is present in the middleware pipeline and that the route group has .RequireRateLimiting("fixed") chained to it. Also make sure you restarted the app after saving your changes.
help me figure out why my rate limiter is not rejecting requests
✔️ Awesome, I've got everything!
Great. Double check you have saved Program.cs and that your app is running with the rate limiter active.
ⓧ I'd like to double check the full code
Your Program.cs is now longer than 30 lines, so here is a checklist of the pieces you should see, in order from top to bottom:
- Two using directives: Microsoft.EntityFrameworkCore and System.Threading.RateLimiting.
- Service registrations: AddDbContext, AddOpenApi, AddProblemDetails.
- The full AddRateLimiter block with RejectionStatusCode, OnRejected, and AddFixedWindowLimiter("fixed", ...).
- Middleware pipeline: UseHttpsRedirection, UseStatusCodePages, UseExceptionHandler, then UseRateLimiter.
- Route group: app.MapGroup("/api/bookmarks").RequireRateLimiting("fixed").
- All five endpoint definitions (GET /, GET /{id}, POST /, PUT /{id}, DELETE /{id}) unchanged from the previous step.
- Validation helpers, records, entity, and DbContext unchanged.
Your API now enforces a hard request ceiling. Next up, you will secure the write endpoints with API key authentication so only authorized clients can create, update, or delete bookmarks.
Secure the API with API Key Authentication
You proved in the last step that your API can limit traffic. But right now, anyone who knows the URL can create, update, or delete bookmarks without restriction.
API key authentication fixes this by requiring a secret header on write operations. Read endpoints stay public so consumers can browse bookmarks freely. This is the pattern used for internal and service-to-service APIs.
In this step, get ready to:
- Create a custom authentication handler that validates an API key from a request header.
- Register the handler and protect write endpoints while keeping read endpoints public.
- Test that unauthenticated POST requests are rejected with 401 Unauthorized.
Create the API key authentication handler
ASP.NET Core lets you write custom authentication handlers by extending the AuthenticationHandler<T> base class. Your handler will inspect every incoming request for an X-Api-Key header and compare its value against a key stored in configuration.
- In Program.cs, replace the existing using statements at the top of the file with these:
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.RateLimiting;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Http.HttpResults;
What are these new namespaces?
Microsoft.AspNetCore.Authentication provides the AuthenticationHandler base class. System.Security.Claims provides ClaimsPrincipal for representing the authenticated user. System.Text.Encodings.Web and Microsoft.Extensions.Options are required by the handler's constructor signature.
- Scroll to the very bottom of Program.cs, below the BookmarkDb class. Add the authentication handler class:
// Authentication handler
class ApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private const string ApiKeyHeaderName = "X-Api-Key";
public ApiKeyAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder) : base(options, logger, encoder) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(ApiKeyHeaderName, out var apiKeyHeaderValues))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var providedApiKey = apiKeyHeaderValues.ToString();
var configuredApiKey = Context.RequestServices
.GetRequiredService<IConfiguration>()
.GetValue<string>("Authentication:ApiKey");
if (string.IsNullOrEmpty(configuredApiKey) || providedApiKey != configuredApiKey)
{
return Task.FromResult(AuthenticateResult.Fail("Invalid API key."));
}
var claims = new[] { new Claim(ClaimTypes.Name, "ApiKeyUser") };
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
What does this handler do?
- If the X-Api-Key header is missing, it returns AuthenticateResult.NoResult() so the request continues unauthenticated.
- If the header is present but the value does not match the configured key, it returns AuthenticateResult.Fail() to reject the request.
- If the key matches, it builds a ClaimsPrincipal and returns AuthenticateResult.Success() so the request is treated as authenticated.
- Save Program.cs.
- Press Ctrl+F5 to run the app and confirm it still compiles and starts without errors.
Seeing a compilation error?
- Verify that all eight using statements are at the top of Program.cs and that you removed the old two-line set.
- Make sure the handler class is placed outside and below the BookmarkDb class, not nested inside it.
Help me fix this compilation error in my API key authentication handler.
Register authentication and protect write endpoints
The handler exists, but ASP.NET Core does not know about it yet. You need to register it as an authentication scheme, add authorization services, update the middleware pipeline, and store the actual API key in configuration.
- In Program.cs, find the closing of the rate limiter configuration (the }); after AddFixedWindowLimiter). Add the following authentication and authorization registration directly below it:
// Authentication
builder.Services.AddAuthentication("ApiKey")
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>("ApiKey", null);
builder.Services.AddAuthorization();
What does this registration do?
AddAuthentication("ApiKey") sets the default scheme name. AddScheme tells ASP.NET Core to use your ApiKeyAuthenticationHandler class whenever the "ApiKey" scheme is challenged. AddAuthorization() enables the authorization system so you can mark specific endpoints as protected.
- In the middleware pipeline section, find the line app.UseRateLimiter(); and add the two authentication and authorization middleware calls directly below it:
app.UseAuthentication();
app.UseAuthorization();
- Save Program.cs.
Now configure the actual API key value. The handler reads from Authentication:ApiKey in configuration, so you need to add it to appsettings.json.
- Open appsettings.json and replace its contents with the following (adds the Authentication section):
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Data Source=bookmarks.db"
},
"Authentication": {
"ApiKey": "my-secret-api-key-change-me"
}
}
Why store the key in appsettings.json?
For a production app, you would use User Secrets or an environment variable so the key never ends up in source control. For this learning project, storing it in appsettings.json keeps things simple and visible.
- Save appsettings.json.
- Press Ctrl+F5 to run the app and verify it still starts. The authentication middleware is active but no endpoints require authorization yet.
Now protect the write endpoints. You only want POST, PUT, and DELETE to require a valid key. GET endpoints remain open.
- In Program.cs, find the MapPost endpoint chain. Locate the line:
.WithName("CreateBookmark");
- Add .RequireAuthorization() before .WithName("CreateBookmark") so the chain now looks like this:
.RequireAuthorization()
.WithName("CreateBookmark");
- Do the same for the MapPut endpoint. Add .RequireAuthorization() before .WithName("UpdateBookmark"):
.RequireAuthorization()
.WithName("UpdateBookmark");
- Do the same for the MapDelete endpoint. Add .RequireAuthorization() before .WithName("DeleteBookmark"):
.RequireAuthorization()
.WithName("DeleteBookmark");
- Save Program.cs.
Test the authentication
Time to prove the lockdown works. You will send a POST request without the API key header and watch it get rejected.
- Press Ctrl+F5 to run the app.
- Note the port number from the browser URL (e.g. https://localhost:7XXX).
Before you run the next command, predict: what HTTP status code do you think you will get if you send a POST without the X-Api-Key header?
- Open a new terminal (or the Package Manager Console) and send a POST request without an API key by running:
Invoke-WebRequest -Uri "https://localhost:{port}/api/bookmarks" -Method Post -ContentType "application/json" -Body '{"title":"Test","url":"https://example.com"}' -SkipCertificateCheck
You will see an error indicating HTTP 401 Unauthorized. That confirms unauthenticated clients cannot create bookmarks.
- Now send the same request with the correct X-Api-Key header by running:
Invoke-WebRequest -Uri "https://localhost:{port}/api/bookmarks" -Method Post -ContentType "application/json" -Headers @{"X-Api-Key"="my-secret-api-key-change-me"} -Body '{"title":"Test Auth","url":"https://example.com"}' -SkipCertificateCheck
This time you should see a 201 Created response with the new bookmark in the body.
- Confirm that GET still works without any key by running:
Invoke-WebRequest -Uri "https://localhost:{port}/api/bookmarks" -SkipCertificateCheck
You should see a 200 OK response with the full list of bookmarks, including the one you just created. Read endpoints remain public.
Getting 401 even with the header?
- Double-check that the key value in your PowerShell command matches exactly what you set in appsettings.json (my-secret-api-key-change-me).
- Make sure the header name is X-Api-Key (case-sensitive match with the constant in the handler).
- Verify you restarted the app after saving appsettings.json.
Help me debug why my API key authentication is failing.
✔️ Awesome, I've got everything!
Great. Double check you have saved both Program.cs and appsettings.json.
ⓧ I'd like to double check the full code
Your Program.cs is too long to inline. Confirm you have each of these sections in order, from top to bottom:
- Eight using statements (Microsoft.AspNetCore.Authentication through Microsoft.AspNetCore.Http.HttpResults).
- Service registrations: AddDbContext, AddOpenApi, AddProblemDetails, AddRateLimiter, then AddAuthentication("ApiKey") with AddScheme, then AddAuthorization().
- Middleware pipeline: MapOpenApi, UseHttpsRedirection, UseStatusCodePages, UseExceptionHandler, UseRateLimiter, UseAuthentication, UseAuthorization.
- Database migration block.
- Route group with RequireRateLimiting("fixed"). Two GET endpoints (no RequireAuthorization). POST, PUT, DELETE endpoints each with .RequireAuthorization() before .WithName(...).
- Validation helpers, record types, Bookmark entity, BookmarkDb context, and ApiKeyAuthenticationHandler class at the bottom.
Your appsettings.json should look like this:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Data Source=bookmarks.db"
},
"Authentication": {
"ApiKey": "my-secret-api-key-change-me"
}
}
Your API is now secured. Read operations stay open for anyone to browse, but write operations demand a valid key. Next up, you will take on a Secret Mission to add a second rate limiting policy and a CORS configuration.
Secret mission
Token Bucket Rate Limiting and CORS
You've protected the API with a fixed window limiter that applies the same quota to every endpoint. But what if write operations should be throttled more aggressively than reads? And what if you only want a specific frontend origin to call your API? Time to add a token bucket policy and a CORS restriction.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a SQLite database, so there are no ongoing costs.
Resources you used:
- Your Bookmark Manager API project folder (contains Program.cs, appsettings.json, EF Core migrations, and all source files).
- The SQLite database file bookmarks.db (stored inside the project folder).
- NuGet packages restored locally for the project (Microsoft.EntityFrameworkCore.Sqlite, Microsoft.EntityFrameworkCore.Tools).
Keep everything running
No action needed. Choose this if you're still actively building on the API or want to experiment with additional features.
- Your project folder stays where Visual Studio created it.
- The SQLite database retains all your bookmark data and seed records.
- You can reopen the solution in Visual Studio and run it any time with Ctrl+F5.
Pause - I'll come back to this later
Stop any running instances to free up system resources. Your files and database stay intact for when you return.
- If the API is still running in Visual Studio, press Shift+F5 or click the red Stop Debugging button to stop it.
- Close the solution in Visual Studio if you want to free up memory.
- Everything is saved on disk. When you come back, open the .sln file in Visual Studio to pick up where you left off.
Delete - I don't want to use this again
Remove all project files, the database, and start fresh. This cannot be undone.
- If the API is running, stop it first with Shift+F5 in Visual Studio.
- Close the solution in Visual Studio.
- Delete the entire project folder. On macOS/Linux, open a terminal and run:
rm -rf ~/path-to-your-project-folder
On Windows, open PowerShell and run:
Remove-Item -Recurse -Force "C:\path-to-your-project-folder"
- Replace the path above with the actual location where Visual Studio created your project (check File > Recent Projects in Visual Studio if you forgot where it lives).
- This removes all source code, the bookmarks.db SQLite database, EF Core migrations, and NuGet package references in one step.
What about the NuGet global cache?
NuGet stores downloaded packages in a shared global cache. Deleting your project folder removes the project-level references, but the cached package files remain. This is normal and harmless. They take up minimal disk space and speed up future projects.
Nice Work!
Nice Work!
Nice work! You just built a production-grade Bookmark Manager API with .NET 9 that handles CRUD operations, rejects abusive traffic, and locks down write access to authenticated clients.
You've learned how to:
- Build a full REST API with Minimal APIs, route groups, TypedResults, and EF Core with SQLite for zero-install persistence.
- Enforce request quotas using the built-in rate limiting middleware with a fixed window policy, proper 429 responses, Retry-After headers, and structured RFC 7807 ProblemDetails error handling throughout.
- Secure write endpoints with a custom API key authentication handler while keeping read endpoints public.
- Secret Mission: Implement a token bucket rate limiting policy for write operations and configure a restricted CORS policy.
Ready to quiz yourself?