ASP.NET Core Authentication and Authorization with CSharpDB

ASP.NET Core ships with a battle-tested security stack: ASP.NET Core Identity for users, roles, claims, and tokens; pluggable authentication schemes for cookies and JWT bearer tokens; and a flexible authorization system built on roles and policies. All of it is database-agnostic — it asks the application to plug in a store.

CSharpDB is a natural fit for that store. It is embedded, ACID, single-file, and ships with a real EF Core 10 provider, an ADO.NET provider, and a typed Collection API. For single-node web apps, internal tools, desktop apps, and dev or CI environments, you get a complete identity solution that lives entirely inside your application directory.

This post walks through three integration patterns: ASP.NET Core Identity over the EF Core provider, JWT bearer with the same store, and a lightweight custom IUserStore when you do not want the full Identity surface. It closes with Data Protection key persistence so cookies survive process restarts.

v1 EF Core caveat. The CSharpDB EF Core provider in v1 does not support composite primary keys, and ASP.NET Core Identity's standard schema uses composite keys on AspNetUserRoles, AspNetUserLogins, and AspNetUserTokens. Until that lands, the runnable path is the lightweight custom store described later in this post — a small ADO.NET-backed user/role/claim store that uses the same PasswordHasher, cookie, and JWT pipeline shown for the EF Core flow. The companion sample at samples/aspnet-core-identity implements that path end-to-end.

When CSharpDB Fits Your Auth Stack

CSharpDB is single-process and single-file. That keeps the deployment story very small — one .db file beside your binaries, no server to provision. It is the right call for:

  • Single-node web apps and internal tools.
  • Desktop apps that wrap a Blazor or MAUI shell and need local sign-in.
  • Edge or kiosk deployments where a database server is not an option.
  • Dev and CI environments where you want a real identity database without a container.

For multi-node web farms you typically want a shared identity store on a server database. In those cases CSharpDB is still useful for local concerns: per-node refresh-token caches, audit logs, or rate-limit counters.

Approach 1: ASP.NET Core Identity on the EF Core Provider

This is the path most teams will take. ASP.NET Core Identity is built on EF Core, and CSharpDB.EntityFrameworkCore is a standard EF Core 10 provider, so the integration is a one-line connection-string change.

Step 1: Install the Packages

dotnet add package CSharpDB.EntityFrameworkCore
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Design

CSharpDB.EntityFrameworkCore brings in CSharpDB.Data (the ADO.NET provider) and the engine. The Identity package adds the IdentityDbContext base class and stores. The design package lets dotnet ef generate migrations.

Step 2: Define the Identity DbContext

You can use the built-in IdentityUser or extend it. Most apps add a few profile fields, so a custom user is shown below.

using CSharpDB.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

public sealed class AppUser : IdentityUser
{
    public string? DisplayName { get; set; }
    public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}

public sealed class AppDbContext : IdentityDbContext<AppUser>
{
    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options) { }

    public DbSet<AuditEntry> AuditLog => Set<AuditEntry>();
}

public sealed class AuditEntry
{
    public int Id { get; set; }
    public string UserId { get; set; } = "";
    public string Action { get; set; } = "";
    public DateTime AtUtc { get; set; }
}

Inheriting IdentityDbContext<AppUser> brings in the standard tables: users, roles, user-roles, user-claims, role-claims, user-logins, and user-tokens. You can add your own DbSet<T> properties alongside them, and they all live in the same database file.

Step 3: Wire It Up in Program.cs

using CSharpDB.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

var dbPath = Path.Combine(builder.Environment.ContentRootPath, "app.db");

builder.Services.AddDbContext<AppDbContext>(opt =>
    opt.UseCSharpDb($"Data Source={dbPath}"));

builder.Services
    .AddIdentity<AppUser, IdentityRole>(opt =>
    {
        opt.Password.RequiredLength = 10;
        opt.Password.RequireNonAlphanumeric = true;
        opt.Lockout.MaxFailedAccessAttempts = 5;
        opt.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
        opt.User.RequireUniqueEmail = true;
    })
    .AddEntityFrameworkStores<AppDbContext>()
    .AddDefaultTokenProviders();

builder.Services.AddAuthorization(opt =>
{
    opt.AddPolicy("AdminOnly", p => p.RequireRole("Admin"));
    opt.AddPolicy("CanManageUsers", p => p.RequireClaim("perm", "users.manage"));
});

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await db.Database.MigrateAsync();
}

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

