Mert Özen Further With Every Line
Security Web Security (OWASP Top 10) — #03

OWASP A01: Broken Access Control (1) — IDOR, Admin Endpoints and Authorization in ASP.NET Core

Mert Özen Nov 6, 2026 11 min 5 views
OWASP A01: Broken Access Control (1) — IDOR, Admin Endpoints and Authorization in ASP.NET Core

Logged in as Alan, we read Grace's order by changing the order number and pull every user from an admin endpoint whose role check was forgotten. We show both flaws in the lab and close them with policies, resource-based authorization through IAuthorizationService and a deny-by-default fallback policy, and discuss choosing between 403 and 404 and the .NET 10 change to cookie redirects.

Version note: This post follows the OWASP Top 10:2025; the code was tested with ASP.NET Core 10 (.NET 10).

Guess First

We added login to the lab and put .RequireAuthorization() on the order endpoint. Alan has logged in; his own order is number 1. If Alan sends a request to /orders/2 with the same cookie, what comes back? And what about /admin/users, which was written only for the admin? Settle both answers in your head, then read on.

This post is the first half of the number one category of the OWASP Top 10:2025, A01:2025 Broken Access Control. OWASP describes this category as users being able to reach data or functions outside their permissions, and groups 40 CWEs under it. Today we look at two of them: reaching another user's record (IDOR) and an admin endpoint where the role check was forgotten. Path traversal, CORS and SSRF are the topic of the next post.

Adding Login to the Lab

We continue from the Bookstore API in the previous post. I added a password column to the users table. The passwords are stored in plain text, and that is a deliberate lab shortcut; how a password should be stored is the topic of the A04 Cryptographic Failures post. Since the table structure changed, don't forget to delete the old bookstore.db file:

            CREATE TABLE IF NOT EXISTS users (
                id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE,
                email TEXT NOT NULL, role TEXT NOT NULL, password TEXT NOT NULL);
...
            INSERT OR IGNORE INTO users VALUES
                (1, 'ada', 'ada@bookstore.test', 'admin', 'dev-only-ada'),
                (2, 'alan', 'alan@bookstore.test', 'customer', 'dev-only-alan'),
                (3, 'grace', 'grace@bookstore.test', 'customer', 'dev-only-grace');

We add three methods and three records to the BookstoreDb class. They all follow the pattern from the previous post, with parameterized queries:

    public async Task<User?> FindUserAsync(string username, string password)
    {
        await using SqliteConnection connection = Open();
        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = "SELECT id, username, email, role FROM users WHERE username = $username AND password = $password";
        command.Parameters.AddWithValue("$username", username);
        command.Parameters.AddWithValue("$password", password);
        await using SqliteDataReader reader = await command.ExecuteReaderAsync();
        return await reader.ReadAsync()
            ? new User(reader.GetInt32(0), reader.GetString(1), reader.GetString(2), reader.GetString(3))
            : null;
    }

    public async Task<List<User>> GetUsersAsync()
    {
        await using SqliteConnection connection = Open();
        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = "SELECT id, username, email, role FROM users ORDER BY id";
        await using SqliteDataReader reader = await command.ExecuteReaderAsync();
        List<User> users = [];
        while (await reader.ReadAsync())
        {
            users.Add(new User(reader.GetInt32(0), reader.GetString(1), reader.GetString(2), reader.GetString(3)));
        }
        return users;
    }

    public async Task<Order?> GetOrderAsync(int id)
    {
        await using SqliteConnection connection = Open();
        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = "SELECT id, user_id, book_id, quantity FROM orders WHERE id = $id";
        command.Parameters.AddWithValue("$id", id);
        await using SqliteDataReader reader = await command.ExecuteReaderAsync();
        return await reader.ReadAsync()
            ? new Order(reader.GetInt32(0), reader.GetInt32(1), reader.GetInt32(2), reader.GetInt32(3))
            : null;
    }

public record User(int Id, string Username, string Email, string Role);
public record Order(int Id, int UserId, int BookId, int Quantity);
public record LoginRequest(string Username, string Password);

I trimmed Program.cs down to this post's topic. For authentication we use ASP.NET Core's built-in cookie authentication; no extra package is needed. When login succeeds, the user's id, name and role are written as claims into an encrypted cookie. The first version, the one with the flaws, looks like this:

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;

var builder = WebApplication.CreateBuilder(args);

