Route Groups

Group endpoints under a shared prefix with MapGroup and apply authorization, filters, tags and other metadata once for the whole group.

Syntaxvar orders = app.MapGroup("/api/orders").RequireAuthorization().WithTags("Orders"); orders.MapGet("/{id:int}", GetOrder); orders.MapPost("/", CreateOrder);

app.MapGroup("/api/orders") returns a RouteGroupBuilder. It is both a place to map endpoints (an IEndpointRouteBuilder) and a place to attach conventions (an IEndpointConventionBuilder). Every endpoint mapped on the group gets the prefix prepended to its template, and every convention added to the group, such as RequireAuthorization(), WithTags("Orders") or AddEndpointFilter(...), is applied to each endpoint when the endpoint table is built. That includes endpoints mapped on the group after the convention was added.

Prefixes and route parameters

A prefix can contain parameters and constraints: MapGroup("/customers/{customerId:int}"). Handlers in the group simply declare int customerId. Groups nest, so /api, then /customers/{customerId:int}, then /orders gives /api/customers/{customerId:int}/orders/{orderId:int} without repeating anything. MapGroup("") is allowed when you want shared metadata without a prefix.

Order of conventions

  • Filters run outermost first: the /api group's filters, then the nested group's, then the endpoint's own, in the order they were added.
  • Metadata accumulates. Group metadata is added before endpoint metadata, so code that reads one item with GetMetadata<T>() sees the endpoint's value: an endpoint can override a group's choice.
  • AllowAnonymous() on one endpoint beats RequireAuthorization() on its group, which is how a public /api/status lives inside a secured group.

Groups per feature

The common layout is one extension method per feature that creates its group and maps its endpoints, so Program.cs reads as a list of features:

var api = app.MapGroup("/api").RequireAuthorization();
api.MapOrders();
api.MapCustomers();

Each feature method owns its own segment (/orders) and tags, and the caller owns the shared prefix and policies. Decide that split once; if both add /api you get /api/api/orders.

Put on the groupKeep on the endpoint
Prefix, OpenAPI tags, default authorization policy, rate limit policy, cross-cutting filters (logging, tenant checks)Endpoint name, summary, produces metadata, stricter or looser policies for one route

What goes wrong

Groups secure only what is mapped on them. An endpoint added with app.MapGet instead of orders.MapGet has the right-looking URL if someone types the prefix by hand, but none of the group's authorization or filters. Two defences work well together: a fallback authorization policy so that anything without explicit metadata still requires an authenticated user, and a test that enumerates EndpointDataSource and fails when an /api endpoint has neither authorization nor AllowAnonymous metadata.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Require authentication for everything under /api with one RequireAuthorization call, while keeping /api/status public with AllowAnonymous.
  • Nest /api/customers/{customerId:int}/orders so every order handler receives customerId without repeating the prefix in each route.
  • Tag all order endpoints as Orders in the OpenAPI document and add a tenant-check filter to the group instead of to each handler.

More examples

One extension method per feature

The method takes <code>IEndpointRouteBuilder</code>, so it can be called on <code>app</code> or on any group, and returns the group so the caller can add more conventions. <code>Cancel</code> adds a stricter policy on top of the group's: authorization metadata combines, so the caller must be authenticated and satisfy <code>OrderAdmin</code>. The policy must be registered with <code>AddAuthorizationBuilder().AddPolicy</code> in a real app, or the first request to that route fails.

Example · csharp
using Microsoft.AspNetCore.Http.HttpResults;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddCookie();
builder.Services.AddAuthorization();
var app = builder.Build();

// Program.cs owns the shared prefix and the default policy; features own their segment.
var api = app.MapGroup("/api").RequireAuthorization();
api.MapOrders();

app.Run();

