How to Build a Database-Driven Navigation Menu in ASP.NET Core 10 Using C# and Role-Based Permissions (2026)

Build an ASP.NET Core database-driven navigation menu by loading a hierarchy of SQL Server menu definitions and rendering it with a Razor View Component. Resolve destination keys to known application routes and check the current user’s policy before displaying protected links. Enforce the same policy on the destination endpoint.

How does a role-aware navigation tree work?

Read menu nodes from SQL Server. Check item policies. Render the allowed tree. Authorize the requested endpoint.
Read menu nodes from SQL Server. Check item policies. Render the allowed tree. Authorize the requested endpoint.

Each database row has an ID, optional parent ID, label, destination key, sort order and enabled flag. A group provides structure, Home points to the dashboard, and Reports points to a route protected by the reports.read policy. The app applies that policy to the endpoint independently of whether the menu displays the link.

Layer Responsibility
SQL Server Store the hierarchy, labels and order
Destination catalog Resolve approved keys to application paths
View Component Apply the current caller’s authorization and build the tree
Controller Enforce access on the HTTP request

A parent group with no visible children disappears. An unknown destination is omitted. A database label remains a string that Razor encodes. This sample does not treat database values as arbitrary redirect destinations or as permissions that can grant access.

Install the project and seed SQL Server

Use the .NET 10 SDK and an empty SQL Server development database. Apply the seed script as a deployment account. Give the app identity read access to dbo.MenuNode and set ConnectionStrings__Navigation through environment configuration or secrets. Use a separate identity for navigation edits.

schema.sql
CREATE TABLE dbo.MenuNode (
    Id int PRIMARY KEY,
    ParentId int NULL REFERENCES dbo.MenuNode(Id),
    Label nvarchar(80) NOT NULL,
    DestinationKey varchar(32) NOT NULL
      CHECK (DestinationKey IN ('group','home','reports')),
    SortOrder int NOT NULL,
    IsEnabled bit NOT NULL DEFAULT 1,
    CHECK (ParentId IS NULL OR ParentId <> Id)
);
INSERT dbo.MenuNode(Id,ParentId,Label,DestinationKey,SortOrder)
VALUES (1,NULL,N'Home','home',10),(2,NULL,N'Analytics','group',20);
INSERT dbo.MenuNode(Id,ParentId,Label,DestinationKey,SortOrder)
VALUES (3,2,N'Reports','reports',10);
Sample.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.*" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.*" />
  </ItemGroup>
</Project>

The self-referencing foreign key protects against missing parent records in the stored table. It does not enforce a complete cycle-free tree. Validate hierarchy updates in your admin workflow, including cycles and maximum depth. In the renderer, rows reachable from root are traversed within a depth cap. An enabled node under a disabled parent cannot be reached.

Implement MVC and the View Component

The component fetches the raw definitions asynchronously and then calls IAuthorizationService for the current user. The cache stores shared definitions for two minutes. The rendered tree and authorization decisions are calculated for each request. The example maps a small, fixed set of destination keys. Larger applications can use an approved route-name catalog and LinkGenerator.

Program.cs
using System.Data;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Caching.Memory;

var b = WebApplication.CreateBuilder(args);
b.Services.AddControllersWithViews();
b.Services.AddMemoryCache();
b.Services.AddSingleton<MenuStore>();
b.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer();
b.Services.AddAuthorization(o => o.AddPolicy("reports.read",
    p => p.RequireRole("Analyst")));
var app = b.Build();
app.MapControllers();
app.Run();

public sealed class DashboardController : Controller
{
    [HttpGet("/")]
    public IActionResult Index() => View();
    [Authorize(Policy = "reports.read")]
    [HttpGet("/reports")]
    public IActionResult Reports() => View("Index");
}

public sealed record MenuRow(int Id, int? Parent, string Label, string Key, int Sort);
public sealed record MenuItem(string Label, string? Path, bool Active, List<MenuItem> Children);

public sealed class MenuStore(IConfiguration config, IMemoryCache cache)
{
    public async Task<IReadOnlyList<MenuRow>> Read(CancellationToken ct)
    {
        if (cache.TryGetValue("nav-definitions-v1", out IReadOnlyList<MenuRow>? hit) && hit is not null)
            return hit;
        await using var db = new SqlConnection(config.GetConnectionString("Navigation")
            ?? throw new InvalidOperationException("Set ConnectionStrings__Navigation."));
        await db.OpenAsync(ct);
        await using var cmd = new SqlCommand("""
            SELECT TOP (201) Id, ParentId, Label, DestinationKey, SortOrder
            FROM dbo.MenuNode WHERE IsEnabled=1 ORDER BY SortOrder,Id
            """, db);
        await using var r = await cmd.ExecuteReaderAsync(ct);
        var rows = new List<MenuRow>();
        while (await r.ReadAsync(ct))
            rows.Add(new MenuRow(r.GetInt32(0), r.IsDBNull(1) ? null : r.GetInt32(1),
                r.GetString(2), r.GetString(3), r.GetInt32(4)));
        if (rows.Count > 200) throw new InvalidOperationException("Menu node limit exceeded.");
        return cache.Set<IReadOnlyList<MenuRow>>("nav-definitions-v1", rows, TimeSpan.FromMinutes(2));
    }
}

