When I added authentication to the task API, I thought the hard part was done. A request arrives, the JWT is validated, and now I know who the caller is. For a while that felt like the whole security story: no token, no access. Then I looked closer at what happened after a user was authenticated, and I realized the application was answering a second question I had not named yet.
An authenticated user is not the same as an authorized user. My API had two different checks, and they were not interchangeable.
Three Different Questions
Working through the code, I found three questions sitting next to each other:
Authentication -> Who are you?
Role authorization -> What kind of operation may you perform?
Ownership -> Can you access this particular resource?
Authentication answers identity, and it is what the JWT middleware does before any controller runs. Role authorization answers whether a category of user may perform an operation at all. Ownership answers whether a specific row in the database is reachable by this specific user.
The first two are about the caller. The third is about the caller’s relationship to a resource. They complement each other, and this project needs all three to describe access properly.
Where Roles Appear in My API
Role authorization is the most visible one, because it lives in attributes. The API defines one policy, AdminOnly, in Program.cs:
const string AdminPolicyName = "AdminOnly";
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(AdminPolicyName, policy =>
policy.RequireRole("Admin"));
});
The policy is just a named requirement: the authenticated user must carry the Admin role. Endpoints then refer to it by name:
[Authorize(Policy = "AdminOnly")]
[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
The user, category, and admin task endpoints use that same policy. A normal user hitting GET /api/users gets rejected before any controller code runs, because the caller does not satisfy the requirement. The question this check answers is broad: is this kind of user allowed to touch this whole group of endpoints? It says nothing about which individual records exist.
Tasks Needed a Different Check
Tasks were where the first question stopped being enough. Tasks belong to users. The admin and the regular user in my seed data both have the User or Admin role, and both can call the task endpoints, but that does not mean they should see each other’s tasks.
Demo Admin -> owns the admin's tasks
Demo User -> owns the user's tasks
Both callers pass authentication. Both pass the role check on the task endpoints, because those endpoints only require [Authorize], not AdminOnly. So if roles were the only check, either user could ask for any task id and the role check would happily allow it. The role check has no idea whose task id 10 is.
That gap is exactly the second question: this task, this user, do they belong together?
How Ownership Is Enforced
Ownership in this API is not in the controller attributes. It is in the service, inside the query. The controller first reads the user id from the token’s claims:
private int GetUserIdFromClaims()
{
var userIdClaim = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier);
if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var userId))
{
throw new UnauthorizedAccessException("User ID claim is invalid");
}
return userId;
}
Then it passes that id into the service. For a single task, the service scopes the query by both the task id and the owner:
var task = await _dbContext.Tasks
.AsNoTracking()
.FirstOrDefaultAsync(task => task.Id == id && task.UserId == userId, cancellationToken);
That && task.UserId == userId is the ownership check. It is not a separate if statement that runs after a lookup. It is part of the query, so a task that does not belong to the caller is never even loaded. The list endpoint does the same thing from the other direction:
IQueryable<TaskItem> query = _dbContext.Tasks
.AsNoTracking()
.Where(task => task.UserId == userId);
I think the placement is the interesting part. If ownership were enforced only in the controller, it would be easy for one endpoint to forget it, and the service would still return any task it was asked for. By putting the user id into the service signature and into every query, a caller cannot ask the service for a task without saying whose it is.
Git shows these two ideas arriving as separate pieces of work. a0072b7 (“associate tasks with authenticated users”) added the user id parameter to the service methods and the ownership filter. 7cdd158 (“add role-based user access”) added the Role column and the Admin requirement on the user endpoints. Later, 7d5cf90 (“use admin authorization policy”) replaced the inline [Authorize(Roles = "Admin")] attributes with the named AdminOnly policy. Ownership and roles were not a discovery sequence I invented after the fact. They were two checks the project needed, built to answer two different questions.
Role vs Ownership
Laid out side by side, the difference is easier to see:
| Question | Role | Ownership |
|---|---|---|
| What is checked? | The caller’s role claim | The relationship between caller and resource |
| Example in this API | AdminOnly on /api/users |
A user’s own task in TaskService |
| Scope | A group of endpoints | A single resource |
| Where it lives | Program.cs policy plus attributes |
The service query |
| Failure for a non-owner | Not applicable | The task is not found |
Role is a property of the caller. Ownership is a property of the connection between the caller and the data.
What Happens When a Task Belongs to Someone Else
This is where I had to be careful not to assume what a tutorial would say. If Demo User asks for a task that belongs to Demo Admin, what does the API return?
The answer in V1 is 404 Not Found, not 403 Forbidden. The reason is the query above. Because the lookup requires task.UserId == userId, the other user’s task does not match, the service returns null, and the controller maps that to not found:
var response = await _taskService.GetTaskByIdAsync(userId, id, cancellationToken);
return this.Reply(response, "Task retrieved successfully", "Task not found");
So from the caller’s point of view, a task that belongs to someone else looks exactly like a task that does not exist. The ownership boundary filters the resource out before it is ever considered, and the response is the same 404 the API uses for a missing id.
I am not going to claim this is the one correct design. Returning 404 hides the existence of other users’ resources, which some people prefer, and 403 would be more explicit about why access was denied. What matters for this article is what V1 actually does, and V1 treats another user’s task as not found, as a direct consequence of scoping the query by owner.
There is one case I want to state precisely rather than round off. The role check returns a real 403 Forbidden when an authenticated non-admin calls an admin endpoint, and that is verified by tests. The cross-user task case is different: it never reaches a role check, and it produces a 404. The two behaviors are not the same, and I would be wrong to describe them as one thing.
What the Tests Protect
Two tests make the difference concrete.
The role check is verified over real HTTP by an integration test:
[Fact]
public async Task UsersEndpoint_EnforcesAdminRole()
{
using var userClient = await CreateAuthenticatedClientAsync("user@mail.com");
using var adminClient = await CreateAuthenticatedClientAsync("admin@mail.com");
var forbiddenResponse = await userClient.GetAsync("/api/users");
var adminResponse = await adminClient.GetAsync("/api/users");
Assert.Equal(HttpStatusCode.Forbidden, forbiddenResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, adminResponse.StatusCode);
}
It proves the endpoint-level rule: a valid token with the wrong role gets 403, and the same endpoint answers the admin. This is role authorization, end to end through the pipeline.
Ownership is verified at the service level, which is where the check lives:
[Fact]
public async Task GetTaskByIdAsync_OnlyReturnsOwnedTask()
{
await using var context = TestDbContextFactory.Create();
context.Tasks.AddRange(
new TaskItem { Id = 1, UserId = 1, Title = "Owned" },
new TaskItem { Id = 2, UserId = 1, Title = "Deleted", IsDeleted = true });
await context.SaveChangesAsync();
var service = CreateService(context);
Assert.NotNull(await service.GetTaskByIdAsync(1, 1));
Assert.Null(await service.GetTaskByIdAsync(2, 1));
Assert.Null(await service.GetTaskByIdAsync(1, 2));
}
The last assertion is the ownership case. User 2 asks for task 1, which belongs to user 1, and the service returns null. The other assertions cover the soft-delete filter, a different rule in the same query.
I want to be honest about the limits of this evidence. These tests protect the specific behaviors they check, and they are not a proof that the whole API is secure. There is also a gap: I did not find an integration test that logs in as one user, requests another user’s task over HTTP, and asserts a status code. The ownership behavior is proven at the service layer, and the 404 mapping for a missing task is proven over HTTP, but the exact end-to-end cross-user response is something I reasoned about from those two pieces rather than saw asserted in a single test. I would rather say that than pretend a test exists that does not.
What I Learned
I had been treating authorization as one thing: a gate at the entrance. Once the gate lets you through, everything behind it is yours. That is only true for endpoints where the resource has no owner.
The project needed two separate checks because it had two kinds of questions. A role tells me what category of user someone is, which is enough to decide whether they may call a whole endpoint. It cannot tell me whether a particular task belongs to them, because a role is not attached to a task. Ownership is the check that answers the second question, and here it lives in the query, next to the data, where a forgotten check is much harder to miss.
The part I keep coming back to is that these are not competing approaches. Role authorization and ownership authorization sit at different layers and answer different questions. Removing either one leaves a hole. A role check with no ownership means any authenticated user can read any user’s task by guessing an id. Ownership with no role check means anyone with a valid token can reach endpoints meant only for admins.
What I actually learned was less about attributes and more about naming the questions. Authentication asks who you are. Role authorization asks what you may do. Ownership asks whether this resource is yours. Once I could say those three sentences clearly, the code I already had stopped looking arbitrary and started looking like three answers to three questions.