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 an embedded, ACID, single-file option for that store. It ships with an 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, identity data can live inside the application directory.
This post shows the provider's bounded EF Core Identity configuration, JWT bearer configuration around the same model, and a lightweight custom IUserStore. It closes with Data Protection key persistence so cookies survive process restarts. Treat the EF snippets as a starting point and use the provider guide for supported behavior and current limits; the custom ADO.NET sample is the runnable end-to-end authentication path.
IdentityUser<int> and IdentityRole<int>. They verify the seven standard tables plus the documented user, role, membership, claim, login, token, persistence, cascade, concurrency, transaction, and cancellation workflows. The default string-key context, Identity schema versions 2 and 3, passkeys, and unlisted store APIs remain unsupported. See the provider guide for details. The companion custom-store sample remains the runnable cookie, JWT, role, policy, and lockout demonstration.
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
ASP.NET Core Identity is built on EF Core, but the supported CSharpDB configuration is more specific than a connection-string swap: use integer user and role keys and pin the Identity model to schema v1 as shown below.
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
Extend the integer-key Identity user while retaining the supported key and schema configuration.
using CSharpDB.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
public sealed class AppUser : IdentityUser<int>
{
public string? DisplayName { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class AppDbContext
: IdentityDbContext<AppUser, IdentityRole<int>, int>
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
public DbSet<AuditEntry> AuditLog => Set<AuditEntry>();
protected override Version SchemaVersion => new(1, 0);
}
public sealed class AuditEntry
{
public int Id { get; set; }
public int UserId { get; set; }
public string Action { get; set; } = "";
public DateTime AtUtc { get; set; }
}
The three-generic-argument IdentityDbContext keeps users, roles, and relationship rows on integer keys. Overriding SchemaVersion prevents .NET 10 from selecting unsupported schema-v3/passkey metadata. The model contains users, roles, user-roles, user-claims, role-claims, user-logins, and user-tokens; application tables can live beside them.
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<int>>(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.EnsureCreatedAsync();
}
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();
app.MapControllers();
app.Run();
UseCSharpDb selects the database file, while AddEntityFrameworkStores<AppDbContext> registers Identity's EF stores over the supported integer-key model. Integration tests cover the workflows documented in the provider guide; validate any additional manager, token-provider, two-factor, or UI flow your application enables.
Step 4: Create the Supported Schema
await db.Database.EnsureCreatedAsync();
The integration fixture uses EnsureCreatedAsync and verifies reopen persistence for AspNetUsers, AspNetRoles, AspNetUserRoles, AspNetUserClaims, AspNetRoleClaims, AspNetUserLogins, and AspNetUserTokens. General EF migrations are supported by the provider, but an Identity-specific generated-migration deployment is not covered by this bounded application configuration; validate that rollout against your exact model before using it in production.
Step 5: Register and Sign In
The following minimal-API endpoints illustrate normal Identity composition. They exercise manager and sign-in flows beyond the documented store-level behavior, so add end-to-end tests for the enabled password, lockout, cookie, token, and UI paths before adopting them.
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);
ASP.NET Core's managers own password hashing, verification, lockout policy, and cookie issuance. CSharpDB integration tests cover the documented persistence-store operations underneath that layer; manager-level behavior should be covered by the application's authentication tests.
Authorization: Roles and Policies
Role data lives in AspNetUserRoles, and claim data in AspNetUserClaims. Integer-key membership and claim persistence are in the tested store workflow; authorization-policy behavior belongs in the application's end-to-end test suite.
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<int>>>();
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<int>(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 schema creation. This is illustrative manager-level code; the runnable custom-store sample provides the repository's end-to-end seeded-admin proof.
Protecting Endpoints
// Role-based
app.MapDelete("/api/users/{id:int}", (int id) => Results.Ok())
.RequireAuthorization(new AuthorizeAttribute { Roles = "Admin" });
// Policy-based (claim)
app.MapPost("/api/users/{id:int}/lock", (int id) => Results.Ok())
.RequireAuthorization("CanManageUsers");
// Multiple requirements
[Authorize(Roles = "Editor,Admin", Policy = "CanManageUsers")]
public class UsersController : ControllerBase { /* ... */ }
Each attribute resolves through the standard IAuthorizationService. Keep authorization code provider-neutral, and test the exact role and claim lookups your application triggers.
Approach 2: JWT Bearer for APIs and SPAs
For APIs and single-page apps you typically issue JWTs instead of cookies. Identity can still own user creation and password verification while CSharpDB persists the supported model. JWT issuance, refresh-token rotation, and the manager APIs used by your application need their own end-to-end coverage.
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<int>>()
.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();
This path avoids an EF migration model and keeps the enabled store behavior directly inspectable. The trade-off is that you implement the IUserStore surface yourself for every feature you want—claims, lockout, two-factor, or external logins. Choose between the custom store and the bounded EF profile based on the exact interfaces and workflows your application can test.
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. Keep connection lifetime owned by EF Core's scoped
DbContext. The integration suite covers a stale concurrency-stamp update and persistence across reopen; load-test the reader/writer mix and request volume of your application. - 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.dbplus 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. General provider migrations and migration locking are supported, but the bounded Identity profile currently proves
EnsureCreated, not an Identity-specific generated-migration rollout. Test the exact migration bundle and rollback procedure before deployment. - Storage tuning.
UseStoragePreset(CSharpDbStoragePreset.WriteOptimized)is available for write-heavy embedded workloads; benchmark it against your actual login, lockout, and token traffic.
What You Get
The supported EF configuration gives a tested persistence foundation for schema-v1 integer-key users, roles, memberships, claims, logins, and tokens in a single ACID file. ASP.NET Core still supplies the password, cookie, JWT, and authorization layers, but features outside the documented provider surface—such as broader manager APIs, two-factor flows, passkeys, and newer Identity schemas—must be validated by the application.
For an internal tool, desktop Blazor app, or self-hosted edge service, use this bounded profile as a starting point, or run the custom-store sample when its explicitly implemented cookie, JWT, role, policy, and lockout surface better matches the application.
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.