Skip to content
Introducing the next era of Duende IdentityServer. Read our CEO’s announcement

Password Authentication Flow

Password authentication is the traditional credential-based flow where users create and manage a password tied to their account. Duende User Management supports passwords as one of several authentication flows, with built-in PBKDF2 hashing, timing-attack protection, configurable complexity rules, and extensible validation.

A fundamental challenge with standalone password authentication is self-service account recovery: there is no way to deliver a password reset token without first verifying that the user controls the delivery channel (email or SMS).

User Management addresses this by design. Creating a user without first verifying a One-Time Password (OTP) channel is not possible via the IUserSelfService interface. By default, a user verifies ownership of their email address via OTP, then creates a password. Whether to allow password-only login or to always require an additional OTP factor during login is a decision left to your application.

IPasswordAuthenticator is the primary interface for verifying a user’s password during login. Inject it into your login page or controller to authenticate a user by a unique attribute (like an email address, username, …) and password. Its single method returns a PasswordAuthenticationResult discriminated union indicating success or failure:

public interface IPasswordAuthenticator
{
Task<PasswordAuthenticationResult> TryAuthenticateAsync(
AttributeCode code,
object value,
NonValidatedPassword password,
CancellationToken ct);
}

TryAuthenticateAsync returns a PasswordAuthenticationResult, which is a discriminated union with three subtypes:

  • PasswordAuthenticationResult.Success — contains the user’s UserSubjectId. The credentials are valid.
  • PasswordAuthenticationResult.Failure — the credentials are invalid.
  • PasswordAuthenticationResult.Expired — contains the user’s UserSubjectId. The credentials are correct, but the password has passed its maximum age and must be changed.

The comparison runs in constant time to prevent account enumeration via timing attacks.

ValidatedPlainTextPassword is a validated value type that cannot be constructed directly. To create one, use the factory methods on IUserAuthenticatorsSelfService. These methods validate the password string against the full set of rules configured in PasswordOptions and return a ValidatedPlainTextPassword on success. They do not store the password or modify the user’s account. You pass the resulting ValidatedPlainTextPassword to a lifecycle method like TrySetPasswordAsync or TryChangePasswordAsync to actually persist it. Both factory methods take the UserSubjectId of the user setting the password as their first argument, so custom validators can apply per-user policy if needed:

IUserAuthenticatorsSelfService.cs
public interface IUserAuthenticatorsSelfService
{
// ...
// Validates the password string and returns a ValidatedPlainTextPassword. Does NOT set the password.
// Throws FormatException if the password does not meet requirements.
Task<ValidatedPlainTextPassword> ValidatePasswordAsync(UserSubjectId userId, string passwordString, CancellationToken ct);
// Validates the password string and returns a PasswordCreationResult. Does NOT set the password.
// Returns a PasswordCreationResult indicating success or failure.
Task<PasswordCreationResult> TryValidatePasswordAsync(UserSubjectId userId, string passwordString, CancellationToken ct);
// ...
}

PasswordCreationResult is a discriminated union with two cases:

PasswordCreationResult.cs
public abstract record PasswordCreationResult
{
// The password passed all validation rules.
public sealed record Success(ValidatedPlainTextPassword Password) : PasswordCreationResult;
// The password failed one or more validation rules.
// Errors contains human-readable reasons suitable for display to the user.
public sealed record Failed(IReadOnlyList<string> Errors) : PasswordCreationResult;
}

Use TryValidatePasswordAsync in user-facing flows where you want to return a validation error rather than catch an exception. Pattern match on PasswordCreationResult.Success to extract the validated password, or on PasswordCreationResult.Failed to surface the specific failure reasons to the user, such as “Password must contain at least 2 uppercase letters.”