app.MapRazorPages();
app.MapControllers();

app.Run();

That is the full integration. UseCSharpDb tells EF Core where the file lives. AddIdentity registers UserManager<AppUser>, SignInManager<AppUser>, role manager, password hasher, and token providers. AddEntityFrameworkStores<AppDbContext> points the user and role stores at our context, which means they read and write through CSharpDB.

Step 4: Generate the Identity Tables

dotnet ef migrations add InitialIdentity
dotnet ef database update

The migration creates AspNetUsers, AspNetRoles, AspNetUserRoles, AspNetUserClaims, AspNetRoleClaims, AspNetUserLogins, AspNetUserTokens, plus your AuditLog table — all inside app.db. db.Database.MigrateAsync() at startup is fine for single-node apps.

Step 5: Register and Sign In

From here, every Identity API works the way it does on any other provider. The example below uses minimal-API endpoints, but the same calls work in MVC controllers, Razor Pages, or Blazor.

app.MapPost("/auth/register", async (
    RegisterDto dto,
    UserManager<AppUser> users) =>
{
    var user = new AppUser
    {
        UserName = dto.Email,
        Email = dto.Email,
        DisplayName = dto.DisplayName
    };

    var result = await users.CreateAsync(user, dto.Password);
    return result.Succeeded
        ? Results.Ok()
        : Results.ValidationProblem(result.Errors.ToDictionary(e => e.Code, e => new[] { e.Description }));
});

app.MapPost("/auth/login", async (
    LoginDto dto,
    SignInManager<AppUser> signIn) =>
{
    var result = await signIn.PasswordSignInAsync(
        dto.Email, dto.Password, dto.RememberMe, lockoutOnFailure: true);

    return result.Succeeded ? Results.Ok() : Results.Unauthorized();
});

app.MapPost("/auth/logout", async (SignInManager<AppUser> signIn) =>
{
    await signIn.SignOutAsync();
    return Results.Ok();
}).RequireAuthorization();

public record RegisterDto(string Email, string DisplayName, string Password);
public record LoginDto(string Email, string Password, bool RememberMe);

Behind the scenes UserManager.CreateAsync hashes the password with PBKDF2, writes the user row to CSharpDB, and emits any default tokens. SignInManager.PasswordSignInAsync verifies the hash, increments the lockout counter on failure, and issues the auth cookie on success.

Authorization: Roles and Policies

Role data lives in AspNetUserRoles, claim data in AspNetUserClaims. Every standard authorization attribute reads from those tables through the EF Core stores.

Seeding Roles and a First Admin

using Microsoft.AspNetCore.Identity;

public static class Seed
{
    public static async Task EnsureRolesAndAdminAsync(IServiceProvider sp)
    {
        using var scope = sp.CreateScope();
        var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
        var users = scope.ServiceProvider.GetRequiredService<UserManager<AppUser>>();

        foreach (var role in new[] { "Admin", "Editor", "Viewer" })
            if (!await roles.RoleExistsAsync(role))
                await roles.CreateAsync(new IdentityRole(role));

        var admin = await users.FindByEmailAsync("admin@example.com");
        if (admin is null)
        {
            admin = new AppUser { UserName = "admin@example.com", Email = "admin@example.com", EmailConfirmed = true };
            await users.CreateAsync(admin, "ChangeMe!2026");
            await users.AddToRoleAsync(admin, "Admin");
            await users.AddClaimAsync(admin, new Claim("perm", "users.manage"));
        }
    }
}

Call await Seed.EnsureRolesAndAdminAsync(app.Services); after MigrateAsync(). The seed runs idempotently against CSharpDB and gives you a working admin on first boot.

Protecting Endpoints

// Role-based
app.MapDelete("/api/users/{id}", (string id) => Results.Ok())
   .RequireAuthorization(new AuthorizeAttribute { Roles = "Admin" });

// Policy-based (claim)
app.MapPost("/api/users/{id}/lock", (string id) => Results.Ok())
   .RequireAuthorization("CanManageUsers");

// Multiple requirements
[Authorize(Roles = "Editor,Admin", Policy = "CanManageUsers")]
public class UsersController : ControllerBase { /* ... */ }

Each attribute resolves through the standard IAuthorizationService. The role and claim lookups go through Identity's UserManager, which reads from CSharpDB through the EF Core stores. There is nothing CSharpDB-specific in your authorization code — that is the point.

Approach 2: JWT Bearer for APIs and SPAs

