Securing Minimal APIs with Scope-Based Authorization in .NET 10

Khalid Abuhakmeh
Two blue circles

Authentication tells you who the caller is; it doesn't tell you what they're allowed to do. In this post, we'll use scope-based authorization in .NET 10 Minimal APIs to enforce fine-grained access control using JWTs issued by Duende IdentityServer.

By the end, you'll have an API where different endpoints require different scopes, and callers without the right scopes get a hard 403 Forbidden.

TL;DR

  • Scope-based authorization checks what an application is allowed to do, not just who the user is.
  • Define named policies with AddAuthorizationBuilder() and RequireClaim("scope", "value").
  • Apply policies to Minimal API endpoints with .RequireAuthorization("policyName").
  • Missing scopes result in 403 Forbidden, not 401 Unauthorized.
  • Duende IdentityServer controls which scopes appear in access tokens; your API enforces the boundaries.

Why Does Scope-Based Authorization Matter?

Without scope checks, any authenticated caller, whether a read-only client or an admin service, reaches the same endpoints. Scopes fix that: they represent what an application is allowed to do on a user's behalf. An access token with only the api scope should not have the same level of access as a token carrying api, openid, and email.

OAuth 2.0 and OpenID Connect define scopes as a core mechanism for limiting access. Duende IdentityServer issues tokens with exactly the scopes a client is authorized to request. Your API's job is to enforce those boundaries.

How Do You Set Up JWT Bearer Authentication?

Start by creating a new .NET 10 project and adding the JWT Bearer package:

Shell

dotnet new web -o ScopeBasedMinimalApi
cd ScopeBasedMinimalApi
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Next, configure authentication in Program.cs to point at Duende IdentityServer's demo instance:

C#

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

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://demo.duendesoftware.com";
        options.TokenValidationParameters.ValidateAudience = false;
    });

The Authority tells the JWT middleware where to find the OpenID Connect discovery document. The middleware downloads the signing keys automatically and validates every incoming token against them. Note that we disable audience validation here because the demo server does not set a specific audience claim. In production, you'd want to validate the audience too.

How Do You Define Scope-Based Policies?

ASP.NET Core's AddAuthorizationBuilder method lets you define named policies that inspect claims on the authenticated user. Since scopes arrive as scope claims in a JWT, we use RequireClaim to check for specific values:

C#

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("read", policy =>
        policy
            .RequireAuthenticatedUser()
            .RequireClaim("scope", "api"))
    .AddPolicy("write", policy =>
        policy
            .RequireAuthenticatedUser()
            .RequireClaim("scope", "api")
            .RequireClaim("scope", "openid"))
    .AddPolicy("admin", policy =>
        policy
            .RequireAuthenticatedUser()
            .RequireClaim("scope", "api")
            .RequireClaim("scope", "email"));

Each policy layers requirements:

  • "read": Requires the api scope. Any valid token with API access can call this endpoint.
  • "write": Requires both api and openid scopes. The caller must have an identity context.
  • "admin": Requires both api and email scopes. The caller must have access to the user's email.

Read operations are broadly available, but mutations and admin actions require elevated permissions.

How Do You Apply Policies to Endpoints?

Wire up the middleware and create endpoints that reference the policies by name:

C#

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

// Public endpoint — no authentication required
app.MapGet("/", () => "Welcome to the Scope-Based Minimal API sample!");

// Requires the "api" scope
app.MapGet("/api/read", (ClaimsPrincipal user) =>
{
    var scopes = user.FindAll("scope").Select(c => c.Value);
    return Results.Ok(new
    {
        Message = "You have read access.",
        User = user.Identity?.Name ?? user.FindFirstValue("sub"),
        Scopes = scopes
    });
}).RequireAuthorization("read");

// Requires both "api" and "openid" scopes
app.MapGet("/api/write", (ClaimsPrincipal user) =>
{
    var scopes = user.FindAll("scope").Select(c => c.Value);
    return Results.Ok(new
    {
        Message = "You have write access.",
        User = user.Identity?.Name ?? user.FindFirstValue("sub"),
        Scopes = scopes
    });
}).RequireAuthorization("write");

// Requires "api" and "email" scopes
app.MapGet("/api/admin", (ClaimsPrincipal user) =>
{
    var scopes = user.FindAll("scope").Select(c => c.Value);
    return Results.Ok(new
    {
        Message = "You have admin access.",
        User = user.Identity?.Name ?? user.FindFirstValue("sub"),
        Scopes = scopes
    });
}).RequireAuthorization("admin");

app.Run();

The .RequireAuthorization("read") call chains the named policy onto the endpoint. ASP.NET Core evaluates all RequireClaim checks before executing the handler. A missing scope means a 403 Forbidden, not a 401 Unauthorized. The caller is authenticated, but not authorized for that specific endpoint.

How Do You Test Scope-Based Authorization?

The Duende IdentityServer demo instance at https://demo.duendesoftware.com has pre-configured clients you can use to test different scope combinations.

First, request a token using the client credentials flow with the m2m client, which only gets the api scope:

Shell

curl -X POST https://demo.duendesoftware.com/connect/token \
  -d "client_id=m2m" \
  -d "client_secret=secret" \
  -d "grant_type=client_credentials" \
  -d "scope=api"

Json

{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ij...",
  "expires_in": 3600,
  "token_type": "Bearer",
  "scope": "api"
}

Use the returned access_token to call each endpoint:

Shell

# Succeeds — token has "api" scope
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Ij..." \
  https://localhost:5001/api/read
# HTTP 200
# {"message":"You have read access.","user":"client_m2m","scopes":["api"]}

# Fails — token lacks "openid" scope
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Ij..." \
  https://localhost:5001/api/write
# HTTP 403

# Fails — token lacks "email" scope
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Ij..." \
  https://localhost:5001/api/admin
# HTTP 403

The m2m client credential token only carries the api scope, so it passes the read policy but fails write and admin. To access those endpoints, you need a token issued through an interactive flow that includes openid and email scopes.

How Does This Map to Real-World API Protection?

This pattern applies across any real application:

sequenceDiagram
    participant C as Client
    participant IS as Duende IdentityServer
    participant API as Minimal API

    C->>IS: Request token (grant_type, scope)
    IS-->>C: Access token (scope claims inside)
    C->>API: GET /api/read (Bearer token)
    API->>API: Validate token & check scope claims
    alt scope matches policy
        API-->>C: 200 OK
    else scope missing
        API-->>C: 403 Forbidden
    end
Scope Meaning Example

api

General API access

Read public data

api + openid

API access with identity context

Create or modify resources

api + email

API access with verified email

Administrative operations

You can extend this further by defining custom scopes in Duende IdentityServer's API scope configuration. The scopes you define on the server side flow into access tokens as claims, and your policies enforce them on the API side.

Duende IdentityServer gives you full control over which clients can request which scopes. And you can go further by combining scope-based policies with other authorization requirements like roles or custom claims.

Conclusion

Scope-based authorization works especially well in a microservices architecture. It separates the concern of "who can request what" (handled by Duende IdentityServer) from "what is allowed here" (handled by your API's policies). With .NET 10 Minimal APIs, the implementation is a few lines of configuration and a policy name on each endpoint.

For more on configuring scopes and API resources in Duende IdentityServer, see the API protection documentation. If you're protecting APIs with reference tokens instead of JWTs, see API protection with introspection. For a deeper look at how IdentityServer decides which claims end up in a token, read How Duende IdentityServer Filters Claims. And if the client credentials flow used in the examples is new to you, OAuth 2.1 Made Simple: The Only Flows You Need is a good starting point.

Related Articles