The key distinction between ValidatedPlainTextPassword and NonValidatedPassword is when you use each one:

  • Use IUserAuthenticatorsSelfService.ValidatePasswordAsync(userId, ...) or TryValidatePasswordAsync(userId, ...) when the user is setting or changing a password. These are factory methods that validate the input and return a ValidatedPlainTextPassword object. They do not persist anything. Pass the result to a lifecycle method like TrySetPasswordAsync or TryChangePasswordAsync to store it.
  • Use NonValidatedPassword.Create() when the user is logging in with an existing password. Validation rules are intentionally skipped because the rules may have changed since the password was created. For example, if the minimum length increased from 8 to 16 characters after a user set a 12-character password, that password would never pass the new validation and the user could never log in.

When logging in, use NonValidatedPassword rather than ValidatedPlainTextPassword. Validation rules are intentionally skipped at authentication time because those rules may have changed since the password was created. For example, a user whose password was valid when they set it should always be able to log in, even if the policy has since tightened.

NonValidatedPassword still performs basic sanity checks (not null, not empty) to catch invalid input. Use TryCreate in user-facing flows where you want to return a validation error rather than catch an exception.

public record NonValidatedPassword
{
public static NonValidatedPassword Create(string passwordString);
public static bool TryCreate(string? passwordString, [NotNullWhen(true)] out NonValidatedPassword? result);
public static bool TryCreate(string? passwordString, [NotNullWhen(true)] out NonValidatedPassword? result, [NotNullWhen(false)] out IReadOnlyList<string>? errors);
}

Password lifecycle operations (setting, changing, and resetting passwords) are available on IUserAuthenticatorsSelfService. These methods manage the stored credential rather than verifying it.

public interface IUserAuthenticatorsSelfService
{
// Sets a new password for the specified user without requiring the current password.
// Returns false if the user record does not exist.
Task<bool> TrySetPasswordAsync(
UserSubjectId subjectId,
ValidatedPlainTextPassword password,
CancellationToken ct);
// Changes a password by verifying the current password first.
Task<bool> TryChangePasswordAsync(
UserSubjectId subjectId,
NonValidatedPassword oldPassword,
ValidatedPlainTextPassword newPassword,
CancellationToken ct);
// Resets a password without requiring the current password.
// Use only after verifying the user's identity via another channel (e.g., OTP).
Task<bool> TryResetPasswordAsync(
UserSubjectId subjectId,
ValidatedPlainTextPassword password,
CancellationToken ct);
// ... other authenticator management methods
}
  • TrySetPasswordAsync - Sets a new password without requiring the current password. Returns false if the user record does not exist. The user must already have an authenticator record before you can set a password. If the user has no other form of authentication, create an empty authenticator first:
// Create an empty authenticator record for the user
await authenticatorsAdmin.TryAddAsync(profile.SubjectId, [], [], cancellationToken);
  • TryChangePasswordAsync - Use for authenticated password changes; requires the current password to be provided and verified.
  • TryResetPasswordAsync - Use for password reset flows after the user’s identity has been verified via a separate channel such as OTP. Does not require the current password.

All three methods return true on success and false if the operation could not be completed (for example, TryChangePasswordAsync returns false if the current password is incorrect).

Password complexity requirements are configured via PasswordOptions, accessible through the top-level options object when registering User Management services.

Program.cs
using Duende.IdentityServer;
using Duende.UserManagement;
builder.Services
.AddIdentityServer()
.AddUserManagement(um => um
.Authentication(auth =>
{
auth.Configure(options =>
{
options.Passwords.MinLength = 12;
// other PasswordOptions...
});
})
);
PropertyDefaultDescription
MinLength8Minimum password length in characters.
MaxLength64Maximum password length; capped at 64 to avoid PBKDF2 pre-hashing vulnerabilities with SHA-512.
MinLower2Minimum number of lowercase letters required.
MinUpper2Minimum number of uppercase letters required.
MinDigits2Minimum number of numeric digit characters required.
MinSymbols2Minimum number of symbol (non-alphanumeric) characters required.
HistoryCount0Number of previous passwords to remember and reject on change or reset; 0 disables history.
MaxAgeDaysnullMaximum password age in days before the password is considered expired; null disables expiration.
PreferredHashAlgorithm"pbkdf2"Algorithm ID used when hashing new passwords and when re-hashing on login. See Password Hashing Algorithms.

