# API Authorization and Security

Course: [API](https://www.devtutorials.ai/learn/building-apis) · Section: Production-Ready APIs
Source: https://www.devtutorials.ai/learn/building-apis/production-ready/authorization

> Check on every request that the caller is allowed to do this: own the record, have the role, carry the scope.

**Authorization** answers one question on every request: **Is this caller allowed to do this?**

Authentication tells the API **who** is calling. Authorization decides **what** that caller is allowed to see or do.

A valid token proves the identity of a user, but it does not automatically grant access to every resource. The API must still check ownership, roles, and permissions before allowing an action.

## Watch Asha Read Ravi's Order

The API below correctly checks that the caller has a valid token. Press **Run**.

What happens? Asha requests order `101`. Order `101` belongs to Asha, so the API returns `200 OK`. So far, everything looks correct.

Next, Asha changes the URL from `/orders/101` to `/orders/102`. Order `102` belongs to Ravi. The API still returns `200 OK`, and it exposes the order of Ravi. The token was valid. The API knew the caller was Asha. The problem is that the API never checked whether Asha was allowed to access order `102`.

**In simple words:** authentication proves who the user is. Authorization checks whether they are allowed to access a specific resource.

## TL;DR

- **Authentication** = Who are you?
- **Authorization** = What are you allowed to do?
- Missing or invalid credentials return `401 Unauthorized`.
- Ownership, role, or permission failures return `403 Forbidden`, or `404 Not Found` in some cases.
- Check authorization on every request.
- Always verify ownership before returning or modifying data.
- Take ownership information from the token, not from the request body.
- Use roles and scopes to control access to features.

## A Real-World Example

Imagine a hotel. At the front desk, the staff checks your identity and gives you a room key card. That is authentication.

The key card then controls which doors you can open. It opens your hotel room, the elevator, and the gym. It cannot open the rooms of other guests, staff-only rooms, or maintenance areas. Every door checks the card before opening.

Authorization works exactly the same way. A valid token identifies the user, but each endpoint must still decide whether access should be allowed.

## What You Will Learn

In this lesson, you will learn:

1. The difference between authentication and authorization.
2. How ownership checks prevent data leaks.
3. What Broken Object Level Authorization (BOLA) is.
4. How roles control access to privileged actions.
5. Why ownership should come from the token, not the request.
6. How scopes restrict what a token can do.

## Authentication vs Authorization

| Question | Check |
|---|---|
| Who is calling? | Authentication |
| Is the caller allowed to do this? | Authorization |
| Missing token? | `401 Unauthorized` |
| Valid token, but not allowed? | `403 Forbidden` or `404 Not Found` |

Authentication always happens first. Authorization happens after the identity of the user is known.

## Check Ownership on Every Record

The bug from the first example is called **Broken Object Level Authorization (BOLA)**. BOLA happens when an API checks that a user is signed in, but does not check whether they own the specific resource that they asked for. It is one of the most common API security vulnerabilities.

The fix is straightforward:

1. Read the user id from the token.
2. Read the owner of the resource.
3. Compare them.
4. Allow access only if they match.

Results:

### No Token

The response is `401 Unauthorized` with `WWW-Authenticate: Bearer`. The API does not know who the caller is.

### Asha Requests Order 101

The response is `200 OK`. The order belongs to Asha.

### Asha Requests Order 102

The response is `404 Not Found`. The order belongs to Ravi.

Why return `404 Not Found` instead of `403 Forbidden`? A `403` tells the caller that the resource exists. A `404` makes "not yours" and "does not exist" look exactly the same. This stops attackers from finding out which resource ids are valid.

## Roles: Staff-Only Actions

Sometimes ownership is not enough. Certain actions should only be available to users with special roles. Examples are Administrator, Manager, and Support Staff.

In Brew & Bytes, refunds can only be processed by staff members.

### Customer Token

Authentication succeeds, and the user is known. However, the role is `customer`. The API returns `403 Forbidden`.

### Admin Token

The role is `admin`. The refund succeeds, and the API returns `200 OK`.

Use a "deny by default" approach. Only users who are clearly allowed should get access. Everything else should be denied automatically.

## Never Trust the Owner in the Request Body

A common mistake is allowing the caller to specify ownership information.

Example request: `{"customerId": "customer-8", "item": "Latte"}`

What if Asha sends the id of Ravi? If the API trusts the request body, Asha could create or change records that belong to Ravi. Instead, ownership must always come from the authenticated token.

Correct approach: `Owner = token.sub`. Not: `Owner = request.customerId`

Never trust user identity information that comes from request bodies, query strings, URLs, or form fields. Identity should always come from a validated token.

## Scopes: Limit What a Token Can Do

Roles control broad access. Scopes control specific permissions.

Examples: `orders:read`, `orders:write`, `payments:read`, and `payments:refund`

Scopes allow the API to grant only the permissions that are required.

Example: the partner token contains the scope `orders:read`. The request `GET /partner/orders` succeeds. The request `POST /partner/orders` fails, because it requires the scope `orders:write`.

The API returns `403 Forbidden` with `WWW-Authenticate: Bearer error="insufficient_scope", scope="orders:write"`. The response clearly explains which permission is missing.

## Authorization Policies in ASP.NET Core

Real applications often keep authorization rules in one place by using policies. Examples are `StaffOnly`, `OrdersWrite`, and `ManagersOnly`. Policies are easy to reuse across multiple endpoints.

Good candidates for policies are roles, claims, and scopes.

Ownership checks usually stay inside the endpoint logic, because each resource has a different owner. For example, `order.CustomerId == currentUserId` must be checked using the specific resource that is being accessed.

## Browsers and CORS

Authorization protects your API. CORS solves a different problem. CORS tells browsers which websites may read responses from your API.

Examples: `https://shop.brewandbytes.in` and `https://admin.brewandbytes.in`

A browser may send a preflight request, `OPTIONS /orders`, before it sends the actual request. The API replies with the headers `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, and `Access-Control-Allow-Headers`.

### Allowed Website

The response contains the header `Access-Control-Allow-Origin`. The browser allows the JavaScript code to read the response.

### Unapproved Website

The response does not contain the CORS permission headers. The browser blocks access.

CORS is not a security system for your API. It only controls what browser-based JavaScript can read. Tools such as curl, Postman, mobile apps, and server-to-server calls ignore CORS completely. You still need authentication and authorization on every protected endpoint.

## Where You See This

- Banking applications only show accounts that belong to the signed-in user.
- GitHub uses scopes to control repository access.
- Admin dashboards use roles to restrict management features.
- E-commerce systems verify ownership before displaying orders.
- Security reviews test APIs with multiple users to identify BOLA vulnerabilities.

## Common mistakes

| Symptom | Cause | Fix |
|---|---|---|
| Browser console: blocked by CORS policy | The API sends no `Access-Control-Allow-Origin` for the origin of that page | Add the origin of the page to the allowed list |
| Users can read the orders of others by changing the id | There is no ownership check (BOLA) | Compare the owner of the record with the caller from the token |
| Customers can refund or delete menu items | Only authentication is checked | Add a role check, and return `403` |
| Orders are saved under the wrong customer | The owner is taken from the request body | Take the owner from the token |
| A `403` on a guess reveals which ids exist | `403` is used for the records of other people | Return `404` instead |
| A partner token can do everything | There are no scopes, or the scopes are never checked | Check the needed scope on each endpoint |

## Exercises

## What to Remember

- Authentication asks **who are you?** (`401`). Authorization asks **are you allowed?** (`403` or `404`).
- Check ownership on **every** record. BOLA is the number one API risk.
- Use `404` for records that the caller should not know about. Use `403` for actions that they may not do.
- The owner, the user id and the role always come from the token, never from the body.
- Give tokens only the scopes that they need, and deny by default.

**Next:** API Performance and Caching. You will make your API faster with caching headers, ETags and `304 Not Modified`.

var app = builder.Build();

// Demo tokens. Each token says WHO is calling.
var tokens = new Dictionary<string, string> { ["token-asha"] = "customer-7", ["token-ravi"] = "customer-8" };
var orders = new Dictionary<int, Order>
{
    [101] = new(101, "customer-7", "Latte", 440),
    [102] = new(102, "customer-8", "Masala Chai", 120),
};

app.MapGet("/orders/{id}", (int id, HttpContext context) =>
{
    string header = context.Request.Headers["Authorization"].ToString();
    string token = header.StartsWith("Bearer ") ? header["Bearer ".Length..] : "";
    if (!tokens.ContainsKey(token))
    {
        context.Response.Headers["WWW-Authenticate"] = "Bearer";
        return Results.StatusCode(401);
    }
    // BUG: we know who is calling, but we never check that the order is theirs.
    return orders.TryGetValue(id, out var order) ? Results.Ok(order) : Results.NotFound();
});

app.Run();

record Order(int Id, string CustomerId, string Item, int Total);`;

// Demo tokens. Each token says WHO is calling.
const tokens = new Map<string, string>([["token-asha", "customer-7"], ["token-ravi", "customer-8"]]);
const orders = new Map<number, { id: number; customerId: string; item: string; total: number }>([
  [101, { id: 101, customerId: "customer-7", item: "Latte", total: 440 }],
  [102, { id: 102, customerId: "customer-8", item: "Masala Chai", total: 120 }],
]);

const app = new Hono();

app.get("/orders/:id", (c) => {
  const header = c.req.header("Authorization") ?? "";
  const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
  if (!tokens.has(token)) {
    c.header("WWW-Authenticate", "Bearer");
    return c.body(null, 401);
  }
  // BUG: we know who is calling, but we never check that the order is theirs.
  const order = orders.get(Number(c.req.param("id")));
  return order ? c.json(order, 200) : c.body(null, 404);
});

Authorization: Bearer token-asha

###
GET /orders/102
Authorization: Bearer token-asha`;

var app = builder.Build();

// Demo tokens. Each token says WHO is calling.
var tokens = new Dictionary<string, string> { ["token-asha"] = "customer-7", ["token-ravi"] = "customer-8" };
var orders = new Dictionary<int, Order>
{
    [101] = new(101, "customer-7", "Latte", 440),
    [102] = new(102, "customer-8", "Masala Chai", 120),
};

app.MapGet("/orders/{id}", (int id, HttpContext context) =>
{
    string header = context.Request.Headers["Authorization"].ToString();
    string token = header.StartsWith("Bearer ") ? header["Bearer ".Length..] : "";
    if (!tokens.ContainsKey(token))
    {
        context.Response.Headers["WWW-Authenticate"] = "Bearer";
        return Results.StatusCode(401);
    }
    // Ownership check: the order must belong to the caller.
    // Someone else's order gets 404, so we do not even reveal that it exists.
    if (!orders.TryGetValue(id, out var order) || order.CustomerId != tokens[token])
        return Results.NotFound();
    return Results.Ok(order);
});

app.Run();

record Order(int Id, string CustomerId, string Item, int Total);`;

// Demo tokens. Each token says WHO is calling.
const tokens = new Map<string, string>([["token-asha", "customer-7"], ["token-ravi", "customer-8"]]);
const orders = new Map<number, { id: number; customerId: string; item: string; total: number }>([
  [101, { id: 101, customerId: "customer-7", item: "Latte", total: 440 }],
  [102, { id: 102, customerId: "customer-8", item: "Masala Chai", total: 120 }],
]);

const app = new Hono();

app.get("/orders/:id", (c) => {
  const header = c.req.header("Authorization") ?? "";
  const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
  if (!tokens.has(token)) {
    c.header("WWW-Authenticate", "Bearer");
    return c.body(null, 401);
  }
  // Ownership check: the order must belong to the caller.
  // Someone else's order gets 404, so we do not even reveal that it exists.
  const order = orders.get(Number(c.req.param("id")));
  if (!order || order.customerId !== tokens.get(token)) return c.body(null, 404);
  return c.json(order, 200);
});

###
GET /orders/101
Authorization: Bearer token-asha

###
GET /orders/102
Authorization: Bearer token-asha`;

var app = builder.Build();

// Each token now says who is calling AND what role they have.
var users = new Dictionary<string, User>
{
    ["token-asha"] = new("customer-7", "customer"),
    ["token-admin"] = new("staff-1", "admin"),
};
var orders = new Dictionary<int, int> { [101] = 440, [102] = 120 }; // order id -> total

app.MapPost("/orders/{id}/refund", (int id, HttpContext context) =>
{
    string header = context.Request.Headers["Authorization"].ToString();
    string token = header.StartsWith("Bearer ") ? header["Bearer ".Length..] : "";
    if (!users.TryGetValue(token, out var user))
    {
        context.Response.Headers["WWW-Authenticate"] = "Bearer";
        return Results.StatusCode(401);
    }
    // Role check: we know who you are, but only admins may refund.
    if (user.Role != "admin")
        return Results.Problem(statusCode: 403, title: "Forbidden", detail: "Only staff can refund orders.");
    if (!orders.TryGetValue(id, out var total)) return Results.NotFound();
    return Results.Ok(new { id, refunded = total, by = user.Id });
});

app.Run();

record User(string Id, string Role);`;

// Each token now says who is calling AND what role they have.
const users = new Map<string, { id: string; role: string }>([
  ["token-asha", { id: "customer-7", role: "customer" }],
  ["token-admin", { id: "staff-1", role: "admin" }],
]);
const orders = new Map<number, number>([[101, 440], [102, 120]]); // order id -> total

const app = new Hono();

app.post("/orders/:id/refund", (c) => {
  const header = c.req.header("Authorization") ?? "";
  const user = users.get(header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "");
  if (!user) {
    c.header("WWW-Authenticate", "Bearer");
    return c.body(null, 401);
  }
  // Role check: we know who you are, but only admins may refund.
  if (user.role !== "admin") {
    const problem = { type: "https://tools.ietf.org/html/rfc9110#section-15.5.4", title: "Forbidden", status: 403, detail: "Only staff can refund orders." };
    return c.body(JSON.stringify(problem), 403, { "Content-Type": "application/problem+json" });
  }
  const id = Number(c.req.param("id"));
  const total = orders.get(id);
  if (total === undefined) return c.body(null, 404);
  return c.json({ id, refunded: total, by: user.id }, 200);
});

Authorization: Bearer token-asha

###
POST /orders/101/refund
Authorization: Bearer token-admin`;

var app = builder.Build();

var tokens = new Dictionary<string, string> { ["token-asha"] = "customer-7", ["token-ravi"] = "customer-8" };
var menu = new Dictionary<string, int> { ["Latte"] = 220, ["Masala Chai"] = 120 };

app.MapPost("/orders", (NewOrder input, HttpContext context) =>
{
    string header = context.Request.Headers["Authorization"].ToString();
    string token = header.StartsWith("Bearer ") ? header["Bearer ".Length..] : "";
    if (!tokens.TryGetValue(token, out var customerId))
    {
        context.Response.Headers["WWW-Authenticate"] = "Bearer";
        return Results.StatusCode(401);
    }
    // The owner comes from the TOKEN, never from the body.
    // NewOrder has no CustomerId field, so a "customerId" in the body is ignored.
    var order = new Order(103, customerId, input.Item, menu[input.Item] * input.Quantity);
    return Results.Created("/orders/103", order);
});

app.Run();

record NewOrder(string Item, int Quantity);
record Order(int Id, string CustomerId, string Item, int Total);`;

const tokens = new Map<string, string>([["token-asha", "customer-7"], ["token-ravi", "customer-8"]]);
const menu = new Map<string, number>([["Latte", 220], ["Masala Chai", 120]]);

const app = new Hono();

app.post("/orders", async (c) => {
  const header = c.req.header("Authorization") ?? "";
  const customerId = tokens.get(header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "");
  if (!customerId) {
    c.header("WWW-Authenticate", "Bearer");
    return c.body(null, 401);
  }
  // The owner comes from the TOKEN, never from the body.
  // We copy only item and quantity, so a "customerId" in the body is ignored.
  const input = await c.req.json<{ item: string; quantity: number }>();
  const order = { id: 103, customerId, item: input.item, total: menu.get(input.item)! * input.quantity };
  c.header("Location", "/orders/103");
  return c.json(order, 201);
});

Authorization: Bearer token-asha
Content-Type: application/json

{"customerId":"customer-8","item":"Latte","quantity":1}`;

var app = builder.Build();

// A partner token carries SCOPES: a list of the exact things it may do.
var scopes = new Dictionary<string, string[]> { ["token-partner"] = new[] { "orders:read" } };

// Returns null when the caller may go on, or the error result to send back.
IResult? Require(HttpContext context, string scope)
{
    string header = context.Request.Headers["Authorization"].ToString();
    string token = header.StartsWith("Bearer ") ? header["Bearer ".Length..] : "";
    if (!scopes.TryGetValue(token, out var granted))
    {
        context.Response.Headers["WWW-Authenticate"] = "Bearer";
        return Results.StatusCode(401);
    }
    if (!granted.Contains(scope))
    {
        // 403: the token is fine, but it was not given this scope.
        context.Response.Headers["WWW-Authenticate"] = "Bearer error=\\"insufficient_scope\\", scope=\\"" + scope + "\\"";
        return Results.StatusCode(403);
    }
    return null;
}

app.MapGet("/partner/orders", (HttpContext context) =>
    Require(context, "orders:read") ?? Results.Ok(new[] { 101, 102 }));

app.MapPost("/partner/orders", (HttpContext context) =>
    Require(context, "orders:write") ?? Results.Created("/orders/103", new { id = 103 }));

app.Run();`;

// A partner token carries SCOPES: a list of the exact things it may do.
const scopes = new Map<string, string[]>([["token-partner", ["orders:read"]]]);

// Returns null when the caller may go on, or the error response to send back.
function requireScope(c: Context, scope: string) {
  const header = c.req.header("Authorization") ?? "";
  const granted = scopes.get(header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "");
  if (!granted) {
    c.header("WWW-Authenticate", "Bearer");
    return c.body(null, 401);
  }
  if (!granted.includes(scope)) {
    // 403: the token is fine, but it was not given this scope.
    c.header("WWW-Authenticate", 'Bearer error="insufficient_scope", scope="' + scope + '"');
    return c.body(null, 403);
  }
  return null;
}

const app = new Hono();

app.get("/partner/orders", (c) => requireScope(c, "orders:read") ?? c.json([101, 102], 200));

app.post("/partner/orders", (c) => {
  const denied = requireScope(c, "orders:write");
  if (denied) return denied;
  c.header("Location", "/orders/103");
  return c.json({ id: 103 }, 201);
});

Authorization: Bearer token-partner

###
POST /partner/orders
Authorization: Bearer token-partner
Content-Type: application/json

{"item":"Latte","quantity":1}`;

var app = builder.Build();

// Only our own web shop may call this API from a browser.
var allowedOrigins = new HashSet<string> { "https://shop.brewandbytes.in" };

app.Use(async (context, next) =>
{
    string origin = context.Request.Headers["Origin"].ToString();
    bool allowed = allowedOrigins.Contains(origin);

    if (allowed)
    {
        // Tell the browser: this page may read my reply.
        context.Response.Headers["Access-Control-Allow-Origin"] = origin;
        context.Response.Headers["Vary"] = "Origin";
    }

    // The browser's "may I?" question before a POST with JSON (a preflight).
    if (context.Request.Method == "OPTIONS")
    {
        if (allowed)
        {
            context.Response.Headers["Access-Control-Allow-Methods"] = "GET, POST";
            context.Response.Headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization";
            context.Response.Headers["Access-Control-Max-Age"] = "600";
        }
        context.Response.StatusCode = 204;
        return;
    }

    await next();
});

app.MapGet("/menu", () => new[] { new { id = 1, name = "Latte", price = 220 } });

app.Run();`;

// Only our own web shop may call this API from a browser.
const allowedOrigins = new Set(["https://shop.brewandbytes.in"]);

const app = new Hono();

app.use("*", async (c, next) => {
  const origin = c.req.header("Origin") ?? "";
  const allowed = allowedOrigins.has(origin);

  // The browser's "may I?" question before a POST with JSON (a preflight).
  if (c.req.method === "OPTIONS") {
    if (allowed) {
      c.header("Access-Control-Allow-Origin", origin);
      c.header("Vary", "Origin");
      c.header("Access-Control-Allow-Methods", "GET, POST");
      c.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
      c.header("Access-Control-Max-Age", "600");
    }
    return c.body(null, 204);
  }

  await next();

  if (allowed) {
    // Tell the browser: this page may read my reply.
    c.header("Access-Control-Allow-Origin", origin);
    c.header("Vary", "Origin");
  }
});

app.get("/menu", (c) => c.json([{ id: 1, name: "Latte", price: 220 }], 200));

Origin: https://shop.brewandbytes.in
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type

###
GET /menu
Origin: https://shop.brewandbytes.in

###
GET /menu
Origin: https://evil.example`;

  {
    prompt: "Decide() allows everything. Make it follow the rules: anyone may read-menu, only admin may refund or delete-menu, and everything else is denied.",
    hint: "Check the allowed cases first and return allow. End with return deny, so that unknown actions are denied by default.",
    explain: "Deny by default is the safe choice. A new action such as drop-database is blocked until someone writes a rule for it.",
    csharp: { starter: `// Decide whether a role may do an action.
// Rules: anyone may "read-menu"; only "admin" may "refund" or "delete-menu".
// Anything not in the rules is denied (deny by default).
string Decide(string role, string action)
{
    // TODO: this allows everything. Return "allow" only when the rules say so,
    // and "deny" for everything else.
    return "allow";
}

var cases = new[] { ("customer", "read-menu"), ("customer", "refund"), ("admin", "refund"), ("customer", "delete-menu"), ("admin", "delete-menu"), ("admin", "drop-database") };
foreach (var (role, action) in cases)
    Console.WriteLine($"{role,-9} {action,-14} -> {Decide(role, action)}");`, solution: `// Decide whether a role may do an action.
// Rules: anyone may "read-menu"; only "admin" may "refund" or "delete-menu".
// Anything not in the rules is denied (deny by default).
string Decide(string role, string action)
{
    if (action == "read-menu") return "allow";
    if ((action == "refund" || action == "delete-menu") && role == "admin") return "allow";
    return "deny";
}

var cases = new[] { ("customer", "read-menu"), ("customer", "refund"), ("admin", "refund"), ("customer", "delete-menu"), ("admin", "delete-menu"), ("admin", "drop-database") };
foreach (var (role, action) in cases)
    Console.WriteLine($"{role,-9} {action,-14} -> {Decide(role, action)}");`, mode: "console" },
    react: { starter: `// Decide whether a role may do an action.
// Rules: anyone may "read-menu"; only "admin" may "refund" or "delete-menu".
// Anything not in the rules is denied (deny by default).
function decide(role: string, action: string): string {
  // TODO: this allows everything. Return "allow" only when the rules say so,
  // and "deny" for everything else.
  return "allow";
}

const cases: [string, string][] = [["customer", "read-menu"], ["customer", "refund"], ["admin", "refund"], ["customer", "delete-menu"], ["admin", "delete-menu"], ["admin", "drop-database"]];
for (const [role, action] of cases) {
  console.log(\`\${role.padEnd(9)} \${action.padEnd(14)} -> \${decide(role, action)}\`);
}`, solution: `// Decide whether a role may do an action.
// Rules: anyone may "read-menu"; only "admin" may "refund" or "delete-menu".
// Anything not in the rules is denied (deny by default).
function decide(role: string, action: string): string {
  if (action === "read-menu") return "allow";
  if ((action === "refund" || action === "delete-menu") && role === "admin") return "allow";
  return "deny";
}

const cases: [string, string][] = [["customer", "read-menu"], ["customer", "refund"], ["admin", "refund"], ["customer", "delete-menu"], ["admin", "delete-menu"], ["admin", "drop-database"]];
for (const [role, action] of cases) {
  console.log(\`\${role.padEnd(9)} \${action.padEnd(14)} -> \${decide(role, action)}\`);
}`, mode: "console" }
  },
  {
    prompt: "Any signed-in customer can delete menu items. Make DELETE /menu/{id} admin-only: 403 Problem Details for customers, 204 for the admin.",
    hint: "After you find the user, check user.Role (user.role in TS). If it is not admin, return the 403 problem before you delete anything.",
    explain: "The token proves who Asha is, but that is not enough. The role check runs before the delete, so a customer changes nothing.",
    csharp: { starter: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var users = new Dictionary<string, User>
{
    ["token-asha"] = new("customer-7", "customer"),
    ["token-admin"] = new("staff-1", "admin"),
};
var menu = new Dictionary<int, string> { [1] = "Latte", [2] = "Masala Chai", [3] = "Blueberry Muffin", [4] = "Cold Coffee" };

app.MapDelete("/menu/{id}", (int id, HttpContext context) =>
{
    string header = context.Request.Headers["Authorization"].ToString();
    string token = header.StartsWith("Bearer ") ? header["Bearer ".Length..] : "";
    if (!users.TryGetValue(token, out var user))
    {
        context.Response.Headers["WWW-Authenticate"] = "Bearer";
        return Results.StatusCode(401);
    }
    // TODO: any signed-in customer can delete menu items. Only "admin" may.
    // Return Results.Problem(statusCode: 403, title: "Forbidden", detail: "Only staff can change the menu.")
    return menu.Remove(id) ? Results.NoContent() : Results.NotFound();
});

app.Run();

record User(string Id, string Role);`, solution: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var users = new Dictionary<string, User>
{
    ["token-asha"] = new("customer-7", "customer"),
    ["token-admin"] = new("staff-1", "admin"),
};
var menu = new Dictionary<int, string> { [1] = "Latte", [2] = "Masala Chai", [3] = "Blueberry Muffin", [4] = "Cold Coffee" };

app.MapDelete("/menu/{id}", (int id, HttpContext context) =>
{
    string header = context.Request.Headers["Authorization"].ToString();
    string token = header.StartsWith("Bearer ") ? header["Bearer ".Length..] : "";
    if (!users.TryGetValue(token, out var user))
    {
        context.Response.Headers["WWW-Authenticate"] = "Bearer";
        return Results.StatusCode(401);
    }
    if (user.Role != "admin")
        return Results.Problem(statusCode: 403, title: "Forbidden", detail: "Only staff can change the menu.");
    return menu.Remove(id) ? Results.NoContent() : Results.NotFound();
});

app.Run();

record User(string Id, string Role);`, mode: "aspnet", requests: `DELETE /menu/1
Authorization: Bearer token-asha

###
DELETE /menu/2
Authorization: Bearer token-admin` },
    react: { starter: `import { Hono } from "hono";

const users = new Map<string, { id: string; role: string }>([
  ["token-asha", { id: "customer-7", role: "customer" }],
  ["token-admin", { id: "staff-1", role: "admin" }],
]);
const menu = new Map<number, string>([[1, "Latte"], [2, "Masala Chai"], [3, "Blueberry Muffin"], [4, "Cold Coffee"]]);

const app = new Hono();

app.delete("/menu/:id", (c) => {
  const header = c.req.header("Authorization") ?? "";
  const user = users.get(header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "");
  if (!user) {
    c.header("WWW-Authenticate", "Bearer");
    return c.body(null, 401);
  }
  // TODO: any signed-in customer can delete menu items. Only "admin" may.
  // Return a 403 Problem Details: title "Forbidden", detail "Only staff can change the menu."
  return menu.delete(Number(c.req.param("id"))) ? c.body(null, 204) : c.body(null, 404);
});

const users = new Map<string, { id: string; role: string }>([
  ["token-asha", { id: "customer-7", role: "customer" }],
  ["token-admin", { id: "staff-1", role: "admin" }],
]);
const menu = new Map<number, string>([[1, "Latte"], [2, "Masala Chai"], [3, "Blueberry Muffin"], [4, "Cold Coffee"]]);

const app = new Hono();

app.delete("/menu/:id", (c) => {
  const header = c.req.header("Authorization") ?? "";
  const user = users.get(header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "");
  if (!user) {
    c.header("WWW-Authenticate", "Bearer");
    return c.body(null, 401);
  }
  if (user.role !== "admin") {
    const problem = { type: "https://tools.ietf.org/html/rfc9110#section-15.5.4", title: "Forbidden", status: 403, detail: "Only staff can change the menu." };
    return c.body(JSON.stringify(problem), 403, { "Content-Type": "application/problem+json" });
  }
  return menu.delete(Number(c.req.param("id"))) ? c.body(null, 204) : c.body(null, 404);
});

Authorization: Bearer token-asha

###
DELETE /menu/2
Authorization: Bearer token-admin` },
    checks: `DELETE /menu/3
Authorization: Bearer token-asha

###
DELETE /menu/4
Authorization: Bearer token-admin

###
DELETE /menu/2
Authorization: Bearer token-admin`
  },
  {
    prompt: "Asha can read the order 102 of Ravi. Add an ownership check to GET /orders/{id}, so that the orders of other customers return 404.",
    hint: "Compare order.CustomerId with tokens[token]. Treat a missing order and the order of someone else in the same way.",
    explain: "Returning 404 for both cases means that an attacker cannot even learn which order ids exist. This is the fix for BOLA, the top item in the OWASP API Security Top 10.",
    csharp: { starter: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Demo tokens. Each token says WHO is calling.
var tokens = new Dictionary<string, string> { ["token-asha"] = "customer-7", ["token-ravi"] = "customer-8" };
var orders = new Dictionary<int, Order>
{
    [101] = new(101, "customer-7", "Latte", 440),
    [102] = new(102, "customer-8", "Masala Chai", 120),
};

app.MapGet("/orders/{id}", (int id, HttpContext context) =>
{
    string header = context.Request.Headers["Authorization"].ToString();
    string token = header.StartsWith("Bearer ") ? header["Bearer ".Length..] : "";
    if (!tokens.ContainsKey(token))
    {
        context.Response.Headers["WWW-Authenticate"] = "Bearer";
        return Results.StatusCode(401);
    }
    // TODO: Asha can read Ravi's order 102. Add an ownership check:
    // if the order is missing OR order.CustomerId is not the caller, return 404.
    return orders.TryGetValue(id, out var order) ? Results.Ok(order) : Results.NotFound();
});

app.Run();

record Order(int Id, string CustomerId, string Item, int Total);`, solution: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Demo tokens. Each token says WHO is calling.
var tokens = new Dictionary<string, string> { ["token-asha"] = "customer-7", ["token-ravi"] = "customer-8" };
var orders = new Dictionary<int, Order>
{
    [101] = new(101, "customer-7", "Latte", 440),
    [102] = new(102, "customer-8", "Masala Chai", 120),
};

app.MapGet("/orders/{id}", (int id, HttpContext context) =>
{
    string header = context.Request.Headers["Authorization"].ToString();
    string token = header.StartsWith("Bearer ") ? header["Bearer ".Length..] : "";
    if (!tokens.ContainsKey(token))
    {
        context.Response.Headers["WWW-Authenticate"] = "Bearer";
        return Results.StatusCode(401);
    }
    // Ownership check: the order must belong to the caller.
    // Someone else's order gets 404, so we do not even reveal that it exists.
    if (!orders.TryGetValue(id, out var order) || order.CustomerId != tokens[token])
        return Results.NotFound();
    return Results.Ok(order);
});

app.Run();

record Order(int Id, string CustomerId, string Item, int Total);`, mode: "aspnet", requests: `GET /orders/101
Authorization: Bearer token-asha

###
GET /orders/102
Authorization: Bearer token-asha` },
    react: { starter: `import { Hono } from "hono";

// Demo tokens. Each token says WHO is calling.
const tokens = new Map<string, string>([["token-asha", "customer-7"], ["token-ravi", "customer-8"]]);
const orders = new Map<number, { id: number; customerId: string; item: string; total: number }>([
  [101, { id: 101, customerId: "customer-7", item: "Latte", total: 440 }],
  [102, { id: 102, customerId: "customer-8", item: "Masala Chai", total: 120 }],
]);

const app = new Hono();

app.get("/orders/:id", (c) => {
  const header = c.req.header("Authorization") ?? "";
  const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
  if (!tokens.has(token)) {
    c.header("WWW-Authenticate", "Bearer");
    return c.body(null, 401);
  }
  // TODO: Asha can read Ravi's order 102. Add an ownership check:
  // if the order is missing OR order.customerId is not the caller, return 404.
  const order = orders.get(Number(c.req.param("id")));
  return order ? c.json(order, 200) : c.body(null, 404);
});

// Demo tokens. Each token says WHO is calling.
const tokens = new Map<string, string>([["token-asha", "customer-7"], ["token-ravi", "customer-8"]]);
const orders = new Map<number, { id: number; customerId: string; item: string; total: number }>([
  [101, { id: 101, customerId: "customer-7", item: "Latte", total: 440 }],
  [102, { id: 102, customerId: "customer-8", item: "Masala Chai", total: 120 }],
]);

const app = new Hono();

app.get("/orders/:id", (c) => {
  const header = c.req.header("Authorization") ?? "";
  const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
  if (!tokens.has(token)) {
    c.header("WWW-Authenticate", "Bearer");
    return c.body(null, 401);
  }
  // Ownership check: the order must belong to the caller.
  // Someone else's order gets 404, so we do not even reveal that it exists.
  const order = orders.get(Number(c.req.param("id")));
  if (!order || order.customerId !== tokens.get(token)) return c.body(null, 404);
  return c.json(order, 200);
});

Authorization: Bearer token-asha

###
GET /orders/102
Authorization: Bearer token-asha` },
    checks: `GET /orders/102
Authorization: Bearer token-ravi

###
GET /orders/101
Authorization: Bearer token-ravi

###
GET /orders/999
Authorization: Bearer token-asha`
  }
];
