diff --git a/web/backend/Features/Features.Auth/Identity/ApplicationUser.cs b/web/backend/Features/Features.Auth/Identity/ApplicationUser.cs
new file mode 100644
index 00000000..a901bd58
--- /dev/null
+++ b/web/backend/Features/Features.Auth/Identity/ApplicationUser.cs
@@ -0,0 +1,20 @@
+namespace Features.Auth.Identity;
+
+///
+/// The user type driving for
+/// Features.Auth. A plain POCO (not IdentityUser<TKey>) backed by
+/// rather than EF Core, matching the rest of the solution's Dapper-only persistence.
+///
+public sealed class ApplicationUser
+{
+ public Guid Id { get; set; }
+ public string UserName { get; set; } = string.Empty;
+ public string? NormalizedUserName { get; set; }
+ public string Email { get; set; } = string.Empty;
+ public string? NormalizedEmail { get; set; }
+ public bool EmailConfirmed { get; set; }
+ public string? PasswordHash { get; set; }
+ public string FirstName { get; set; } = string.Empty;
+ public string LastName { get; set; } = string.Empty;
+ public DateTime DateOfBirth { get; set; }
+}
diff --git a/web/backend/Features/Features.Auth/Identity/Argon2PasswordHasher.cs b/web/backend/Features/Features.Auth/Identity/Argon2PasswordHasher.cs
new file mode 100644
index 00000000..a8bcd37f
--- /dev/null
+++ b/web/backend/Features/Features.Auth/Identity/Argon2PasswordHasher.cs
@@ -0,0 +1,20 @@
+using Infrastructure.PasswordHashing;
+using Microsoft.AspNetCore.Identity;
+
+namespace Features.Auth.Identity;
+
+public sealed class Argon2PasswordHasher(IPasswordInfrastructure passwordInfrastructure)
+ : IPasswordHasher
+{
+ public string HashPassword(ApplicationUser user, string password) =>
+ passwordInfrastructure.Hash(password);
+
+ public PasswordVerificationResult VerifyHashedPassword(
+ ApplicationUser user,
+ string hashedPassword,
+ string providedPassword
+ ) =>
+ passwordInfrastructure.Verify(providedPassword, hashedPassword)
+ ? PasswordVerificationResult.Success
+ : PasswordVerificationResult.Failed;
+}
diff --git a/web/backend/Features/Features.Auth/Identity/DapperUserStore.cs b/web/backend/Features/Features.Auth/Identity/DapperUserStore.cs
new file mode 100644
index 00000000..f555f3fc
--- /dev/null
+++ b/web/backend/Features/Features.Auth/Identity/DapperUserStore.cs
@@ -0,0 +1,381 @@
+using System.Data.Common;
+using Dapper;
+using Infrastructure.Sql;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Data.SqlClient;
+
+namespace Features.Auth.Identity;
+
+///
+/// Dapper-based implementation of the ASP.NET Core Identity store interfaces consumed by
+/// , persisting against the existing dbo.UserAccount,
+/// dbo.UserCredential, and dbo.UserVerification tables. No EF Core is used, and no schema
+/// changes were required: normalized name/email are computed on the fly rather than persisted (the
+/// existing unique indexes are already case-insensitive under SQL Server's default collation).
+///
+public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
+ : Repository(connectionFactory),
+ IUserStore,
+ IUserPasswordStore,
+ IUserEmailStore
+{
+ private const string SelectUserSql =
+ """
+ SELECT UserAccountID AS Id, Username AS UserName, FirstName, LastName, Email, DateOfBirth
+ FROM dbo.UserAccount
+ """;
+
+ public void Dispose() { }
+
+ public Task GetUserIdAsync(ApplicationUser user, CancellationToken cancellationToken) =>
+ Task.FromResult(user.Id.ToString());
+
+ public Task GetUserNameAsync(ApplicationUser user, CancellationToken cancellationToken) =>
+ Task.FromResult(user.UserName);
+
+ public Task SetUserNameAsync(
+ ApplicationUser user,
+ string? userName,
+ CancellationToken cancellationToken
+ )
+ {
+ user.UserName = userName ?? string.Empty;
+ return Task.CompletedTask;
+ }
+
+ public Task GetNormalizedUserNameAsync(
+ ApplicationUser user,
+ CancellationToken cancellationToken
+ ) => Task.FromResult(user.NormalizedUserName);
+
+ public Task SetNormalizedUserNameAsync(
+ ApplicationUser user,
+ string? normalizedName,
+ CancellationToken cancellationToken
+ )
+ {
+ user.NormalizedUserName = normalizedName;
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Inserts the account and its initial credential within a single database transaction, mirroring the
+ /// previous AuthRepository.RegisterUserAsync behavior. already
+ /// checks username/email uniqueness before calling this method, so the duplicate-key branch here only
+ /// guards against a genuine race between two concurrent registrations.
+ ///
+ public async Task CreateAsync(
+ ApplicationUser user,
+ CancellationToken cancellationToken
+ )
+ {
+ await using DbConnection connection = await CreateConnection();
+ await using DbTransaction transaction = await connection.BeginTransactionAsync(
+ cancellationToken
+ );
+
+ try
+ {
+ Guid userAccountId = await connection.ExecuteScalarAsync(
+ new CommandDefinition(
+ """
+ INSERT INTO dbo.UserAccount (Username, FirstName, LastName, DateOfBirth, Email)
+ OUTPUT INSERTED.UserAccountID
+ VALUES (@UserName, @FirstName, @LastName, @DateOfBirth, @Email);
+ """,
+ new
+ {
+ user.UserName,
+ user.FirstName,
+ user.LastName,
+ user.DateOfBirth,
+ user.Email,
+ },
+ transaction,
+ cancellationToken: cancellationToken
+ )
+ );
+
+ await connection.ExecuteAsync(
+ new CommandDefinition(
+ """
+ INSERT INTO dbo.UserCredential (UserAccountId, Hash)
+ VALUES (@UserAccountId, @Hash);
+ """,
+ new { UserAccountId = userAccountId, Hash = user.PasswordHash },
+ transaction,
+ cancellationToken: cancellationToken
+ )
+ );
+
+ await transaction.CommitAsync(cancellationToken);
+ user.Id = userAccountId;
+ }
+ catch (SqlException ex) when (IsDuplicateKeyViolation(ex))
+ {
+ await transaction.RollbackAsync(cancellationToken);
+ return IdentityResult.Failed(
+ new IdentityError
+ {
+ Code = "DuplicateUserName",
+ Description = "Username or email already exists.",
+ }
+ );
+ }
+ catch
+ {
+ await transaction.RollbackAsync(cancellationToken);
+ throw;
+ }
+
+ return IdentityResult.Success;
+ }
+
+ ///
+ /// Persists profile field changes and, when is
+ /// , ensures a corresponding dbo.UserVerification row exists (idempotently,
+ /// matching the previous AuthRepository.ConfirmUserAccountAsync behavior).
+ ///
+ public async Task UpdateAsync(
+ ApplicationUser user,
+ CancellationToken cancellationToken
+ )
+ {
+ await using DbConnection connection = await CreateConnection();
+
+ await connection.ExecuteAsync(
+ new CommandDefinition(
+ """
+ UPDATE dbo.UserAccount
+ SET Username = @UserName, FirstName = @FirstName, LastName = @LastName,
+ Email = @Email, DateOfBirth = @DateOfBirth, UpdatedAt = GETDATE()
+ WHERE UserAccountID = @Id
+ """,
+ new
+ {
+ user.Id,
+ user.UserName,
+ user.FirstName,
+ user.LastName,
+ user.Email,
+ user.DateOfBirth,
+ },
+ cancellationToken: cancellationToken
+ )
+ );
+
+ if (user.EmailConfirmed)
+ await EnsureVerificationRowAsync(connection, user.Id, cancellationToken);
+
+ return IdentityResult.Success;
+ }
+
+ public Task DeleteAsync(
+ ApplicationUser user,
+ CancellationToken cancellationToken
+ ) => throw new NotSupportedException("User deletion is not supported.");
+
+ public async Task FindByIdAsync(
+ string userId,
+ CancellationToken cancellationToken
+ )
+ {
+ if (!Guid.TryParse(userId, out Guid id))
+ return null;
+
+ await using DbConnection connection = await CreateConnection();
+ ApplicationUser? user = await connection.QueryFirstOrDefaultAsync(
+ new CommandDefinition(
+ $"{SelectUserSql} WHERE UserAccountID = @Id",
+ new { Id = id },
+ cancellationToken: cancellationToken
+ )
+ );
+
+ if (user != null)
+ await HydrateAsync(connection, user, cancellationToken);
+
+ return user;
+ }
+
+ public async Task FindByNameAsync(
+ string normalizedUserName,
+ CancellationToken cancellationToken
+ )
+ {
+ await using DbConnection connection = await CreateConnection();
+ ApplicationUser? user = await connection.QueryFirstOrDefaultAsync(
+ new CommandDefinition(
+ $"{SelectUserSql} WHERE Username = @UserName",
+ new { UserName = normalizedUserName },
+ cancellationToken: cancellationToken
+ )
+ );
+
+ if (user != null)
+ await HydrateAsync(connection, user, cancellationToken);
+
+ return user;
+ }
+
+ public Task SetPasswordHashAsync(
+ ApplicationUser user,
+ string? passwordHash,
+ CancellationToken cancellationToken
+ )
+ {
+ user.PasswordHash = passwordHash;
+ return Task.CompletedTask;
+ }
+
+ public Task GetPasswordHashAsync(
+ ApplicationUser user,
+ CancellationToken cancellationToken
+ ) => Task.FromResult(user.PasswordHash);
+
+ public Task HasPasswordAsync(ApplicationUser user, CancellationToken cancellationToken) =>
+ Task.FromResult(user.PasswordHash != null);
+
+ public Task SetEmailAsync(
+ ApplicationUser user,
+ string? email,
+ CancellationToken cancellationToken
+ )
+ {
+ user.Email = email ?? string.Empty;
+ return Task.CompletedTask;
+ }
+
+ public Task GetEmailAsync(ApplicationUser user, CancellationToken cancellationToken) =>
+ Task.FromResult(user.Email);
+
+ public Task GetEmailConfirmedAsync(
+ ApplicationUser user,
+ CancellationToken cancellationToken
+ ) => Task.FromResult(user.EmailConfirmed);
+
+ public Task SetEmailConfirmedAsync(
+ ApplicationUser user,
+ bool confirmed,
+ CancellationToken cancellationToken
+ )
+ {
+ user.EmailConfirmed = confirmed;
+ return Task.CompletedTask;
+ }
+
+ public async Task FindByEmailAsync(
+ string normalizedEmail,
+ CancellationToken cancellationToken
+ )
+ {
+ await using DbConnection connection = await CreateConnection();
+ ApplicationUser? user = await connection.QueryFirstOrDefaultAsync(
+ new CommandDefinition(
+ $"{SelectUserSql} WHERE Email = @Email",
+ new { Email = normalizedEmail },
+ cancellationToken: cancellationToken
+ )
+ );
+
+ if (user != null)
+ await HydrateAsync(connection, user, cancellationToken);
+
+ return user;
+ }
+
+ public Task GetNormalizedEmailAsync(
+ ApplicationUser user,
+ CancellationToken cancellationToken
+ ) => Task.FromResult(user.NormalizedEmail);
+
+ public Task SetNormalizedEmailAsync(
+ ApplicationUser user,
+ string? normalizedEmail,
+ CancellationToken cancellationToken
+ )
+ {
+ user.NormalizedEmail = normalizedEmail;
+ return Task.CompletedTask;
+ }
+
+ /// Loads the active credential hash and verification status onto a freshly queried user.
+ private static async Task HydrateAsync(
+ DbConnection connection,
+ ApplicationUser user,
+ CancellationToken cancellationToken
+ )
+ {
+ user.PasswordHash = await connection.ExecuteScalarAsync(
+ new CommandDefinition(
+ """
+ SELECT Hash
+ FROM dbo.UserCredential
+ WHERE UserAccountId = @UserAccountId AND IsRevoked = 0
+ """,
+ new { UserAccountId = user.Id },
+ cancellationToken: cancellationToken
+ )
+ );
+
+ int? verified = await connection.ExecuteScalarAsync(
+ new CommandDefinition(
+ """
+ SELECT TOP 1 1
+ FROM dbo.UserVerification
+ WHERE UserAccountID = @UserAccountId
+ """,
+ new { UserAccountId = user.Id },
+ cancellationToken: cancellationToken
+ )
+ );
+ user.EmailConfirmed = verified.HasValue;
+ }
+
+ ///
+ /// If a concurrent request verifies the same user first, the resulting duplicate-key SQL exception
+ /// (error 2601/2627) is swallowed to keep the operation idempotent.
+ ///
+ private static async Task EnsureVerificationRowAsync(
+ DbConnection connection,
+ Guid userAccountId,
+ CancellationToken cancellationToken
+ )
+ {
+ int? alreadyVerified = await connection.ExecuteScalarAsync(
+ new CommandDefinition(
+ """
+ SELECT TOP 1 1
+ FROM dbo.UserVerification
+ WHERE UserAccountID = @UserAccountId
+ """,
+ new { UserAccountId = userAccountId },
+ cancellationToken: cancellationToken
+ )
+ );
+
+ if (alreadyVerified.HasValue)
+ return;
+
+ try
+ {
+ await connection.ExecuteAsync(
+ new CommandDefinition(
+ """
+ INSERT INTO dbo.UserVerification (UserAccountId, VerificationDateTime)
+ VALUES (@UserAccountId, GETDATE())
+ """,
+ new { UserAccountId = userAccountId },
+ cancellationToken: cancellationToken
+ )
+ );
+ }
+ catch (SqlException ex) when (IsDuplicateKeyViolation(ex))
+ {
+ // A concurrent request verified this user first. Keep behavior idempotent.
+ }
+ }
+
+ /// Determines whether a is a duplicate key violation (SQL Server error 2601 or 2627).
+ private static bool IsDuplicateKeyViolation(SqlException ex) => ex.Number is 2601 or 2627;
+}