The MaxLength default of 64 comes from the PBKDF2/SHA-512 security limit. Passwords longer than 128 bytes (64 UTF-16 characters) can trigger pre-hashing behavior in PBKDF2 that weakens the key derivation. See the OWASP Password Storage Cheat Sheet for background.

When HistoryCount is set to a value greater than 0, password history validation is automatically enforced. The system retains the hashes of the user’s most recent passwords (up to HistoryCount entries) and rejects any new password that matches one of them. This prevents users from cycling back to a recently used password.

Both TryChangePasswordAsync and TryResetPasswordAsync check the candidate password against the stored history and return false if it matches any of the retained entries.

Program.cs
options.Passwords.HistoryCount = 5;

The default value of 0 disables history checking entirely, so no previous passwords are stored or compared.

When MaxAgeDays is set to a positive integer, TryAuthenticateAsync checks whether the user’s password is older than that many days. If it is, the method returns PasswordAuthenticationResult.Expired instead of PasswordAuthenticationResult.Success. The credentials are correct, but the user must change their password before continuing.

If the system does not know when the password was set (for example, for accounts migrated from an external store without a creation timestamp), the password is treated as expired immediately.

A value of null (the default) disables expiration entirely.

Because TryAuthenticateAsync can return distinct results, your login handler should pattern-match on all three:

LoginPage.cshtml.cs
public async Task<IActionResult> OnPostLogin(string email, string password)
{
var result = await passwordAuth.TryAuthenticateAsync(
AttributeCode.Create("email"),
email,
NonValidatedPassword.Create(password),
ct);
return result switch
{
PasswordAuthenticationResult.Success success => await CompleteSignIn(success.UserSubjectId),
PasswordAuthenticationResult.Expired expired => RedirectToPage("/ChangePassword", new { userId = expired.UserSubjectId }),
PasswordAuthenticationResult.Failure => Error("Invalid username or password"),
_ => Error("Unexpected authentication result")
};
}

Beyond the built-in complexity rules, you can implement IPasswordValidator to add custom policy checks such as blocklist enforcement, breach database lookups (e.g., Have I Been Pwned), or dictionary word rejection. The ValidateAsync method receives the UserSubjectId of the user setting the password, the candidate password string, and a cancellation token.

public interface IPasswordValidator
{
Task<PasswordValidationResult> ValidateAsync(UserSubjectId userId, string password, CancellationToken ct);
}

PasswordValidationResult is a discriminated union with two cases:

public abstract record PasswordValidationResult
{
// The password passed validation.
public sealed record Accepted : PasswordValidationResult;
// The password failed validation.
// Reason is a human-readable explanation suitable for display to the user.
public sealed record Rejected(string Reason) : PasswordValidationResult;
}

You can implement a custom validator by inheriting from the IPasswordValidator interface. The userId parameter lets you apply per-user logic, such as rejecting passwords that contain the user’s own identifier. The following example rejects passwords found in a common-password blocklist:

using Duende.UserManagement.Authentication.Passwords;
public class BlocklistPasswordValidator : IPasswordValidator
{
private static readonly HashSet<string> CommonPasswords =
[
"Password1!", "Welcome1!", "Summer2024!"
];
public Task<PasswordValidationResult> ValidateAsync(UserSubjectId userId, string password, CancellationToken ct)
{
if (CommonPasswords.Contains(password))
{
return Task.FromResult<PasswordValidationResult>(
new PasswordValidationResult.Rejected(
"This password is too common. Please choose a more unique password."));
}
return Task.FromResult<PasswordValidationResult>(
new PasswordValidationResult.Accepted());
}
}

