Incendia.Identity.Mongo 10.1.0

dotnet add package Incendia.Identity.Mongo --version 10.1.0
                    
NuGet\Install-Package Incendia.Identity.Mongo -Version 10.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Incendia.Identity.Mongo" Version="10.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Incendia.Identity.Mongo" Version="10.1.0" />
                    
Directory.Packages.props
<PackageReference Include="Incendia.Identity.Mongo" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Incendia.Identity.Mongo --version 10.1.0
                    
#r "nuget: Incendia.Identity.Mongo, 10.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Incendia.Identity.Mongo@10.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Incendia.Identity.Mongo&version=10.1.0
                    
Install as a Cake Addin
#tool nuget:?package=Incendia.Identity.Mongo&version=10.1.0
                    
Install as a Cake Tool

Incendia.Identity.Mongo

A MongoDB store for ASP.NET Core Identity that writes only what changed.

Adding a claim sends $push of that claim. Changing an email sends $set of the email fields. Removing a passkey sends $pull of that passkey. Every write is guarded by the user's ConcurrencyStamp, so concurrent requests never silently overwrite each other — exactly the guarantees of the EF Core stores, on a document database.

Works with the standard IdentityUser / IdentityRole (or your own subclasses), UserManager, RoleManager and SignInManager. Built on Incendia.MongoTracker.

dotnet add package Incendia.Identity.Mongo

Your model stays free of MongoDB

Other MongoDB providers for Identity make your user and role classes inherit their own base types (MongoUser<TKey> / MongoRole<TKey> in AspNetCore.Identity.Mongo, MongoIdentityUser<TKey> / MongoIdentityRole<TKey> in AspNetCore.Identity.MongoDbCore). The class your application, domain and API code pass around then depends on a storage library.

Here there are no special base classes: the stores work with the standard IdentityUser<TKey> / IdentityRole<TKey>, or your own subclasses, exactly as they are. Everything MongoDB-specific — the document layout, change tracking, concurrency checks — lives inside the stores and their registration, so the rest of the application does not need to know which database Identity uses. That is how storage is best kept anyway: behind the Identity abstractions, not in the model.

It also makes switching easy. An application built on ASP.NET Core Identity — for example with the EF Core stores — moves to MongoDB by replacing the store registration with AddMongoStores: the user and role types and all code working through UserManager, RoleManager and SignInManager stay unchanged. What does need attention is outside that code: existing users and roles have to be migrated to the new collections, and any query written directly against the DbContext has to use the Users / Roles queries instead.


Contents


Why

Most MongoDB Identity providers save a user by replacing the whole document. That works, but every change — a failed login counter, a new claim, a refreshed token — rewrites the entire user with all its claims, logins and tokens, and two requests that changed different things can only be told apart by the concurrency stamp.

This provider tracks changes instead and sends minimal atomic updates, while keeping the Identity semantics:

Incendia.Identity.Mongo AspNetCore.Identity.Mongo AspNetCore.Identity.MongoDbCore EF Core Identity stores + MongoDB EF Core provider
User and role types Standard IdentityUser<TKey> / IdentityRole<TKey> or your subclasses Must inherit MongoUser<TKey> / MongoRole<TKey> Must inherit MongoIdentityUser<TKey> / MongoIdentityRole<TKey> Standard IdentityUser<TKey> / IdentityRole<TKey>
How a user is saved Minimal $set / $push / $pull ReplaceOne of the whole user ReplaceOne of the whole user; claims, logins and tokens rewritten as whole arrays Role and claim queries (GetRolesAsync, GetUsersInRoleAsync, GetUsersForClaimAsync) use joins, which the provider does not support
ConcurrencyStamp check Every write Update and delete Update and delete Every write
Passkeys (.NET 10) ✅ — — Supported by the stores

<sub>Compared with the latest sources of AspNetCore.Identity.Mongo and AspNetCore.Identity.MongoDbCore at the time of writing, and with the EF Core Identity UserStore (GetRolesAsync, GetUsersInRoleAsync, GetUsersForClaimAsync use LINQ joins).</sub>


Quick start

builder.Services
  .AddIdentity<IdentityUser, IdentityRole>(options =>
  {
    options.Lockout.MaxFailedAccessAttempts = 5;
    options.SignIn.RequireConfirmedEmail = true;
  })
  .AddMongoStores(options =>
  {
    options.Database = mongoClient.GetDatabase("app");

    // Optional
    options.UsersCollection = "Users";   // default
    options.RolesCollection = "Roles";   // default
    options.CreateIndexes = true;        // see "Indexes"
  })
  .AddDefaultTokenProviders();

That's it — use UserManager, RoleManager and SignInManager as usual:

var user = new IdentityUser("egor") { Email = "egor@example.com" };
await userManager.CreateAsync(user, password);

await userManager.AddToRoleAsync(user, "admin");
await userManager.AddClaimAsync(user, new Claim("tier", "gold"));
await userManager.AddLoginAsync(user, new UserLoginInfo("Google", googleId, "Google"));

What is written to MongoDB

Each UserManager / RoleManager call that changes a user sends one update with only the affected fields and elements, filtered by _id and the ConcurrencyStamp the user was loaded with. These are the actual updates, recorded by the integration tests of this repository:

Call Update sent
AddClaimAsync(user, new Claim("tier", "gold")) $push: { Claims: { ClaimType: "tier", ClaimValue: "gold" } }
AddClaimsAsync(user, [a, b]) $push: { Claims: { $each: [a, b] } }
RemoveClaimAsync(user, claim) $pull: { Claims: { ClaimType: …, ClaimValue: … } }
AddLoginAsync(user, login) $push: { Logins: { LoginProvider, ProviderKey, ProviderDisplayName } }
RemoveLoginAsync(user, …) $pull: { Logins: … } + $set of the new User.SecurityStamp
AddToRoleAsync(user, "admin") $push: { Roles: "<role id>" }
RemoveFromRoleAsync(user, "admin") $pull: { Roles: "<role id>" }
SetAuthenticationTokenAsync(user, …) (new token) $push: { Tokens: { LoginProvider, Name, Value } }
SetEmailAsync(user, email) $set: { User.Email, User.NormalizedEmail, User.SecurityStamp }
AccessFailedAsync(user) $set: { User.AccessFailedCount: 1 }
AddOrUpdatePasskeyAsync(user, passkey) (new) $push: { Passkeys: { CredentialId, Data } }
AddOrUpdatePasskeyAsync(user, passkey) (sign-in) $set: { "Passkeys.$[i0].Data.SignCount": … } with arrayFilters: [{ "i0.CredentialId": … }]
RemovePasskeyAsync(user, credentialId) $pull: { Passkeys: { CredentialId: { $in: [ … ] } } }
RoleManager.SetRoleNameAsync + UpdateAsync $set: { Role.Name, Role.NormalizedName }
RoleManager.AddClaimAsync(role, claim) $push: { Claims: … }

Every update also sets the new User.ConcurrencyStamp (or Role.ConcurrencyStamp), as Identity requires.

Two operations replace an array instead of changing one element, because MongoDB cannot add and remove elements of the same array in a single update: ReplaceClaimAsync, and updating the value of an existing token (SetAuthenticationTokenAsync, authenticator key, recovery codes). They are still guarded by the concurrency stamp.


Concurrency

Every update and delete is filtered by the ConcurrencyStamp the user (or role) had when it was loaded. If another request changed or deleted it in the meantime, the operation returns IdentityErrorDescriber.ConcurrencyFailure, exactly as with the EF Core stores:

// Request A and request B loaded the same user
await usersA.AddClaimAsync(userA, new Claim("tier", "gold"));   // Succeeded
await usersB.AddClaimAsync(userB, new Claim("lang", "ru"));     // Failed: ConcurrencyFailure, nothing overwritten

Users and roles may come from anywhere, including the Users and Roles queries of an admin page. An instance the store has not loaded itself is compared with the stored document, so all its changes are saved; a copy older than the stored document is rejected with ConcurrencyFailure.

Passkeys are addressed by their credential id, never by their position in the array, so a passkey is always changed or removed precisely, whatever happened to the others.


Document layout

A user is stored as one document. The Identity user is kept as is under User, and its related data lives next to it in arrays:

{
  "_id": "c6435c66-60f4-4476-86ad-11977b64e6b8",
  "User": {
    "_id": "c6435c66-60f4-4476-86ad-11977b64e6b8",
    "UserName": "egor",
    "NormalizedUserName": "EGOR",
    "Email": "egor@example.com",
    "NormalizedEmail": "EGOR@EXAMPLE.COM",
    "EmailConfirmed": false,
    "PasswordHash": "AQAAAAIAAYagAAAAE…",
    "SecurityStamp": "XBUXK3CTK645PNULEAGCPPNGQ2NF5TXN",
    "ConcurrencyStamp": "4dbb187d-7acd-4e60-9f7f-f0a519891934",
    "PhoneNumber": null,
    "PhoneNumberConfirmed": false,
    "TwoFactorEnabled": false,
    "LockoutEnd": null,
    "LockoutEnabled": true,
    "AccessFailedCount": 0
  },
  "Roles": ["586648da-889b-4b07-a03d-a0aa5e1d3e61"],
  "Claims": [{ "ClaimType": "tier", "ClaimValue": "gold" }],
  "Logins": [{ "LoginProvider": "Google", "ProviderKey": "1093", "ProviderDisplayName": "Google" }],
  "Tokens": [{ "LoginProvider": "Google", "Name": "access_token", "Value": "ya29.a0" }],
  "Passkeys": []
}

