Import API reference
This page covers the types and interfaces you use to build and submit import batches, handle results, and resolve conflicts when imported data overlaps with existing records.
Importing Users
Section titled “Importing Users”IUserImporter is the entry point for bulk import. It is registered as a transient service automatically when adding Duende User Management,
and can be injected anywhere in your application.
public interface IUserImporter{ Task<UserImportBatchResult> ImportAsync(IReadOnlyList<UserImportRecord> records, CancellationToken ct);}For each record in the batch, the importer first ensures a root user record exists for the given SubjectId.
This record coordinates identity across all aspects and is created before any per-aspect work begins.
Then, the importer runs up to three steps in order:
- Profile: creates or updates the user profile with the provided
SubjectIdandProfileAttributes. - Authenticator: creates or updates authenticator data (passwords, passkeys, TOTP keys, OTP addresses, external providers, recovery codes).
- Membership: assigns the user to the specified groups and roles.
Each step is optional. If you only provide Authenticators on a record, the profile and membership steps are skipped entirely.
If a step encounters existing data (for example, a profile with the same SubjectId already exists), the importer calls
the registered IUserImportConflictResolver to decide what to do. The default resolver retries on concurrency conflicts
and skips everything else, which means a first-time import of new users works out of the box without any configuration.
If a record needs to overwrite existing data, or if you want to customize the behavior for specific conflict reasons,
you can register your own resolver. See Conflict Resolution for details.
A failure on one record does not affect the others in the batch. The importer processes every record and returns a
UserImportBatchResult with per-record outcomes, so you always know exactly which records succeeded and which did not.
End-to-end example
Section titled “End-to-end example”The following example imports two users: one with a password and group membership, and one with an external authenticator.
public class UserImportService(IUserImporter importer, IUserProfileAdmin profileAdmin){ public async Task RunAsync(CancellationToken ct) { // Build profile attributes using the schema so values are type-validated. var schema = await profileAdmin.GetSchemaAsync(ct);
var aliceAttributes = new AttributeValueCollection(schema); aliceAttributes.Set(AttributeCode.Create("email"), "alice@example.com"); aliceAttributes.Set(AttributeCode.Create("display_name"), "Alice");
// Represent a pre-hashed bcrypt password from the source system. // Hash and Salt are raw bytes; supply the actual bytes from your source data. var bcryptHash = new HashedPasswordData( algorithmId: "bcrypt", hash: new byte[] { /* raw hash bytes from source */ }, salt: new byte[] { /* raw salt bytes from source */ }, parameters: new Dictionary<string, string> { ["cost"] = "12" });
var records = new List<UserImportRecord> { new UserImportRecord { SubjectId = new UserSubjectId("user-001"), ProfileAttributes = aliceAttributes.Validate(), Authenticators = new AuthenticatorImport { Password = new PasswordImport(bcryptHash), }, Memberships = new MembershipImport { Groups = new[] { GroupId.Create("admins") }, }, }, new UserImportRecord { SubjectId = new UserSubjectId("user-002"), Authenticators = new AuthenticatorImport { ExternalAuthenticatorAddresses = new[] { new ExternalAuthenticatorAddress( Provider: "google", ProviderSubjectId: "google-sub-abc123" ), }, }, }, };
UserImportBatchResult result = await importer.ImportAsync(records, ct);
Console.WriteLine($"Created: {result.CreatedCount}"); Console.WriteLine($"Updated: {result.UpdatedCount}"); Console.WriteLine($"Skipped: {result.SkippedCount}"); Console.WriteLine($"Failed: {result.FailedCount}");
foreach (UserImportResult r in result.Results) { if (r.Status == UserImportStatus.Failed) Console.WriteLine($" FAILED {r.SubjectId}: {r.Error}"); } }}Building Import Records
Section titled “Building Import Records”This section covers the types you use to describe what gets imported for each user: the record itself, authenticator data, and group or role memberships.
UserImportRecord
Section titled “UserImportRecord”Each record describes a single user to import. Only SubjectId is required; all other fields are optional.
public sealed record UserImportRecord{ public required UserSubjectId SubjectId { get; init; } public ValidatedAttributeValueCollection? ProfileAttributes { get; init; } public AuthenticatorImport? Authenticators { get; init; } public MembershipImport? Memberships { get; init; }}You can provide any combination of ProfileAttributes, Authenticators, and Memberships.
AuthenticatorImport
Section titled “AuthenticatorImport”AuthenticatorImport groups all authenticator data for a user. Each field is optional; include only the authenticator
types you are migrating.
public sealed record AuthenticatorImport{ public IReadOnlyCollection<OtpAddress>? OtpAddresses { get; init; } public IReadOnlyCollection<ExternalAuthenticatorAddress>? ExternalAuthenticatorAddresses { get; init; } public IReadOnlyCollection<PasskeyImport>? Passkeys { get; init; } public PasswordImport? Password { get; init; } public IReadOnlyCollection<TotpDeviceImport>? TotpAuthenticators { get; init; } public IReadOnlyCollection<PlainTextRecoveryCode>? RecoveryCodes { get; init; }}PasswordImport
Section titled “PasswordImport”public sealed record PasswordImport(HashedPasswordData Data);PasswordImport carries a pre-hashed password from the source system. The platform stores the hash as-is and verifies
it using the IPasswordHashAlgorithm registered for the stored algorithm ID. On the first successful authentication,
the password is transparently re-hashed using the current preferred algorithm, so users are migrated to the new hashing
scheme without any disruption.
TotpDeviceImport
Section titled “TotpDeviceImport”public sealed record TotpDeviceImport(TotpDeviceName Name, PlainBytesTotpKey Key);TotpDeviceImport carries the raw TOTP secret key from the source system. Provide the key as a PlainBytesTotpKey
and a display name for the authenticator.
PasskeyImport
Section titled “PasskeyImport”public sealed record PasskeyImport{ public required IReadOnlyList<byte> CredentialId { get; init; } public required IReadOnlyList<byte> PublicKeyCose { get; init; } public required int Algorithm { get; init; } public uint SignCount { get; init; } public bool BackupEligible { get; init; } public bool BackedUp { get; init; } public Guid Aaguid { get; init; } public required string Name { get; init; }}PasskeyImport carries the raw WebAuthn credential data. CredentialId and PublicKeyCose are the byte arrays
from the original registration ceremony. Algorithm is the COSE algorithm identifier (for example, -7 for ES256).
SignCount, BackupEligible, BackedUp, and Aaguid correspond to the authenticator data fields from the original attestation.
MembershipImport
Section titled “MembershipImport”public sealed record MembershipImport{ public IReadOnlyCollection<GroupId>? Groups { get; init; } public IReadOnlyCollection<RoleId>? DirectRoles { get; init; }}MembershipImport assigns the user to existing groups and roles. The referenced groups and roles must already exist
before you run the import; a missing group or role causes a hard failure on that individual record.
Handling Results
Section titled “Handling Results”UserImportBatchResult
Section titled “UserImportBatchResult”ImportAsync returns a UserImportBatchResult with a per-record result list and aggregate counts.
public sealed record UserImportBatchResult{ public required IReadOnlyList<UserImportResult> Results { get; init; } public int CreatedCount { get; } public int UpdatedCount { get; } public int SkippedCount { get; } public int FailedCount { get; }}UserImportResult
Section titled “UserImportResult”Each entry in UserImportBatchResult.Results corresponds to one input record and reports the outcome for that record.
public sealed record UserImportResult{ public required UserSubjectId SubjectId { get; init; } public required UserImportStatus Status { get; init; } public string? Error { get; init; }}When Status is Failed, Error contains a description of what went wrong.
For all other statuses, Error is null.
UserImportStatus enum
Section titled “UserImportStatus enum”| Value | Meaning |
|---|---|
Created | The user was successfully created. |
Updated | The user was successfully updated (overwrite conflict resolution was applied). |
Skipped | The user was skipped because a conflict was resolved as Skip. |
Failed | The user failed to import due to an error. |
Conflict Resolution
Section titled “Conflict Resolution”When the importer tries to create a profile, authenticator, or membership record and discovers that matching
data already exists, it raises a conflict. Rather than failing immediately, the importer delegates the decision
to an IUserImportConflictResolver. The resolver inspects the conflict (which record, which step, and why it conflicted)
and returns a resolution: skip the step, overwrite the existing data, or retry the operation.
This design keeps the import pipeline itself generic. The policy for handling duplicates, unique attribute collisions, and concurrency races lives in the resolver, which you can swap out without changing any import logic.
Default behavior
Section titled “Default behavior”The built-in resolver is intentionally conservative. It retries when a concurrency conflict occurs (another process modified the same record at the same time) and skips everything else. In practice, this means:
- A first-time import of new users works without any configuration.
- Re-running the same import skips every record that was already imported, because the default resolver treats
ProfileAlreadyExistsandAuthenticatorAlreadyExistsas skip. - If you need idempotent re-runs (where re-importing a record updates it in place), you need a custom resolver that returns
Overwriteinstead ofSkip.
IUserImportConflictResolver
Section titled “IUserImportConflictResolver”public interface IUserImportConflictResolver{ Task<UserImportConflictResolution> ResolveAsync(UserImportConflict conflict, CancellationToken ct);}The default implementation is described above. To customize conflict handling, register your own implementation with the service provider (see Registering a custom resolver).
UserImportConflict
Section titled “UserImportConflict”The resolver receives a UserImportConflict describing the record, the step that failed, and the reason.
public sealed record UserImportConflict{ public required UserImportRecord Record { get; init; } public required UserImportStep Step { get; init; } public required UserImportConflictReason Reason { get; init; } public required Exception Exception { get; init; }}UserImportStep enum
Section titled “UserImportStep enum”| Value | Meaning |
|---|---|
Profile | The user profile creation or update step. |
Authenticator | The authenticator creation or update step. |
Membership | The membership assignment step. |
UserImportConflictReason enum
Section titled “UserImportConflictReason enum”| Value | Meaning |
|---|---|
ProfileAlreadyExists | A user profile with the same subject ID already exists. |
ProfileUniqueKeyConflict | A unique attribute value on the incoming record already belongs to a different user profile. |
AuthenticatorAlreadyExists | Authenticators for the same subject ID already exist. |
AuthenticatorKeyConflict | A unique authenticator key is already claimed by a different user. |
ConcurrencyConflict | An optimistic concurrency conflict occurred. |
MembershipAlreadyExists | A membership record for the same subject ID already exists. |
UserImportConflictResolution
Section titled “UserImportConflictResolution”The resolver returns one of three resolutions:
public abstract record UserImportConflictResolution{ public sealed record Skip : UserImportConflictResolution; public sealed record Overwrite(UserSubjectId TargetSubjectId) : UserImportConflictResolution; public sealed record Retry : UserImportConflictResolution;}Skip: skips the conflicting step; existing data is left unchanged.Overwrite(TargetSubjectId): overwrites the existing user; profile attributes are overlaid and authenticators are merged additively.Retry: retries the operation, which is useful when the resolver has taken corrective action such as deleting the conflicting record. Retries are subject to an internal cap.
Registering a custom resolver
Section titled “Registering a custom resolver”IUserImportConflictResolver is registered as a singleton with the default implementation. To override it,
register your own implementation before the default is used:
builder.Services.AddSingleton<IUserImportConflictResolver, CustomConflictResolver>();The following example overwrites existing profiles but skips all other conflicts:
public class CustomConflictResolver : IUserImportConflictResolver{ public Task<UserImportConflictResolution> ResolveAsync( UserImportConflict conflict, CancellationToken ct) { UserImportConflictResolution resolution = conflict.Reason switch { UserImportConflictReason.ProfileAlreadyExists => new UserImportConflictResolution.Overwrite(conflict.Record.SubjectId),
UserImportConflictReason.ConcurrencyConflict => new UserImportConflictResolution.Retry(),
_ => new UserImportConflictResolution.Skip(), };
return Task.FromResult(resolution); }}