Register the custom validator with the service provider. Multiple IPasswordValidator implementations can be registered. You can register the validator directly with:

services.AddTransient<IPasswordValidator, BlocklistPasswordValidator>();

Or you can use the helper method when configuring user authentication:

using Duende.IdentityServer;
builder.Services
.AddIdentityServer()
.AddUserManagement(um => um
.Authentication(auth =>
{
auth.AddPasswordValidator<BlocklistPasswordValidator>();
})
);

Validations run in registration order, and the first rejection stops further evaluation. Both the built-in complexity checks (length, character class requirements from PasswordOptions) and any registered IPasswordValidator implementations run inside TryValidatePasswordAsync at validation time. This means validation happens when you call TryValidatePasswordAsync, before the password is passed to any lifecycle method. The password lifecycle methods accept an already-validated ValidatedPlainTextPassword and do not re-run the validators.

Passwords are the most attacked credential type on the internet: reused, guessed, phished, and leaked constantly. User Management supports them because some applications need them, but the defaults are designed to make the worst outcomes less likely.

Passwords are hashed with PBKDF2-HMAC-SHA-512 at 210000 iterations, following the OWASP recommendation. Each password gets a unique salt, so two users with the same password have different hashes and rainbow table attacks are useless. The MaxLength is capped at 64 characters to avoid a PBKDF2 pre-hashing vulnerability that appears when passwords exceed the HMAC-SHA-512 block size. TryAuthenticateAsync uses constant-time comparison throughout, so an attacker cannot determine whether an account exists by measuring response times. ValidatedPlainTextPassword and NonValidatedPassword intentionally return the type name from ToString() to prevent accidental logging.

The default minimum password length of 8 characters is a floor, not a recommendation. For new applications, 12-16 characters is a more defensible baseline. Consider plugging in an IPasswordValidator that checks submitted passwords against the Have I Been Pwned k-anonymity API. Rejecting passwords that appear in known breach datasets is one of the highest-value things you can do to reduce credential stuffing risk.

Never use passwords as the only factor. They are phishable, reused across services, and leaked regularly. Pair them with TOTP at minimum, or push users toward passkeys for sensitive operations.

One thing that catches people out: TryResetPasswordAsync does not require the current password. That is intentional; it is for password reset flows where the user has already proved their identity via OTP. But it means your application is responsible for that identity verification step. Calling TryResetPasswordAsync without first confirming who the user is would be a serious security hole.

For cross-cutting security topics (data protection key persistence, throttling configuration, and password hashing parameters) see Security Considerations.

The password authentication flow has two paths: a success path where valid credentials produce a subject ID, and a failure path where invalid credentials, throttling, or lockout prevent access.

The user submits valid credentials and is authenticated in constant time:

sequenceDiagram
    actor User
    participant App
    participant UserManagement as User Management

    User->>App: Submit credentials
    App->>UserManagement: TryAuthenticateAsync(attributeCode, value, password)
    Note over UserManagement: Constant-time PBKDF2 comparison
    UserManagement-->>App: PasswordAuthenticationResult.Success
    App-->>User: Signed in ✓

When credentials are wrong, the system returns a PasswordAuthenticationResult.Failure without revealing whether an account exists. Repeated failures may trigger throttling or account lockout depending on your security configuration:

sequenceDiagram
    actor User
    participant App
    participant UserManagement as User Management

    User->>App: Submit credentials
    App->>UserManagement: TryAuthenticateAsync(attributeCode, value, password)
    Note over UserManagement: Constant-time comparison (prevents timing enumeration)
    UserManagement-->>App: PasswordAuthenticationResult.Failure
    App-->>User: Generic error message ✗
    Note over App: Do not reveal whether an account exists