string connectionString = builder.Configuration.GetConnectionString("Bookstore")
    ?? "Data Source=bookstore.db";
builder.Services.AddSingleton(new BookstoreDb(connectionString));

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options => options.Cookie.Name = "bookstore.auth");
builder.Services.AddAuthorization();

var app = builder.Build();

app.Services.GetRequiredService<BookstoreDb>().Initialize();

app.MapPost("/login", async (LoginRequest request, BookstoreDb db, HttpContext context) =>
{
    User? user = await db.FindUserAsync(request.Username, request.Password);
    if (user is null)
    {
        return Results.Unauthorized();
    }

    List<Claim> claims =
    [
        new(ClaimTypes.NameIdentifier, user.Id.ToString()),
        new(ClaimTypes.Name, user.Username),
        new(ClaimTypes.Role, user.Role)
    ];
    ClaimsPrincipal principal = new(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme));
    await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
    return Results.NoContent();
});

app.MapGet("/books", async (BookstoreDb db) => await db.GetBooksAsync());

// Being logged in counts as enough: nobody checks which order it is
app.MapGet("/orders/{id:int}", async (int id, BookstoreDb db) =>
    await db.GetOrderAsync(id) is Order order ? Results.Ok(order) : Results.NotFound())
    .RequireAuthorization();

// Written for the admin screen, but there is no role check
app.MapGet("/admin/users", async (BookstoreDb db) => await db.GetUsersAsync())
    .RequireAuthorization();

app.Run();

There are no app.UseAuthentication() and app.UseAuthorization() lines, because WebApplication adds both middlewares by itself when those services are registered. Let's run the app with the http profile on port 5104 and log in as Alan. -c saves the cookie to a file, and -b sends it back on later requests:

$ curl -i -c alan.txt -X POST http://localhost:5104/login -H "Content-Type: application/json" -d '{"username":"alan","password":"dev-only-alan"}'
HTTP/1.1 204 No Content
Date: Fri, 06 Nov 2026 19:08:01 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Pragma: no-cache
Set-Cookie: bookstore.auth=CfDJ8EINq_wm4TFCv7guzy6SHCeyv9mL8WCX...; path=/; samesite=lax; httponly

The cookie value is a ticket encrypted with the Data Protection API; the client can't read what is inside, and if it changes it, the server rejects it. The default flags are samesite=lax and httponly. There is no secure, because the default SecurePolicy follows the request itself and we are on plain HTTP; in Production you should set it to CookieSecurePolicy.Always.

IDOR: Reading Someone Else's Order

Alan first asks for his own order, then adds one to the number and asks for Grace's:

$ curl -i -b alan.txt http://localhost:5104/orders/1
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:08:01 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"id":1,"userId":2,"bookId":1,"quantity":1}

$ curl -i -b alan.txt http://localhost:5104/orders/2
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:08:01 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"id":2,"userId":3,"bookId":2,"quantity":2}

userId is 3, which is Grace. This flaw is called IDOR (Insecure Direct Object Reference): a key sent by the client is tied directly to a database record without an ownership check. In the API world the same problem is also called BOLA (Broken Object Level Authorization). Its CWE is CWE-639, authorization bypass through a user-controlled key.

The misconception here is believing that .RequireAuthorization() does authorization. That line only asks "is this someone who has logged in?", which is the result of authentication. Nobody asks "can this person see this order?" The two questions are different, and the framework can't ask the second one for you, because only your data model knows who owns the order.

Missing Function-Level Checks: The Admin Endpoint

Let's go to the admin endpoint with the same cookie:

$ curl -i -b alan.txt http://localhost:5104/admin/users
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:08:01 GMT
Server: Kestrel
Transfer-Encoding: chunked

[{"id":1,"username":"ada","email":"ada@bookstore.test","role":"admin"},{"id":2,"username":"alan","email":"alan@bookstore.test","role":"customer"},{"id":3,"username":"grace","email":"grace@bookstore.test","role":"customer"}]

An ordinary customer got every user's email address and role. This is IDOR's sibling: a missing check not at the record level but at the function level. It usually goes like this: the Angular side shows the admin menu only to admins, and the team assumes the endpoint is "hidden anyway". But the address is easy to find in a JavaScript bundle, in a log or just by guessing. An address starting with /admin is one of the first things an attacker tries.

Role Checks with a Policy

In ASP.NET Core, authorization rules are defined as policies. One line is enough for a role check:

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("AdminOnly", policy => policy.RequireRole("admin"));

