The Account Lifecycle with ASP.NET Core Identity: Registration, Email Confirmation, Password Reset, External Login and Account Deletion
Identity's reset token is never marked as used; it carries the security stamp, so it dies when the password changes, and its default lifetime is one day. We build registration, e-mail confirmation, a password reset provider with its own lifetime, dropping other sessions, e-mail change, the risk of automatic linking in Google external login, and personal data export and account deletion for KVKK/GDPR, with an Angular screen.
Version note: The code in this post was built and run with .NET 10 (ASP.NET Core 10.0.13 runtime), Microsoft.AspNetCore.Identity.EntityFrameworkCore 10.0.14, Microsoft.AspNetCore.Authentication.Google 10.0.14 and Angular 22.2.
Guess First
Ada forgot her password, Bookstore sent her a reset link, and Ada set a new password with it. The next day she opens the same e-mail again and clicks the same link. What happens?
reset 204
["InvalidToken"] reuse 400
The second attempt is rejected. Most people think the reason is "Identity marks the token as used". There is no such mark; the token is never stored in the database. The rejection happens because the user's security stamp is regenerated when the password changes, and the token carries the old stamp. This detail looks small, but it shapes the whole account lifecycle: which token dies when, what happens to other devices after a password reset, what open sessions do when the e-mail changes. In this post we build the life of a Bookstore account end to end: registration, e-mail confirmation, password reset, e-mail change, external login and account deletion. Since the front end is Angular, we don't use Identity's ready-made Razor pages but our own endpoints on top of UserManager and SignInManager.
Token Providers: What Is Inside a Token?
By default, e-mail confirmation, password reset and e-mail change tokens are produced by DataProtectorTokenProvider<TUser>. The token is a small package encrypted with Data Protection: the creation time, the user's id, the purpose (ResetPassword, EmailConfirmation, ChangeEmail:new@address) and the user's current security stamp. Validation checks four things: has it expired, is the id the same, is the purpose the same, is the stamp still the same. Having the stamp inside the token has a strong consequence: any operation that changes the stamp (password reset, password change, e-mail change, 2FA setting) invalidates every outstanding token issued to that user.
The default lifetime, from a run rather than from memory:
DataProtectionTokenProviderOptions.TokenLifespan=1.00:00:00
SignInOptions.RequireConfirmedEmail=False RequireConfirmedAccount=False
One day. Reasonable for e-mail confirmation, long for password reset: for a whole day the link is a sign-in key sitting in an inbox, a forwarded e-mail or a log file. The catch is that there is only one DataProtectionTokenProviderOptions; shorten the lifetime and e-mail confirmation gets shorter too. The fix is a separate provider with its own options for password reset:
public sealed class PasswordResetTokenProviderOptions : DataProtectionTokenProviderOptions
{
public const string ProviderName = "PasswordReset";
}
public sealed class PasswordResetTokenProvider<TUser>(
IDataProtectionProvider dataProtectionProvider,
IOptions<PasswordResetTokenProviderOptions> options,
ILogger<DataProtectorTokenProvider<TUser>> logger)
: DataProtectorTokenProvider<TUser>(dataProtectionProvider, options, logger)
where TUser : class;
builder.Services.AddIdentity<BookstoreUser, BookstoreRole>(options =>
{
options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;
options.SignIn.RequireConfirmedEmail = true;
options.User.RequireUniqueEmail = true;
options.Password.RequiredLength = 12;
options.Tokens.PasswordResetTokenProvider = PasswordResetTokenProviderOptions.ProviderName;
})
.AddEntityFrameworkStores<BookstoreIdentityDbContext>()
.AddDefaultTokenProviders()
.AddTokenProvider<PasswordResetTokenProvider<BookstoreUser>>(PasswordResetTokenProviderOptions.ProviderName);
builder.Services.Configure<DataProtectionTokenProviderOptions>(options =>
options.TokenLifespan = TimeSpan.FromHours(24));
builder.Services.Configure<PasswordResetTokenProviderOptions>(options =>
{
options.Name = PasswordResetTokenProviderOptions.ProviderName;
options.TokenLifespan = builder.Configuration.GetValue("Lab:ResetTokenLifespan", TimeSpan.FromMinutes(15));
});
builder.Services.Configure<SecurityStampValidatorOptions>(options =>
options.ValidationInterval = builder.Configuration.GetValue("Lab:StampInterval", TimeSpan.FromMinutes(1)));
Giving options.Name a different value matters: the Data Protection purpose is derived from this name, so a token produced by one provider cannot be decoded by the other. Password reset gets 15 minutes, e-mail confirmation 24 hours. I also lowered the SecurityStampValidator interval from 30 minutes to 1 minute; why will be visible in the output shortly. The default of RequireConfirmedEmail is false, meaning a user can sign in with an unconfirmed e-mail. In Bookstore we turn it on.
Registration and E-mail Confirmation
account.MapPost("/register", async (RegisterRequest request, UserManager<BookstoreUser> users, OutboxEmailSender email) =>
{
if (await users.FindByEmailAsync(request.Email) is not null)
{
// Same response as a new registration; the account owner is told by e-mail
await email.SendAsync(request.Email, "You already have an account", $"{WebOrigin}/forgot-password");
return TypedResults.Accepted((string?)null);
}
var user = new BookstoreUser { UserName = request.Email, Email = request.Email, DisplayName = request.DisplayName };
IdentityResult result = await users.CreateAsync(user, request.Password);
if (!result.Succeeded)
{
return Results.ValidationProblem(result.Errors.ToDictionary(e => e.Code, e => new[] { e.Description }));
}
string token = await users.GenerateEmailConfirmationTokenAsync(user);
string code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token));
await email.SendAsync(user.Email, "Confirm your e-mail", $"{WebOrigin}/confirm-email?userId={user.Id}&code={code}");
return TypedResults.Accepted((string?)null);
});
There are two decisions. First, a request with an already registered e-mail also gets a 202. Returning a 400 saying "this e-mail is already registered" lets anyone query who is a Bookstore customer (account enumeration). Hiding it completely on a registration form is hard, but keeping the status code and timing the same is an easy win. Second, because the token travels in a URL (Uniform Resource Locator), it is encoded with WebEncoders.Base64UrlEncode. In the lab, instead of sending e-mail I write to an outbox file:
HTTP/1.1 202 Accepted
{"status":"NotAllowed"} 401
HTTP/1.1 202 Accepted
{"to":"ada@bookstore.test","subject":"Confirm your e-mail","link":"https://localhost:4200/confirm-email?userId=1e213a7a-cb16-4a7e-b5c9-f158a06a297c\u0...
{"to":"ada@bookstore.test","subject":"You already have an account","link":"https://localhost:4200/forgot-password"}
In order: registration 202, a sign-in attempt before confirmation gets 401 with SignInResult NotAllowed, a second registration with the same e-mail gets 202 again, but a "you already have an account" e-mail lands in the outbox. The confirmation endpoint decodes the token and calls ConfirmEmailAsync. Here I caught a bug in the first version of the lab: a request with a malformed userId returned 500, because with a Guid-keyed store FindByIdAsync throws FormatException while parsing an invalid string. The fixed version:
account.MapPost("/confirm-email", async (ConfirmEmailRequest request, UserManager<BookstoreUser> users) =>
{
// FindByIdAsync throws FormatException for a malformed Guid; reject it as a bad request
if (!Guid.TryParse(request.UserId, out _) || await users.FindByIdAsync(request.UserId) is not { } user)
{
return Results.BadRequest();
}
string token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(request.Code));
IdentityResult result = await users.ConfirmEmailAsync(user, token);
return result.Succeeded ? TypedResults.NoContent() : Results.BadRequest(result.Errors.Select(e => e.Code));
});
confirm bad id 400
confirm 204
{"status":"signed-in"} 200
Password Reset and Dropping the Other Sessions
account.MapPost("/forgot-password", async (ForgotPasswordRequest request, UserManager<BookstoreUser> users, OutboxEmailSender email) =>
{
BookstoreUser? user = await users.FindByEmailAsync(request.Email);
if (user is not null && await users.IsEmailConfirmedAsync(user))
{
string token = await users.GeneratePasswordResetTokenAsync(user);
string code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token));
await email.SendAsync(request.Email, "Reset your password", $"{WebOrigin}/reset-password?email={Uri.EscapeDataString(request.Email)}&code={code}");
}
// Always the same answer: the endpoint must not reveal whether the account exists
return TypedResults.Accepted((string?)null);
});
account.MapPost("/reset-password", async (ResetPasswordRequest request, UserManager<BookstoreUser> users) =>
{
BookstoreUser? user = await users.FindByEmailAsync(request.Email);
if (user is null)
{
return Results.BadRequest(new[] { "InvalidToken" });
}
string token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(request.Code));
IdentityResult result = await users.ResetPasswordAsync(user, token, request.NewPassword);
return result.Succeeded ? TypedResults.NoContent() : Results.BadRequest(result.Errors.Select(e => e.Code));
});
The scenario: Ada is signed in on two devices (A and B). I request a reset for an existing and a non-existing e-mail, then reset from A, retry the same link and send a request with B's cookie. Finally I run the lab with a 10-second reset lifetime and try a new link after waiting 12 seconds:
HTTP/1.1 202 Accepted
HTTP/1.1 202 Accepted
reset 204
["InvalidToken"] reuse 400
HTTP/1.1 401 Unauthorized
Set-Cookie: bookstore.auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax; httponly
Set-Cookie: Identity.External=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax; httponl...
Set-Cookie: Identity.TwoFactorUserId=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax; ...
Set-Cookie: Identity.TwoFactorRememberMe=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=l...
["InvalidToken"] expired 400
The same 202 for the existing and the non-existing e-mail. The reset succeeds, and the same link gives InvalidToken the second time. Device B gets a 401 on its next request and Identity clears all of its cookies, including the "remember this device" marker. What makes this happen is that ResetPasswordAsync regenerates the security stamp and SecurityStampValidator sees the old stamp in B's cookie. I ran the lab with the interval at zero; with the default 30 minutes, B could have taken up to 30 minutes to drop. For a user who resets their password because they think their account was taken over, 30 minutes is long. In Bookstore I chose 1 minute: one query per minute in exchange for a one-minute window. The expired link was rejected as expected too.
The error messages are deliberately plain: an expired, used or other user's token all return the same InvalidToken. Telling the user "the link is invalid or expired, request a new one" is enough.
The Angular Side: The Reset Screen
The link arrives at the Angular application with email and code query parameters. With withComponentInputBinding() enabled they bind directly to component inputs:
export class ResetPassword {
readonly email = input.required<string>();
readonly code = input.required<string>();
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
private readonly fb = inject(FormBuilder);
readonly status = signal<ResetStatus>('idle');
readonly form = this.fb.nonNullable.group({
newPassword: ['', [Validators.required, Validators.minLength(12)]],
});
async submit(): Promise<void> {
this.status.set('saving');
try {
await firstValueFrom(
this.http.post('/account/reset-password', {
email: this.email(),
code: this.code(),
newPassword: this.form.getRawValue().newPassword,
}),
);
this.status.set('done');
// Remove the single-use code from the address bar and browser history
await this.router.navigate([], { queryParams: {}, replaceUrl: true });
} catch (error) {
const invalid = error instanceof HttpErrorResponse && error.status === 400
&& Array.isArray(error.error) && error.error.includes('InvalidToken');
this.status.set(invalid ? 'invalid-link' : 'error');
}
}
}
In the template I use autocomplete="new-password" so the password manager can suggest and save the new password. When InvalidToken arrives, a "request a new link" message is shown instead of the form. Clearing the query string with replaceUrl: true after success keeps the code out of the browser history. For the same reason this page should have no third-party scripts or images, and the Referrer-Policy header should be at least strict-origin-when-cross-origin; otherwise the code can travel to another site with the full URL in the Referer header. I compiled the component with ng build; I did not do an end-to-end click-through in a browser for this post.
E-mail Change
account.MapPost("/change-email", async (ChangeEmailRequest request, HttpContext context, UserManager<BookstoreUser> users, OutboxEmailSender email) =>
{
BookstoreUser user = (await users.GetUserAsync(context.User))!;
string token = await users.GenerateChangeEmailTokenAsync(user, request.NewEmail);
string code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token));
// The link goes to the NEW address: whoever confirms must own that mailbox
await email.SendAsync(request.NewEmail, "Confirm your new e-mail", $"{WebOrigin}/confirm-email-change?userId={user.Id}&email={Uri.EscapeDataString(request.NewEmail)}&code={code}");
return TypedResults.Accepted((string?)null);
}).RequireAuthorization();
GenerateChangeEmailTokenAsync embeds the new address in the token's purpose (ChangeEmail:new@address). If someone tries to change the address in the confirmation request, the token doesn't match. Then the real change, signing in again and /me:
["InvalidToken"] tampered email 400
confirm change 204
401
{"status":"signed-in"} 200
The 401 after the change is no surprise: ChangeEmailAsync also regenerates the security stamp, so when the e-mail changes every device signs in again. If the user name equals the e-mail, don't forget to call SetUserNameAsync too; otherwise the user cannot sign in with the new address. Also send an "your e-mail address was changed" notice to the old address: the first thing someone who takes over an account usually does is change the e-mail, and the owner of the old address learns about it from that message.
External Login: Linking Google and Two Accounts with the Same E-mail
Google sign-in works through Identity's Identity.External cookie: the Google handler first writes the returned identity into this temporary cookie, then your callback links it to a local account.
account.MapGet("/external/callback", async (SignInManager<BookstoreUser> signIn, UserManager<BookstoreUser> users) =>
{
ExternalLoginInfo? info = await signIn.GetExternalLoginInfoAsync();
if (info is null)
{
return Results.Redirect($"{WebOrigin}/login?error=external");
}
SignInResult result = await signIn.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: false);
if (result.Succeeded)
{
return Results.Redirect(WebOrigin);
}
// No automatic linking by e-mail: an existing local account must sign in first and link explicitly
string? externalEmail = info.Principal.FindFirstValue(ClaimTypes.Email);
if (externalEmail is not null && await users.FindByEmailAsync(externalEmail) is not null)
{
return Results.Redirect($"{WebOrigin}/login?linkRequired={info.LoginProvider}");
}
return Results.Redirect($"{WebOrigin}/register/external");
});
The critical decision is in the comment in the middle. When someone arrives from Google with the e-mail ada@bookstore.test and a local account with that e-mail exists, it is tempting to say "same person" and link automatically. But with a provider that does not verify e-mail addresses, or whose domain you cannot control, that is a shortcut to account takeover: the attacker creates an account at the provider with the victim's e-mail and Bookstore links it to the victim's account. The safe way is to ask the user to sign in with their own password (or passkey) first and link with AddLoginAsync while signed in. To unlink there is RemoveLoginAsync; but don't allow it if the user would be left with no other way to sign in (a password or another provider), or the account gets locked out.
The code in this section compiles, but I did not run it against Google: the lab runs only on the local machine and makes no requests to third-party providers. The AddGoogle registration is only made when a client id exists in configuration. End-to-end OAuth and OIDC (OpenID Connect) traffic with a local Keycloak comes in posts 11 and 12 of the series.
Account Deletion and Personal Data
KVKK (Kişisel Verilerin Korunması Kanunu, Türkiye's Personal Data Protection Law) and GDPR (General Data Protection Regulation) give users the right to obtain and erase their data. Identity uses the [PersonalData] attribute for this; properties of IdentityUser such as e-mail and phone are already marked, and you mark the ones you add:
public sealed class BookstoreUser : IdentityUser<Guid>
{
[PersonalData]
public string DisplayName { get; set; } = "";
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
HTTP/1.1 200 OK
Content-Disposition: attachment; filename=PersonalData.json
{"DisplayName":"Ada","Id":"1e213a7a-cb16-4a7e-b5c9-f158a06a297c","UserName":"ada@bookstore.test","Email":"ada@bookstore.test","EmailConfirmed":"True","PhoneNumber":null,"PhoneNumberConfirmed":"False","TwoFactorEnabled":"False"}
The export endpoint collects the properties that carry [PersonalData] through reflection and adds the linked external logins. Deletion asks for the password again; an account should not be deleted with one click from a session left open:
["PasswordMismatch"] 400
delete 204
$ sqlite3 bookstore-identity.db "select count(*) from AspNetUsers; select count(*) from AspNetUserTokens;"
0
0
DeleteAsync deletes the user row in the Identity tables and, by cascade, the related claim, login, token and passkey rows. But Bookstore's orders, invoices and logs are outside Identity. They need a separate decision: an invoice under a legal retention obligation is not deleted but anonymized; the customer name in the order history is removed; if the logs hold the user id instead of the e-mail, things are easier. To keep sensitive fields encrypted in the database there are also [ProtectedPersonalData] and options.Stores.ProtectPersonalData; for those you need to write an ILookupProtector and key management.
In Production
Don't send e-mail synchronously inside the request; if the SMTP (Simple Mail Transfer Protocol) server is slow, registration and password reset get slow too, and on top of that response times differ for existing and non-existing accounts and enumeration comes back. Write the e-mail to an outbox table and let a background job send it. The outbox file in the lab is the simplest form of this.
Left without rate limiting, /forgot-password and /register can be used to flood someone's inbox. Put limits per IP (Internet Protocol) address and per target e-mail; rate limiting itself was covered in post 11 of the Web Security series. Among Identity's .NET 10 metrics, aspnetcore.identity.user.generated_tokens and aspnetcore.identity.user.verify_token_attempts are directly useful for watching abuse of these endpoints.
Don't forget to share the Data Protection key ring across multiple instances: if one instance cannot decode a reset token produced by another, the user sees "invalid link" and nobody finds out why.
Common Pitfalls
First, leaving the password reset token at the default one-day lifetime, and not noticing that shortening DataProtectionTokenProviderOptions shortens e-mail confirmation too. Use a separate provider.
Second, forgetting open sessions after a reset. The stamp is regenerated, but SecurityStampValidator looks every 30 minutes by default; set the interval to what you need.
Third, revealing whether an account exists through different responses at registration and reset. Fourth, linking external logins automatically by e-mail. Fifth, passing an unvalidated string to FindByIdAsync; with a Guid key that means a 500 and a log full of needless exceptions.
Sixth, logging the reset link. A file like the lab's outbox, or a log line written "for debugging", puts a valid sign-in key where anyone can read it. In production log only "to whom, for what purpose, when", never the link itself.
A Small Experiment
★☆☆ In the lab, use an e-mail confirmation token twice. Compare the result of the second attempt with the password reset token and explain the difference through the security stamp.
★★☆ Generate an e-mail confirmation token for Ada, then change her password and try the same confirmation token. Explain from the token's contents why it returns InvalidToken.
★★★ Write two separate rate limiter policies for /forgot-password: 5 requests per minute per IP and 3 requests per hour per target e-mail. Measure the response time for an existing and a non-existing e-mail with 50 requests each and show why the difference closes.
The account now is born, changes and dies. In the next post we strengthen the sign-in itself: MFA (Multi-Factor Authentication) with TOTP (Time-based One-Time Password), recovery codes, step-up authentication for sensitive operations and why SMS (Short Message Service) is a weak second factor.