In the first post I shared a .NET 10 starter template and listed what it doesn't do yet. The biggest item on that list was this: any authenticated user could update or delete any product. This post is about fixing that, and about a question I had along the way: how does my endpoint know who the user is?
Repo: https://github.com/Fmaes2/dotnet-minimal-api-starter
The rule
- Any authenticated user can read products.
- Only the owner can update or delete a product.
- Anyone else gets
403 Forbidden.
1. Store the owner
The product gets an OwnerId, and the database enforces it with a foreign key to the Identity users table:
public class Product
{
// ...
public string OwnerId { get; set; } = "";
}
modelBuilder.Entity<Product>(e =>
{
// ...
e.HasOne<IdentityUser>()
.WithMany()
.HasForeignKey(p => p.OwnerId)
.IsRequired()
.OnDelete(DeleteBehavior.Cascade);
});
The id is a string because IdentityUser uses a string key (a GUID). IsRequired() means a product can't be saved without an owner, so forgetting to set it fails loudly instead of silently.
2. How does the endpoint know who the user is?
Here is what actually happens on a request with an Authorization: Bearer <token> header:
-
app.UseAuthentication()runs before any endpoint. It calls the JWT bearer handler. - The handler reads the header, validates the token (signature, issuer, audience, expiry) and builds a
ClaimsPrincipalfrom the token's claims. - That principal is stored in
HttpContext.User. No header, or an invalid token, leaves the user anonymous. -
app.UseAuthorization()checksRequireAuthorization()and rejects anonymous users with401before your handler runs. - When a handler declares a
ClaimsPrincipal userparameter, Minimal APIs fills it withHttpContext.User. It doesn't come from the body or the query string.
So your endpoint never reads the header. It just receives what the middleware prepared, which also means the same handler would work unchanged if you switched to cookie authentication.
To read the id from the principal I added a small extension method:
public static string GetRequiredUserId(this ClaimsPrincipal user) =>
user.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value
?? throw new InvalidOperationException("The authenticated user has no id claim.");
Depending on the handler configuration, the id can show up as sub or as NameIdentifier, so it checks both. If neither exists it throws: endpoints require authentication and the tokens always carry sub, so that would be a programming error, not a client error.
3. Check the owner in the endpoints
On create, the owner comes from the token, never from the request body. The request type doesn't even have an owner field, so nobody can create a product in someone else's name.
On update and delete:
var product = await db.Products.FindAsync(id);
if (product is null) return TypedResults.NotFound();
if (product.OwnerId != user.GetRequiredUserId()) return TypedResults.Forbid();
Why 403 and not 404? Because any authenticated user can already read every product, so the fact that it exists is not a secret. If reads were private, I'd return 404 for other people's products so the API doesn't reveal which ids exist.
The handler's return type is a Results<Ok<...>, NotFound, ForbidHttpResult> union, so OpenAPI documents all three responses and the compiler complains if a case is not handled.
4. Testing with two users
The tests that matter use two users at once:
[Fact]
public async Task Another_user_cannot_update_a_product_they_do_not_own()
{
var owner = CreateClientFor(await RegisterAndLoginAsync());
var intruder = CreateClientFor(await RegisterAndLoginAsync());
var id = await CreateProductAsync(owner);
var response = await intruder.PutAsJsonAsync($"/api/products/{id}",
new ProductRequest("Hacked", null, 1m));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var current = await owner.GetFromJsonAsync<ProductResponse>($"/api/products/{id}");
Assert.Equal("Original", current!.Name);
}
The last two lines are the important part. Checking the status code alone would pass even if the API rejected the request after saving the change. Reading the product back proves it was not modified. There's an equivalent test for delete.
Bonus: GET /api/auth/me
While I was at it, I added an endpoint that returns the id and email of the authenticated user. It reads everything from the token claims and doesn't touch the database. It's also a handy way to see exactly what is in your token.
Gotchas
- The migration adds a required column, so a database that already contains products can't take it as is. In development I recreated the database; in a real project you'd backfill an owner first.
- Listing still returns everyone's products. Making each user see only their own is a different decision, and I haven't made it yet.
- There are no roles yet, so no admin can edit other people's products.
What's next
Rate limiting on the login endpoint is next on my list. If you'd rather see something else first (refresh tokens, roles, a health check that actually checks the database), tell me in the comments or in the Discussions.
Top comments (0)