app.MapGet("/admin/users", async (BookstoreDb db) => await db.GetUsersAsync())
    .RequireAuthorization("AdminOnly");

RequireRole looks at the ClaimTypes.Role claim we wrote during login. Let's try it with Alan and Ada (ada.txt is the cookie file left over from Ada's login):

$ curl -i -b alan.txt http://localhost:5104/admin/users
HTTP/1.1 403 Forbidden
Content-Length: 0
Date: Fri, 06 Nov 2026 19:08:50 GMT
Server: Kestrel

$ curl -i -b ada.txt http://localhost:5104/admin/users
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:08:50 GMT
Server: Kestrel
Transfer-Encoding: chunked

[{"id":1,"username":"ada","email":"ada@bookstore.test","role":"admin"},{"id":2,"username":"alan","email":"alan@bookstore.test","role":"customer"},{"id":3,"username":"grace","email":"grace@bookstore.test","role":"customer"}]

The benefit of naming a policy is that the rule lives in one place. When you want to say "admin or support" tomorrow, you change one policy, not a hundred endpoints. Policies that look at a claim or at your own requirement instead of a role are written the same way.

Resource-Based Authorization

A role isn't enough for orders: Alan and Grace are both customers. The decision has to be made by looking at the record itself. For this we use ASP.NET Core's resource-based authorization model: a requirement, and a handler that evaluates that requirement for a specific resource type.

using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;

public sealed class OrderOwnerRequirement : IAuthorizationRequirement;

public sealed class OrderOwnerHandler : AuthorizationHandler<OrderOwnerRequirement, Order>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, OrderOwnerRequirement requirement, Order resource)
    {
        string? userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
        if (context.User.IsInRole("admin") || resource.UserId.ToString() == userId)
        {
            context.Succeed(requirement);
        }
        return Task.CompletedTask;
    }
}

We register the handler with builder.Services.AddSingleton<IAuthorizationHandler, OrderOwnerHandler>(). The endpoint first reads the record, then asks IAuthorizationService: "does this user meet this requirement for this order?"

app.MapGet("/orders/{id:int}", async (int id, BookstoreDb db, ClaimsPrincipal user,
    IAuthorizationService authorization) =>
{
    Order? order = await db.GetOrderAsync(id);
    if (order is null)
    {
        return Results.NotFound();
    }

    AuthorizationResult result = await authorization.AuthorizeAsync(user, order, new OrderOwnerRequirement());
    // 404 rather than 403 for someone else's order: don't reveal that the record exists
    return result.Succeeded ? Results.Ok(order) : Results.NotFound();
});
$ curl -i -b alan.txt http://localhost:5104/orders/2
HTTP/1.1 404 Not Found
Content-Length: 0
Date: Fri, 06 Nov 2026 19:08:50 GMT
Server: Kestrel

$ curl -i -b alan.txt http://localhost:5104/orders/99
HTTP/1.1 404 Not Found
Content-Length: 0
Date: Fri, 06 Nov 2026 19:08:50 GMT
Server: Kestrel

$ curl -i -b ada.txt http://localhost:5104/orders/2
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:08:50 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"id":2,"userId":3,"bookId":2,"quantity":2}

For Alan, Grace's order and order number 99, which doesn't exist at all, can no longer be told apart. Had we returned 403, we would have said "there is an order with this number, but it isn't yours"; someone trying numbers one by one could work out which records exist. 403 isn't wrong, but when the existence of a record is sensitive too, 404 says less. Make this choice consciously for each resource and apply it consistently across the API. Ada is an admin, so the handler lets her through.

There is a simpler way too: adding the ownership condition to the query, that is, WHERE id = $id AND user_id = $userId. For list endpoints this is required anyway. The advantage of the handler is that the rule (owner or admin) lives in one place, in a class you can test.

Deny by Default: The Fallback Policy

In a minimal API, an endpoint you don't annotate is anonymous. So security depends on remembering to write .RequireAuthorization() on every endpoint. OWASP recommends the opposite: make "no" the default and mark the endpoints that should be public one by one. In ASP.NET Core this is called the fallback policy:

builder.Services.AddAuthorizationBuilder()
    .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
    .AddPolicy("AdminOnly", policy => policy.RequireRole("admin"));

The fallback policy applies to every endpoint that has no authorization metadata of its own. Login and the book list should stay public; we add .AllowAnonymous() to them. To test the rule, I wrote a small endpoint without marking anything:

app.MapGet("/me", (ClaimsPrincipal user) => new
{
    id = user.FindFirstValue(ClaimTypes.NameIdentifier),
    name = user.Identity?.Name
});
$ curl -i http://localhost:5104/me
HTTP/1.1 401 Unauthorized
Content-Length: 0
Date: Fri, 06 Nov 2026 19:08:50 GMT
Server: Kestrel

$ curl -i -b alan.txt http://localhost:5104/me
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:08:50 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"id":"2","name":"alan"}

A forgotten endpoint now starts out closed. If you forget, the worst case is a user complaining "why does this ask me to log in?"; the other way round, data leaks.

One more detail: cookie authentication was designed for websites, so it tries to redirect a user who hasn't logged in to an /Account/Login page. .NET 10 changed this for API endpoints: on known API endpoints, such as minimal APIs that read and write JSON, it returns 401 and 403 instead of redirecting. But the framework makes that distinction by looking at endpoint metadata, and the two endpoints in the first version behaved differently. /admin/users, which returns a List<User> directly, returned 401, while /orders/{id}, which returns an IResult, redirected to the login page:

$ curl -i http://localhost:5104/orders/2
HTTP/1.1 302 Found
Content-Length: 0
Date: Fri, 06 Nov 2026 19:08:01 GMT
Server: Kestrel
Location: http://localhost:5104/Account/Login?ReturnUrl=%2Forders%2F2

A 302 to a page that doesn't exist makes no sense to an API client. Since the lab has no login page, we override two events and return a plain 401 and 403 in every case:

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.Name = "bookstore.auth";
        // The API has no login page: return 401 and 403 instead of redirecting
        options.Events.OnRedirectToLogin = context =>
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            return Task.CompletedTask;
        };
        options.Events.OnRedirectToAccessDenied = context =>
        {
            context.Response.StatusCode = StatusCodes.Status403Forbidden;
            return Task.CompletedTask;
        };
    });

All the fixed outputs in this post were taken with this setting. If you want more detail on policies and roles, see the authorization post in the ASP.NET Core Web API series as well; there we focused on "how", here on "why".

Common Pitfalls

The first pitfall is thinking authentication and authorization are the same thing. It is easy to see .RequireAuthorization() or [Authorize] and assume the endpoint is protected; but without arguments it only asks "has this user logged in?"

The second is taking the user's identity from the client. Trusting a value that arrives in a route like /users/2/orders or in a userId field in the body is IDOR itself. Always read who the user is from the claim the server verified, as we did in the handler.

The third is protecting only the read endpoint. Once GET is fixed, PUT, DELETE and the list endpoints are often forgotten. Every endpoint that touches the same resource should go through the same check.

The fourth is leaving secrecy to the frontend. Hiding the menu, disabling the button or writing a route guard is user experience. If the rule isn't on the server, it doesn't exist.

Checklist

Is the fallback policy on, and are public endpoints deliberately marked with .AllowAnonymous()? Are admin functions protected by a named policy? Does every endpoint that touches a user's record compare the record's owner with the claim on the server, and is the ownership condition in the WHERE clause of list queries? Have you decided whether another user's record gets a 403 or a 404, and do you apply that everywhere? In an API, does an unauthenticated request end up with a 401 rather than a login page?

Rather than checking these by hand, the most solid option is to turn them into an integration test: sending requests to every one of Grace's records with Alan's cookie and expecting 404 is a test of a few lines, and it keeps you from silently bringing this bug back.

A Small Experiment

★☆☆ Run the lab in its first version, log in as Alan and send a request to /orders/3. Whose order is it? Then switch to the fixed version and repeat the same request.

★★☆ Write an endpoint called GET /orders that returns all orders of the logged-in user. This time apply the ownership condition not with the handler but directly with WHERE in the SQL, and make it return every order for Ada.

★★★ Add a DELETE /orders/{id} endpoint and use the same OrderOwnerRequirement. Then change the rule: the admin can see every order, but only the owner can delete it. Would you use a separate requirement for that, or an operation name in the same handler?

In the next post we move to the second half of A01: path traversal in an endpoint that downloads files, a CORS policy opened the wrong way, and SSRF, which joined this category in 2025. We will talk about why an endpoint that fetches a cover image from a URL can turn into a door to the internal network, then close it with an allowlist, a redirect check and an IP check.