A role document has the same shape: { "_id", "Role": { … }, "Claims": [ … ] }. Roles of a user holds role ids. The user's own properties are untouched by the store: whatever your TUser serializes to is stored under User.


Your own user and role types

Inherit from IdentityUser<TKey> / IdentityRole<TKey> as usual. Plain properties are tracked without any configuration. Nested objects and collections need a mode, otherwise they are compared as a whole value and a change made in place (user.Profile.City = "…") is not detected:

public class AppUser : IdentityUser<Guid>
{
  public Profile Profile { get; set; } = new();
  public List<string> Interests { get; set; } = [];
}

builder.Services
  .AddIdentity<AppUser, IdentityRole<Guid>>()
  .AddMongoStores(
    options => options.Database = database,
    model =>
    {
      model.Entity<AppUser>(e =>
      {
        e.Property(u => u.Profile).IsChild();   // tracked field by field
        e.Property(u => u.Interests).IsSet();   // $push / $pull of single values
      });
    });

Now user.Profile.City = "Kazan"; await userManager.UpdateAsync(user); sends $set: { "User.Profile.City": "Kazan" } and nothing else. See the MongoTracker property modes for all options.

Instead of the callback you can register your own IdentityModelBuilder instance before AddMongoStores; the stores use it and the Identity configuration is added to it:

var model = new IdentityModelBuilder();
model.Entity<AppUser>(e => e.Property(u => u.Profile).IsChild());

builder.Services.AddSingleton(model);
builder.Services.AddIdentity<AppUser, IdentityRole<Guid>>().AddMongoStores(options => options.Database = database);

Identifiers

Users and roles created without an identifier get one, like with a database-generated key: Guid and string keys receive a new Guid, ObjectId keys a new ObjectId. Identifiers of other types must be set before CreateAsync. FindByIdAsync accepts the string form of any of these keys.

Applications using Guid keys usually register a serializer for the representation they store:

BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));

Indexes

UserManager checks that a user name is free before creating a user, but two registrations running at the same time can both pass that check. With options.CreateIndexes = true the stores create these indexes when the application starts (through an IHostedService, so a generic host such as ASP.NET Core is required):

Collection Index Purpose
Users User.NormalizedUserName, unique Rejects the second of two concurrent registrations of one name
Users User.NormalizedEmail Lookup by email (not unique: unique emails are optional in Identity)
Users Logins.LoginProvider + Logins.ProviderKey Lookup by external login
Users Roles Users in a role
Users Claims.ClaimType + Claims.ClaimValue Users for a claim
Users Passkeys.CredentialId, unique (.NET 10) Lookup by passkey
Roles Role.NormalizedName, unique Rejects the second of two concurrent creations of one role

It is disabled by default because a unique index cannot be created on a collection that already contains duplicates.


User-only mode, custom stores

Without roles, use AddIdentityCore:

builder.Services
  .AddIdentityCore<AppUser>()
  .AddMongoStores(options => options.Database = database);

The stores are registered in DI and can be replaced or extended — inherit from UserStore<TUser, TRole, TKey>, UserOnlyStore<TUser, TKey> or RoleStore<TRole, TKey> and register your class:

builder.Services.AddScoped<IUserStore<AppUser>, AuditedUserStore>();

Requirements and limitations

  • .NET 8, .NET 9 or .NET 10; passkeys (IUserPasskeyStore) on .NET 10.
  • MongoDB.Driver 3.9 or later (through Incendia.MongoTracker).
  • The store methods that change claims, logins, tokens, roles or passkeys only stage the change; it is saved by UpdateAsync, which UserManager calls right after them. This is how the EF Core stores handle claims, logins, tokens and roles; for passkeys it also makes the change and the new concurrency stamp a single update. Call UpdateAsync yourself if you use the store interfaces directly.
  • IdentityOptions.Stores.ProtectPersonalData is not supported: the stores do not encrypt personal data. Enabling it makes UserManager throw at construction instead of storing the data unencrypted.
  • Deleting a role does not remove its id from the users that had it. Such ids are ignored by GetRolesAsync, IsInRoleAsync and GetUsersInRoleAsync.

Tests

The integration tests run UserManager and RoleManager against a real MongoDB (Testcontainers) and check both the stored data and the exact updates sent for every operation, including concurrent requests.

License

MIT.

Feedback

Questions and issues: GitHub Issues.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
10.1.0 0 10/3/2026
10.0.4 167 1/30/2026
10.0.3 135 1/22/2026
10.0.2 132 1/21/2026