public sealed class SiteMenuViewComponent(MenuStore store, IAuthorizationService auth) : ViewComponent
{
    public async Task<IViewComponentResult> InvokeAsync()
    {
        var rows = await store.Read(HttpContext.RequestAborted);
        var groups = rows.ToLookup(row => row.Parent);
        var canReport = (await auth.AuthorizeAsync(HttpContext.User, "reports.read")).Succeeded;
        return View(Build(null, 0));

        List<MenuItem> Build(int? parent, int depth)
        {
            var visible = new List<MenuItem>();
            if (depth >= 8) return visible;
            foreach (var row in groups[parent])
            {
                if (row.Key == "reports" && !canReport) continue;
                var children = Build(row.Id, depth + 1);
                var path = row.Key switch { "home" => "/", "reports" => "/reports", _ => null };
                if (path is null && (row.Key != "group" || children.Count == 0)) continue;
                visible.Add(new MenuItem(row.Label, path,
                    path is not null && string.Equals(HttpContext.Request.Path.Value,
                        path, StringComparison.OrdinalIgnoreCase), children));
            }
            return visible;
        }
    }
}
Views/Dashboard/Index.cshtml
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Operations</title>
<body>
@await Component.InvokeAsync("SiteMenu")
<main><h1>Operations</h1></main>
</body>
</html>
Views/Shared/Components/SiteMenu/Default.cshtml
@model List<MenuItem>
<nav aria-label="Primary">
  @await Html.PartialAsync("_MenuNodes", Model)
</nav>
Views/Shared/_MenuNodes.cshtml
@model List<MenuItem>
<ul>
@foreach (var node in Model)
{
  <li>
    @if (node.Path is string path)
    {
      <a href="@Url.Content("~" + path)" aria-current="@(node.Active ? "page" : null)">@node.Label</a>
    }
    else
    {
      <span>@node.Label</span>
    }
    @if (node.Children.Count > 0)
    {
      @await Html.PartialAsync("_MenuNodes", node.Children)
    }
  </li>
}
</ul>

The nested lists preserve the relationship between groups and children, and the active link receives aria-current. Keep the nav landmark labeled. If you add collapsible groups, use keyboard-accessible controls, expanded state and logical focus order. Check menu wrapping at narrow widths before adding custom styles.

Test both visibility and route access

Run on http://localhost:5096 in Development. Use the developer JWT tool for the appropriate audience. Generate one token with role Analyst and one authenticated token without it. Use curl with the Authorization header when testing. Direct browser navigation needs a browser-compatible authentication scheme to carry credentials.

  1. Open the root without a token. Expect Home and no Reports link. Request /reports directly and expect 401.
  2. Open the root with a token lacking Analyst. Expect the Reports group to remain hidden. The direct route should return 403.
  3. Open /reports with the Analyst token. Expect 200, a Reports link and aria-current for that link.
  4. Disable the parent in SQL and wait for the two-minute cache TTL. Confirm its subtree disappears. The Analyst still has route access because that access belongs to the endpoint policy.
  5. Insert an unknown destination key in the development fixture. Check that it does not generate an external or unrecognized link.

Cache, tenant and production design

If tenants have different menu definitions, apply tenant scope in the SQL query and cache key. Reapply authorization per caller after the cache lookup. In a deployment with several replicas, use coordinated invalidation when editors expect an immediate change. The sample can issue duplicate reads on concurrent cache misses. Measure contention before adding coalescing and preserve request cancellation.

Keep the database topology bounded. The query reads at most 201 rows and rejects a configuration beyond 200. Have a validation and rollback path for navigation changes. If the menu is critical and the database can be unavailable, define a small public fallback and monitor failures. Never grant access through the fallback.

Troubleshooting and FAQ

Symptom Check
Reports is visible but clicking returns 403 Compare the component’s policy, token role mapping and endpoint policy
A menu edit is delayed Check the cache TTL and replica invalidation
A group never appears Check its parent, whether it has visible children and its depth
A label appears as text containing markup Keep Razor encoding and fix the stored label

Can I store full URLs? The sample uses known destination keys. If you support external destinations, define an explicit scheme and host policy and review it independently.

Is hiding a menu item enough to protect a page? The route enforces authorization for direct requests. Hiding the item improves the interface for users without access.

See ASP.NET Core View Components, policy-based authorization, and the ASP.NET PB JWT tutorial.

Comments