For APIs and single-page apps you typically issue JWTs instead of cookies. Identity still owns user creation, password verification, and refresh-token storage. CSharpDB is the durable store under all of it.

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

var jwtKey = builder.Configuration["Jwt:Key"]!;
var jwtIssuer = builder.Configuration["Jwt:Issuer"]!;

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(opt =>
    {
        opt.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = jwtIssuer,
            ValidAudience = jwtIssuer,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey))
        };
    });

builder.Services.AddIdentityCore<AppUser>(opt =>
    {
        opt.Password.RequiredLength = 10;
        opt.User.RequireUniqueEmail = true;
    })
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<AppDbContext>()
    .AddSignInManager()
    .AddDefaultTokenProviders();

AddIdentityCore is the API-friendly counterpart to AddIdentity. It skips the cookie scheme since JWT bearer is doing the authentication.

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;

app.MapPost("/auth/token", async (
    LoginDto dto,
    UserManager<AppUser> users,
    IConfiguration config) =>
{
    var user = await users.FindByEmailAsync(dto.Email);
    if (user is null || !await users.CheckPasswordAsync(user, dto.Password))
        return Results.Unauthorized();

    var roles = await users.GetRolesAsync(user);
    var claims = new List<Claim>
    {
        new(JwtRegisteredClaimNames.Sub, user.Id),
        new(JwtRegisteredClaimNames.Email, user.Email!),
        new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
    };
    claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));

    var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Key"]!));
    var token = new JwtSecurityToken(
        issuer: config["Jwt:Issuer"],
        audience: config["Jwt:Issuer"],
        claims: claims,
        expires: DateTime.UtcNow.AddMinutes(15),
        signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

    return Results.Ok(new { token = new JwtSecurityTokenHandler().WriteToken(token) });
});

Refresh tokens deserve durable storage. The cleanest place is AspNetUserTokens — Identity already exposes UserManager.SetAuthenticationTokenAsync and GetAuthenticationTokenAsync for it, which write through CSharpDB. If you want richer rotation metadata (issued IP, device, parent token), add a RefreshToken entity to AppDbContext and rotate yourself.

Approach 3: A Lightweight Custom Store with the Collection API

You do not always need the full Identity surface. If you have a small set of internal users and no plans to add external logins, two-factor, or claim trees, a custom IUserStore<TUser> backed by a typed CSharpDB collection is dramatically simpler.

using CSharpDB.Engine;
using Microsoft.AspNetCore.Identity;

public sealed class SimpleUser
{
    public string Id { get; set; } = Guid.NewGuid().ToString("N");
    public string UserName { get; set; } = "";
    public string NormalizedUserName { get; set; } = "";
    public string PasswordHash { get; set; } = "";
    public List<string> Roles { get; set; } = new();
}

public sealed class CollectionUserStore :
    IUserStore<SimpleUser>,
    IUserPasswordStore<SimpleUser>,
    IUserRoleStore<SimpleUser>
{
    private readonly Database _db;
    private Collection<SimpleUser>? _users;

    public CollectionUserStore(Database db) => _db = db;

    private async Task<Collection<SimpleUser>> UsersAsync()
    {
        if (_users is not null) return _users;
        _users = await _db.GetCollectionAsync<SimpleUser>("users");
        await _users.EnsureIndexAsync(u => u.NormalizedUserName);
        return _users;
    }

    public async Task<IdentityResult> CreateAsync(SimpleUser user, CancellationToken ct)
    {
        var users = await UsersAsync();
        await users.PutAsync(user.Id, user);
        return IdentityResult.Success;
    }

    public async Task<SimpleUser?> FindByNameAsync(string normalizedName, CancellationToken ct)
    {
        var users = await UsersAsync();
        await foreach (var hit in users.FindByIndexAsync(u => u.NormalizedUserName, normalizedName))
            return hit.Value;
        return null;
    }

    public Task SetPasswordHashAsync(SimpleUser user, string? hash, CancellationToken ct)
    { user.PasswordHash = hash ?? ""; return Task.CompletedTask; }

    public Task<string?> GetPasswordHashAsync(SimpleUser user, CancellationToken ct)
        => Task.FromResult<string?>(user.PasswordHash);

    // Implement remaining IUserStore / IUserRoleStore members against the same collection.
}

Register it in DI and Identity wires up the rest:

builder.Services.AddSingleton<Database>(_ =>
    Database.OpenAsync("app.db").GetAwaiter().GetResult());