Inject IPasswordAuthenticator into your login handler. Use NonValidatedPassword.Create(password) when calling TryAuthenticateAsync. This skips policy validation so that users whose passwords predate a rule change can still log in. Pattern-match on all three result types: redirect to a password-change page on Expired, and show a generic error on Failure to avoid revealing whether an account exists. After a successful password check, optionally redirect to a second-factor page if the user has TOTP configured:

LoginPage.cshtml.cs
public async Task<IActionResult> OnPostLogin(string email, string password)
{
var result = await passwordAuth.TryAuthenticateAsync(
AttributeCode.Create("email"),
email,
NonValidatedPassword.Create(password),
ct);
if (result is PasswordAuthenticationResult.Failure)
return Error("Invalid username or password");
if (result is PasswordAuthenticationResult.Expired expired)
return RedirectToPage("/ChangePassword", new { userId = expired.UserSubjectId });
var success = (PasswordAuthenticationResult.Success)result;
var user = await userAuthenticatorsSelfService.TryGetAsync(success.UserSubjectId, ct);
// Optionally check for a second factor before completing sign-in.
if (user?.TotpDeviceNames.Count > 0)
{
StoreAuthState(success.UserSubjectId);
return RedirectToPage("/LoginWith2FA");
}
await SignIn(user);
return RedirectToPage("/Index");
}

Use TryChangePasswordAsync when the user is already signed in and wants to update their password. Use NonValidatedPassword.Create for the current password (authentication path) and authenticatorsSelfService.TryValidatePasswordAsync for the new password (applies full validation rules):

public async Task<IActionResult> OnPostChangePassword(
string currentPassword,
string newPassword,
string confirmNewPassword,
CancellationToken ct)
{
if (newPassword != confirmNewPassword)
return Error("New passwords do not match");
var creationResult = await authenticatorsSelfService.TryValidatePasswordAsync(GetCurrentUserId(), newPassword, ct);
if (creationResult is not PasswordCreationResult.Success { Password: var validatedNewPassword })
return Error("New password does not meet requirements");
var success = await authenticatorsSelfService.TryChangePasswordAsync(
GetCurrentUserId(),
NonValidatedPassword.Create(currentPassword),
validatedNewPassword,
ct);
if (!success)
return Error("Current password is incorrect");
return Success("Password changed successfully");
}

Use TryResetPasswordAsync at the end of a forgot-password flow, after the user’s identity has been confirmed via OTP. This method does not require the current password:

// Step 1: Verify user identity via OTP (see OTP flow documentation).
// Step 2: Once identity is confirmed, reset the password directly.
public async Task<IActionResult> OnPostCompleteReset(string newPassword, CancellationToken ct)
{
var creationResult = await authenticatorsSelfService.TryValidatePasswordAsync(GetVerifiedUserId(), newPassword, ct);
if (creationResult is not PasswordCreationResult.Success { Password: var validatedPassword })
return Error("Password does not meet requirements");
var success = await authenticatorsSelfService.TryResetPasswordAsync(
GetVerifiedUserId(),
validatedPassword,
ct);
if (!success)
return Error("Password reset failed");
return RedirectToPage("/Login");
}

The recommended pattern when using passwords is to combine them with Time-Based One-Time Password (TOTP) for two-factor authentication:

  • First Factor — Password (something you know)
  • Second Factor — TOTP code (something you have)
  • Backup — Recovery codes (something you saved)

See TOTP Authentication Flow for the second-factor implementation, Passkeys as Second Factor for using passkeys as a second step, and Recovery Codes for backup access.

User Management supports multiple password hashing algorithms simultaneously, enabling transparent migration from legacy hashes to stronger algorithms. Each stored hash carries an algorithm identifier, and the system automatically re-hashes passwords on successful login when a stronger algorithm is available. The PreferredHashAlgorithm option in PasswordOptions controls which algorithm is used for new password hashes and for re-hashing on login. See Password Hashing Algorithms for the full list of built-in algorithms and how to register custom ones.