DEV Community

Fmaes
Fmaes

Posted on AI-assisted

Per-user ownership in a .NET Minimal API (and how the user reaches your endpoint)

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; } = "";
}
Enter fullscreen mode Exit fullscreen mode
modelBuilder.Entity<Product>(e =>
{
    // ...
    e.HasOne<IdentityUser>()
        .WithMany()
        .HasForeignKey(p => p.OwnerId)
        .IsRequired()
        .OnDelete(DeleteBehavior.Cascade);
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. app.UseAuthentication() runs before any endpoint. It calls the JWT bearer handler.
  2. The handler reads the header, validates the token (signature, issuer, audience, expiry) and builds a ClaimsPrincipal from the token's claims.
  3. That principal is stored in HttpContext.User. No header, or an invalid token, leaves the user anonymous.
  4. app.UseAuthorization() checks RequireAuthorization() and rejects anonymous users with 401 before your handler runs.
  5. When a handler declares a ClaimsPrincipal user parameter, Minimal APIs fills it with HttpContext.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.");
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)