builder.Services.AddScoped<IUserStore<SimpleUser>, CollectionUserStore>();

builder.Services
    .AddIdentityCore<SimpleUser>()
    .AddDefaultTokenProviders();

Two reasons to take this path: collection point reads are sub-microsecond, and there is no migration step. The trade-off is that you implement the IUserStore surface yourself for any feature you want — claims, lockout, two-factor, external logins. For most apps, the EF Core path is the right default. Reach for the Collection API when you specifically want minimal moving parts and can live with a smaller feature set.

Persisting Data Protection Keys

ASP.NET Core encrypts auth cookies and antiforgery tokens with a rotating key ring managed by the Data Protection system. By default that ring is written to %LOCALAPPDATA% on Windows or a profile directory on Linux. On a clean container or a fresh deploy, the keys are gone — and every existing cookie becomes invalid.

For a single-node CSharpDB app, the cleanest fix is to store the key ring in the same database file. A small custom IXmlRepository handles it:

using System.Xml.Linq;
using CSharpDB.Engine;
using Microsoft.AspNetCore.DataProtection.Repositories;

public sealed class DataProtectionKey
{
    public string Id { get; set; } = Guid.NewGuid().ToString("N");
    public string Xml { get; set; } = "";
}

public sealed class CSharpDbXmlRepository : IXmlRepository
{
    private readonly Database _db;

    public CSharpDbXmlRepository(Database db) => _db = db;

    public IReadOnlyCollection<XElement> GetAllElements()
    {
        var keys = _db.GetCollectionAsync<DataProtectionKey>("dp_keys").Result;
        var list = new List<XElement>();
        foreach (var entry in keys.ScanAsync().ToBlockingEnumerable())
            list.Add(XElement.Parse(entry.Value.Xml));
        return list;
    }

    public void StoreElement(XElement element, string friendlyName)
    {
        var keys = _db.GetCollectionAsync<DataProtectionKey>("dp_keys").Result;
        keys.PutAsync(friendlyName, new DataProtectionKey { Id = friendlyName, Xml = element.ToString() }).Wait();
    }
}
builder.Services
    .AddDataProtection()
    .SetApplicationName("my-app")
    .AddKeyManagementOptions(opt =>
    {
        opt.XmlRepository = new CSharpDbXmlRepository(database);
    });

Now the key ring lives in app.db alongside the users it protects, and cookies survive every restart and redeploy.

Operations Notes

A few things worth knowing when you take this to production on a single node:

  • Concurrency. The Identity pipeline opens many short EF Core scopes per request. The CSharpDB EF Core provider handles the concurrent reader/writer story; just keep the connection lifetime owned by EF Core's scoped DbContext.
  • Backups. Because everything lives in one file, a backup is a copy. Use the engine's online backup API or stop the app and copy app.db plus its WAL.
  • Admin UI. The CSharpDB Admin app can open the same file (read-only or read-write) so ops can unlock an account or rotate a claim without writing screens.
  • Migrations on deploy. db.Database.MigrateAsync() at startup is fine for single-node deploys. The provider serializes concurrent migrations across processes with __EFMigrationsLock.
  • Storage tuning. For login-heavy workloads, UseStoragePreset(CSharpDbStoragePreset.WriteOptimized) on the EF Core builder noticeably improves lockout-counter and token-write throughput.

What You Get

With one EF Core provider swap and a normal Identity setup, your application gets PBKDF2-hashed passwords, lockout, two-factor scaffolding, external login slots, role and claim authorization, refresh-token storage, and a Data Protection key ring — all backed by a single ACID file you can copy, ship, and back up. The Identity code in your app stays the standard, well-documented surface, and CSharpDB is the part that goes away.

If you are starting a new internal tool, a desktop Blazor app, or a self-hosted edge service, this is a complete identity story without a database server.

Try the Runnable Sample

The repo includes a runnable companion at samples/aspnet-core-identity. It implements the v1-friendly variant of this post — a small custom user store over the CSharpDB.Data ADO.NET provider, with the same cookie + JWT pipeline, role and policy authorization, lockout, and a seeded admin.

dotnet run --project samples/aspnet-core-identity/AspNetCoreIdentitySample.csproj

The sample boots an ASP.NET Core 10 web app at http://localhost:5290, seeds an admin (admin@example.com / ChangeMe!2026), and exposes minimal-API endpoints for cookie login, JWT issuance, role-based authorization, and policy-based authorization. A sample.http file in the project walks through the full flow.