Articles · Compliance & Migration
Importing identity
Try out the running example
Nobody adopts an identity system on an empty database. Somewhere there’s an
AspNetUsers table, a Keycloak realm, an Auth0 tenant or a Duende config — full of
users who must keep logging in with passwords you cannot read. This article is about
the machinery that moves them: four importers, one target, one invariant — no
password-reset email blast, ever.
One target, four sources
Every importer writes through IImportTarget — upsert-shaped methods over users,
credentials, roles, groups, clients. AddSentinelEfImportTarget() implements it over
the EF stores; the sample runs all four importers against one
SQLite-backed host:
var options = new ImportOptions { TargetRealmId = RealmId, DryRun = dryRun };
var aspnet = await new AspNetIdentityImporter(target)
.ImportAsync(LegacyExports.AspNetIdentity(), options, ct);
var keycloak = await new KeycloakRealmImporter(target, hasher)
.ImportAsync(LegacyExports.KeycloakRealmExport(), options, ct);
var auth0 = await new Auth0Importer(target)
.ImportAsync(LegacyExports.Auth0Export(), options, ct);
var duende = await new DuendeConfigImporter(target, hasher)
.ImportAsync(LegacyExports.DuendeClientsExport(), options, ct);
Each run returns an ImportReport: created/updated/skipped counts per entity, an
Issues list with severities, and (for client importers) GeneratedClientSecrets —
more on that below.
Two properties hold across all four:
DryRun = truewrites nothing. Full report, zero rows — the triage list you run in CI against last night’s export before anyone schedules a cutover window (Dry_run_reports_everything_and_writes_nothing).- Re-import is idempotent. Entities match by natural key — email,
imported:role key, group key,clientId— so re-running updates in place and never duplicates. A delta export the night before cutover is routine, not scary (Reimport_is_idempotent_updating_instead_of_duplicating).
Imported roles arrive prefixed (Support Agent → imported:support-agent) so they can
never collide with the catalog your declarative config
declares.
ASP.NET Core Identity: the hash rides along
The Identity importer reads the classic four tables — AspNetUsers, AspNetRoles,
AspNetUserRoles, AspNetUserClaims (claims become user attributes) — through an
IAspNetIdentitySource you implement over your legacy connection. The interesting part
is the password column: Identity V3’s PBKDF2 blob is carried verbatim, tagged
aspnet-identity-v3:
services.AddSingleton(new PasswordHasher(
new Argon2idPasswordHashAlgorithm(memoryKib: 8, iterations: 1, parallelism: 1), // cheap: teaching code
[new Pbkdf2PasswordHashAlgorithm(), .. ImporterHashAlgorithms.All()]));
The first argument is what new hashes are minted with (argon2id); the accepted set adds the foreign verifiers. At login the credential’s algorithm tag picks the verifier, and on success the hash is transparently rehashed to argon2id — the upgrade happens the only moment the plaintext legitimately exists:
GET /migration/credentials/alice@legacy.sample → { "algorithms": ["aspnet-identity-v3"] }
POST /auth/login (her password since 2019) → 200
GET /migration/credentials/alice@legacy.sample → { "algorithms": ["argon2id"] }
The sample generates its fixture hashes with the actual
Microsoft.AspNetCore.Identity hasher, so the coexistence path is exercised against the
real producer (Identity_v3_hash_verifies_at_login_and_rehashes_to_argon2id).
Keycloak: the whole realm export
Point KeycloakRealmImporter at the JSON kc.sh export writes and it walks realm
roles, groups (by path), users — with pbkdf2-sha256 credentials re-encoded into PHC
format for Sentinel’s PBKDF2 verifier, bcrypt ones carried as-is — plus clients:
publicClient maps to public/PKCE, plaintext client secrets are re-hashed through your
hasher on the way in. Both Keycloak’s older hashedSaltedValue credential field and the
newer secretData/credentialData
shape are understood. Kara logs in with her Keycloak password on day one
(Keycloak_pbkdf2_and_auth0_bcrypt_credentials_both_log_in).
Auth0: ndjson, bcrypt, and the blocked flag
Auth0’s bulk export is newline-delimited JSON; bcrypt hashes appear either as the
export’s top-level passwordHash or in the import format’s
custom_password_hash.hash.value. Both land as bcrypt credentials that verify
natively. email_verified carries over, blocked: true becomes a Sentinel
suspension — Dan can’t log in on the new system either, which is the point. Users
without a bcrypt hash (social-only accounts) import credential-less — unsupported
algorithms get a warning issue — so plan a federated login for
them instead.
Duende: rotation-on-migration
IdentityServer stores client secrets as base64(sha256(secret)) — a fine storage
format and an unmigratable one: there’s no plaintext to re-hash into argon2id. The
importer refuses to pretend otherwise. For every confidential client it generates a
fresh secret, stores only its hash, and surfaces the plaintext exactly once, in the
report:
var generated = report.GeneratedClientSecrets; // { "legacy-mvc": "<fresh 256-bit secret>" }
That dictionary is your rotation checklist: hand each value to the owning team, update their config, done. The old secret is dead on arrival —
hasher.VerifyAndUpgrade(newSecret, mvc.SecretAlgorithm!, mvc.SecretHash!, out _) // true
hasher.VerifyAndUpgrade("old-mvc-secret", mvc.SecretAlgorithm!, mvc.SecretHash!, out _) // false
— and public clients, having no secret, don’t appear
(Duende_sha256_secrets_trigger_rotation_on_migration). Grant types, PKCE flags,
redirect URIs and scopes map onto the OIDC server’s
client model; the exotic ones (device flow, CIBA) come back as issues rather than
silently dropping.
The import is the easy half
Users in, credentials verifying, clients rotated — that’s the data. The migration risk lives in the other half: does the new engine make the same authorization decisions the old code did? That’s a different article — shadow-mode migration runs Sentinel’s evaluator silently beside your legacy checks and gates cutover on a divergence counter reading zero. The pipeline is deliberately staged: dry-run → import → login natively → shadow → cutover, and each stage is a test you can re-run, not a leap you take once.