public static class OrderEndpoints
{
    public static RouteGroupBuilder MapOrders(this IEndpointRouteBuilder routes)
    {
        var group = routes.MapGroup("/orders").WithTags("Orders");

        group.MapGet("/{id:int}", GetById).WithName("GetOrderById");
        group.MapPost("/", Create);
        group.MapPost("/{id:int}/cancel", Cancel).RequireAuthorization("OrderAdmin");

        return group;
    }

    static Results<Ok<OrderDto>, NotFound> GetById(int id) =>
        id == 5001 ? TypedResults.Ok(new OrderDto(5001, "C-1001", 240.00m)) : TypedResults.NotFound();

    static Created<OrderDto> Create(CreateOrderRequest request) =>
        TypedResults.Created("/api/orders/5002", new OrderDto(5002, request.CustomerId, request.Total));

    static NoContent Cancel(int id) => TypedResults.NoContent();
}

public record OrderDto(int Id, string CustomerId, decimal Total);
public record CreateOrderRequest(string CustomerId, decimal Total);

Different policies for public and admin groups

Putting reads and writes in separate groups keeps the policy decision visible in one line per group instead of scattered across endpoints. <code>AllowAnonymous()</code> sits on the catalog endpoint itself rather than the group, because a group-level marker beats every rule on every endpoint in it, including a <code>RequireAuthorization</code> added later (see net-authz-basics). The output cache applies only to the public catalog group, which avoids the classic mistake of caching a response that depends on the signed-in user. Output caching only caches GET and HEAD responses, so it would not cache the admin writes anyway, but keeping it off the admin group makes the intent explicit.

Example · csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddCookie();
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("CatalogAdmin", policy => policy.RequireRole("catalog-admin"));
builder.Services.AddOutputCache();
var app = builder.Build();

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

// Cached for 60 seconds per distinct query string. AllowAnonymous goes on each public endpoint,
// never on the group: on the group it would override any RequireAuthorization added there later.
var catalog = app.MapGroup("/api/products")
    .CacheOutput(policy => policy.Expire(TimeSpan.FromSeconds(60)).SetVaryByQuery("category", "page"));
catalog.MapGet("", (string? category, int page = 1) => new[] { new { sku = "KET-001", name = "Blue kettle", price = 39.90m, category, page } })
    .AllowAnonymous();

// Same resources, different group: authenticated, admin-only, never cached.
var admin = app.MapGroup("/admin/products").RequireAuthorization("CatalogAdmin");
admin.MapPut("/{sku}", (string sku, ProductUpdate update) => TypedResults.NoContent());
admin.MapDelete("/{sku}", (string sku) => TypedResults.NoContent());

app.Run();

record ProductUpdate(string Name, decimal Price);

A test that finds unprotected endpoints

Enumerating <code>EndpointDataSource</code> through <code>WebApplicationFactory</code> sees exactly the endpoints production sees, including those mapped by feature extension methods. The test fails with the display names of offending routes, for example an endpoint someone mapped on <code>app</code> instead of the secured group. <code>RequireAuthorization()</code> adds <code>IAuthorizeData</code> metadata and <code>AllowAnonymous()</code> adds <code>IAllowAnonymous</code>, so both deliberate choices pass.

Example · csharp
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

public class EndpointSecurityTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public void Every_api_endpoint_requires_authorization_or_is_explicitly_anonymous()
    {
        var endpoints = factory.Services.GetRequiredService<EndpointDataSource>().Endpoints
            .OfType<RouteEndpoint>()
            .Where(e => e.RoutePattern.RawText?.StartsWith("/api", StringComparison.OrdinalIgnoreCase) == true);

        var unprotected = endpoints
            .Where(e => e.Metadata.GetMetadata<IAuthorizeData>() is null
                     && e.Metadata.GetMetadata<IAllowAnonymous>() is null)
            .Select(e => e.DisplayName)
            .ToList();

        Assert.Empty(unprotected);
    }
}

// Stand-in for the API project's Program class.
public partial class Program;

Discussion

  • Be the first to comment on this lesson.