Skip to main content
ANVISoftware Solutions
Lesson 16 of 18Advanced19 min

Authorizing Requests

By the end of this lesson

Enforce what each caller may do, including ownership checks on records.

A validated token tells you who is calling. It does not tell you whether that caller may read employee 42, and nothing about the token can tell you, because the answer depends on the record.

So authorisation happens at two levels, and both are needed. The coarse level asks whether this caller may use this endpoint at all — a role, a scope, a policy. The record level asks whether this caller may touch this particular row. Endpoints that implement the first and skip the second are the most common serious flaw in real APIs, and the reason is understandable: the endpoint looks protected, because it is, at the level that is easy to see.

The vocabulary, and which level each one serves:

Claim
A statement inside the validated token — the subject's id, their roles, their scopes. All authorisation decisions start from these, and never from values in the request body or query string.
Role
A named group of people: HumanResources, DepartmentManager. Coarse by design, and useful for deciding who may reach an endpoint.
Scope
A permission granted to the calling application, such as employees.read. It limits what a client may do with a token even when the person behind it could do more.
Policy
A named rule combining requirements — authenticated, plus a scope, plus a role. Naming it once and applying it by name keeps the same rule identical across endpoints.
Resource-based check
A decision that needs the record itself: is this the caller's own record, do they manage this person, is this order from their department. It can only run after the record is loaded.
Both levels: a policy on the endpoint, and an ownership check on the record
C#
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ReadEmployees", policy => policy
        .RequireAuthenticatedUser()
        .RequireClaim("scope", "employees.read"));
});

app.MapGet("/api/employees/{id:int}", async (
    int id, ClaimsPrincipal caller, EmployeeService employees, CancellationToken ct) =>
{
    var employee = await employees.FindAsync(id, ct);
    if (employee is null)
    {
        return Results.NotFound();
    }

    if (!int.TryParse(caller.FindFirstValue("employee_id"), out var callerId))
    {
        return Results.Forbid();
    }

    var mayRead =
        employee.Id == callerId
        || employee.ManagerId == callerId
        || caller.IsInRole("HumanResources");

    return mayRead
        ? Results.Ok(employee.ToResponse())
        : Results.Forbid();
})
.RequireAuthorization("ReadEmployees");
  • RequireAuthorization("ReadEmployees") is the coarse level. A caller without the scope never reaches the handler, so the expensive work is not done for a request that was never allowed.
  • The record-level check cannot be expressed there, because it needs the employee. That is why it sits inside the handler, immediately after the record is loaded and before anything is returned.
  • callerId comes from the validated token's claims, via ClaimsPrincipal. It does not come from the route, the body or a header the caller controls — that distinction is the whole security property of this handler.
  • TryParse rather than Parse: a token without the expected claim produces a refusal rather than an exception and a 500. Failing closed is the behaviour you want when the input to a security decision is missing.
  • The three conditions are the rule in one readable expression: your own record, someone you manage, or an HR role. Written this way it can be reviewed, and the same expression belongs in a test.
  • The response is a DTO, not the entity. A caller who may read a record still may not read every field of it.

One design decision has no universally right answer: when a record exists but this caller may not see it, do you return 403 or 404? A 403 is the accurate answer and it confirms the record exists, which is itself information. A 404 reveals nothing and is a small lie to a legitimate caller who now cannot tell a missing record from a forbidden one.

Use the sensitivity of existence to decide. That employee 42 exists in your company directory is not usually a secret, so 403 is fine and more helpful. That a particular medical record or a specific customer's invoice exists can be exactly the fact worth protecting, so 404 is the safer answer there.

Whichever you choose, apply it consistently across the API and document it. An API that returns 403 on some endpoints and 404 on others for the same situation tells a caller which records exist by the difference.

Practices that keep authorisation from developing gaps:

  • Deny by default — require authorisation across the API and opt specific endpoints out, so a new endpoint is protected before anyone remembers to protect it
  • Take every identity value from the validated token, never from the request
  • Check writes as carefully as reads. An update or delete on someone else's record is worse than a read of it
  • Put the record-level check where the record is loaded, so no endpoint can skip it by accident
  • Authorise collection endpoints too. A list endpoint has to filter to what the caller may see, not return everything and rely on the client to hide rows
  • Keep the rule in one place per resource. The same ownership rule written separately in five handlers will differ in at least one of them
  • Test the refusals. A test that asserts 403 for a caller who should not have access is the only thing that keeps the check alive through later refactoring

Summary

  • Authorisation has two levels: who may use the endpoint, and who may touch this record
  • Coarse rules go in named policies; record-level rules run after the record is loaded
  • Every identity value comes from the validated token, never from the request
  • Missing ownership checks are the most common serious flaw in real APIs, because the endpoint still looks protected
  • Deny by default, authorise collections as well as single records, and test the refusals

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Add the missing check

An endpoint GET /api/orders/{id} requires authentication and returns the order for the id given. Orders belong to a department, and a caller should see only orders from their own department, unless they hold a Finance role.

Write the check, and say exactly where in the handler it goes and why.

Show solution

The check goes after the order is loaded and before anything is returned. It cannot go earlier, because the order's department is not known until the record is read, and it must not go later, because by then the data has been sent.

The caller's department comes from the validated token's claims. Taking it from a query parameter or a header would let a caller nominate the department they claim to belong to, which removes the check while appearing to implement it.

The refusal should be Forbid, or NotFound if you have decided that the existence of an order is itself sensitive. Whichever you pick, it needs to match every other endpoint in the API.

C#
var order = await orders.FindAsync(id, ct);
if (order is null)
{
    return Results.NotFound();
}

if (!int.TryParse(caller.FindFirstValue("department_id"), out var callerDepartmentId))
{
    return Results.Forbid();
}

var mayRead = order.DepartmentId == callerDepartmentId
    || caller.IsInRole("Finance");

return mayRead ? Results.Ok(order.ToResponse()) : Results.Forbid();

Think about it

Where the identity must come from

An endpoint accepts POST /api/orders with a body containing employeeId, and files the order against that employee.

Explain why taking employeeId from the body is a problem, and describe the two situations in which accepting it is legitimate.

Show solution

Taken from the body, employeeId is a value the caller chose. Any authenticated caller can file an order against anyone, and nothing in the request looks unusual.

When a caller is filing their own order, the employee id belongs in the token, not the body. Reading it from the validated claims removes the field from the contract and the problem with it.

It is legitimate to accept it when the caller is genuinely acting on behalf of someone else — an HR or administrative role placing an order for a colleague — and when the caller's relationship to that employee is checked before the order is written. It is also legitimate when the field is used only to confirm what the token says, and a mismatch is refused.

The general rule holds in both cases: identity comes from the token, and any id in the request is untrusted input that has to be authorised against the caller's claims before it is used.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

An endpoint validates the bearer token, then returns the employee whose id appears in the URL. What is missing?
Why must the caller's identity come from the token rather than from the request body?

Saved in this browser only.