Local password login
The fallback, for deployments that cannot run an identity provider. Switching it on means becoming the identity provider - storing credentials, deciding who is who, issuing something the client presents afterwards. Use OIDC if you can.
builder.Services.AddToamaisutaaPasswordLogin(builder.Configuration); // section "LocalLogin"
builder.Services.AddSingleton<IPasswordResetNotifier, YourEmailSender>();
app.MapToamaisutaaPasswordEndpoints();Writing the browser side of this? Using this from a SPA covers the whole flow in one place - the sign-in branch, refresh on 401, and where the tokens should live.
It needs AddToamaisutaaBearer as well - the tokens it issues are validated by the same pipeline that validates your identity provider's, which is why Toamaisutaa.AspNetCore depends on Toamaisutaa.OpenIdConnect. A store registration and an IPasswordResetNotifier are also required, and all three are checked at startup rather than at the first request.
The endpoints
| Method | Route | Answers |
|---|---|---|
| POST | /auth/login | 200 with a token pair, 200 with a two-factor challenge, or 401 |
| POST | /auth/refresh | 200 with a rotated pair, or 401 |
| POST | /auth/logout | 204 |
| GET | /auth/sessions | 200. Authenticated. See sessions |
| DELETE | /auth/sessions/{id} | 204 or 404. Authenticated |
| DELETE | /auth/sessions | 204. Authenticated. Signs out everywhere else |
| POST | /auth/register | 201, 400, or 409. Only mapped when AllowSelfRegistration is true |
| POST | /auth/password | 204. Authenticated. Sets a first password or changes an existing one |
| POST | /auth/password/forgot | 204, always |
| POST | /auth/password/reset | 204 or 400 |
| POST | /auth/email | 204, 400, or 409. Authenticated. Only mapped when an IEmailVerificationNotifier is registered |
| POST | /auth/email/verify | 204, 400, or 409. Only mapped when an IEmailVerificationNotifier is registered |
| POST | /auth/magic-link | 204, always. Only mapped when an IMagicLinkNotifier is registered |
| POST | /auth/magic-link/verify | 200 with a token pair, 200 with a two-factor challenge, or 401. Only mapped when an IMagicLinkNotifier is registered |
| POST | /auth/users | 201, 400, 403, or 409. Admin only. Only mapped when an IAdminPasswordIssuedNotifier is registered and Oidc:AdminRole is set |
| POST | /auth/users/{userId}/password | 204, 400, 403, or 502. Admin only. Only mapped when an IAdminPasswordIssuedNotifier is registered and Oidc:AdminRole is set |
| POST | /auth/invitations | 201, 400, 403, or 502. Admin only. Only mapped when an IInvitationNotifier is registered and Oidc:AdminRole is set |
| POST | /auth/invitations/complete | 201 with a token pair, 400, or 409. Only mapped when an IInvitationNotifier is registered |
| GET | /auth/.well-known/jwks.json | 200 with the public signing keys. Only mapped when SigningKeys is set - see signing local tokens |
Requests are camelCase, token responses are not
Request bodies bind to this package's own records, so they are camelCase: identifier, refreshToken, newPassword. Sign-in responses use the RFC 6749 names - access_token, refresh_token, expires_in, token_type - because a token endpoint is a place where a standard already exists.
The asymmetry is deliberate and it is the one thing here you cannot guess. Everything else this package returns is camelCase.
Bodies
POST /auth/login - deviceToken is optional, and only meaningful with trusted devices.
{ "identifier": "ada", "password": "correct horse battery staple", "deviceToken": null }Answers 200 with a token pair. recovery_codes_running_low is true or null, never false; device_token and device_expires_in are null unless a device was trusted.
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "VWv8Paxg53FWF4HQ_Xzwp8o1EI3YWLSV4PbZAoH1x2M",
"expires_in": 900,
"token_type": "Bearer",
"recovery_codes_running_low": null,
"device_token": null,
"device_expires_in": null
}Or 200 with a challenge and no tokens, for a user who has enrolled in two-factor authentication. Branch on two_factor_required, which is absent from the shape above:
{ "two_factor_required": true, "challenge": "No1CXq9-...", "expires_in": 300 }POST /auth/refresh answers the same token-pair shape, or 401.
{ "refreshToken": "VWv8Paxg53FWF4HQ_Xzwp8o1EI3YWLSV4PbZAoH1x2M" }POST /auth/logout answers 204 whether or not that token existed.
{ "refreshToken": "VWv8Paxg53FWF4HQ_Xzwp8o1EI3YWLSV4PbZAoH1x2M" }POST /auth/register answers 201 with the same token-pair shape, so a registration signs the user straight in.
{ "userName": "ada", "email": "[email protected]", "password": "correct horse battery staple" }POST /auth/password - authenticated. Omit currentPassword when the account arrived through an identity provider and is gaining its first password. Answers 204, 400, or 429 when the per-address rate limit refuses it.
A first password has no current one to prove, so it needs a recent sign-in instead: the caller's token must carry an auth_time from the identity provider, or a toa_2fa_at, within LocalLogin:FirstPasswordProofWindow (five minutes by default). Otherwise a stolen access token would be enough to add a password that outlives it. Send the user back through the identity provider with max_age or prompt=login first, so the token they come back with is fresh.
The access token has to carry auth_time
It is read off the token presented here, which is the access token, and many providers put auth_time only in the ID token. Entra and Auth0 leave it out of access tokens by default, and prompt=login does not change that. Configure the provider to include it - an optional or custom claim on the access token - or these accounts will get 400 however recently they signed in. Keycloak includes it by default.
The new credential signs in with the user name and carries no email address. The provider's email is only what the provider asserted, and copying it in made it a reset address for a mailbox nobody had shown the account owns. Add the address through /auth/email, which proves it.
The user name is the provider's handle, and never its email. An account whose handle is missing or shaped like an address (a UPN is one) gets 400 here: in the user-name column an address becomes a hold on it that proving the mailbox cannot release. Such an account can use passkeys or magic links instead.
A user name cannot contain @, wherever it is chosen - registration, admin creation, invitation completion - and answers 400. The sign-in box takes a user name or an email, so an address-shaped user name claimed that address for sign-in before its owner ever arrived.
{ "currentPassword": "the old one", "newPassword": "the new one" }POST /auth/password/forgot answers 204 always - for an unknown address and for an account an identity provider owns alike. The lookup and the mail happen after the response, on a background queue, so a real account takes no longer to answer than an unknown address: waiting on the mail server inside the request told the clock what the body would not. A second request for the same address inside LocalLogin:MailRequestCooldown (a minute by default) is dropped, whether or not the address has an account.
Your notifier runs without a request
IPasswordResetNotifier.SendAsync is called after the 204, in a scope of its own, with no HttpContext. A notifier that builds its link from IHttpContextAccessor or the request's host throws there, the failure is only logged, and reset mail stops arriving. Build the link from configuration.
{ "email": "[email protected]" }POST /auth/password/reset answers 204 or 400.
{ "token": "the token from the notifier", "newPassword": "the new one" }POST /auth/email - authenticated, asks for a verification link and moves nothing yet. The link goes to newEmail and only there; the account changes when it is redeemed. currentPassword is required, including when newEmail is the address the account already has, which is how a link is asked for again. Answers 204, 400, or 409 - see Email verification.
{ "newEmail": "[email protected]", "currentPassword": "the one they signed in with" }POST /auth/email/verify - anonymous, but only usable with a valid token. Writes the address the token names and stamps it confirmed. Answers 204, 400, or 409.
{ "token": "the token from the notifier" }POST /auth/users - admin only, provisions an account on someone else's behalf. password is optional; omit it and Toamaisutaa generates one. Answers 201, 400, 403, or 409. Never signs the caller in as the new account, and the response never carries a password - see Admin-provisioned accounts.
{ "userName": "newteacher", "email": "[email protected]", "password": null }{ "userId": "0199...", "userName": "newteacher", "email": "[email protected]" }POST /auth/users/{userId}/password - admin only, overwrites userId's password with no current-password check. password is optional; omit it and Toamaisutaa generates one. Answers 204, 400, 403, or 502 when the password was set but could not be delivered - never a password.
The password is mailed only to the credential's own address, never to the profile email an identity provider writes. An account with none gets no mail, so give the password in the request and deliver it another way; leaving it out there answers 400 before anything changes, because a generated password would reach nobody. Creating a user with neither an email nor a password is refused the same way. With LocalLogin:RequireVerifiedEmailForPasswordReset on, an address that was never verified answers 400 before anything changes.
{ "password": null }POST /auth/invitations - admin only, reserves an account with nothing but an email. Answers 201, 400, 403, or 502 when nothing could be delivered and the reservation was rolled back. Never returns the invitation token - see Completing a reserved invitation.
{ "email": "[email protected]" }{ "userId": "0199...", "email": "[email protected]" }POST /auth/invitations/complete - anonymous, but only usable with a valid token. Sets the user name and password on the one reserved account the token names, and signs in - the same shape /auth/register answers with. Answers 201, 400, or 409 for a taken user name.
{ "token": "the token from the notifier", "userName": "newparent", "password": "the one they chose" }The two error shapes
A credential that was not accepted answers 401 with the RFC 6749 shape. Wrong password, no such account, locked out and an unknown refresh token are all this one body, because telling them apart tells a caller which user names are real:
{ "error": "invalid_grant", "error_description": "The credentials are not valid." }Input the caller can correct answers 400 - or 409 for a taken user name - with a camelCase array. These strings are written to be shown to the person who typed the input:
{ "errors": ["Use at least 8 characters."] }A successful sign-in returns a short-lived access token and an opaque refresh token. The access token is signed locally and validated by the same bearer pipeline, so policies, ICurrentUser and provisioning cannot tell the two apart.
A user may have a password, external logins, or both. Adding a password to an account that arrived through OIDC is supported and touches nothing on the external side.
Once a user enrols in two-factor authentication, /auth/login returns a challenge instead of tokens and the sign-in finishes at /auth/2fa/verify.
Things to know before switching it on
Password hashing, and how to replace it, is its own page: Password hashing. So are the other three swap-this-interface seams - the password rules, what goes in the access token, and roles: Customizing local password login.
Lockout is a denial-of-service vector, on purpose
Five failures in fifteen minutes locks an account for fifteen minutes, counted against the account rather than the caller. Someone who knows a user name can keep that person locked out. The alternative is an unthrottled online guessing oracle, which is worse. Per-IP rate limiting on the anonymous endpoints covers the other half, and is enforced by the endpoints themselves rather than by middleware you have to remember to add.
Behind a proxy, configure forwarded headers
The limiter keys on the connection's address. An IPv6 caller counts as its /64, since that is what one customer is handed; an IPv4 caller counts as its address. Behind a reverse proxy the connection comes from the proxy, so unless UseForwardedHeaders() rewrites it, every client shares one limit and ten junk logins a minute answer 429 for the whole site. When requests arrive from a private or loopback address with X-Forwarded-For still on them, the package logs a warning once saying so.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownProxies.Add(IPAddress.Parse("10.0.0.2")); // your proxy, and only your proxy
});
app.UseForwardedHeaders();Registration reveals whether an account exists
A taken user name answers 409. Hiding that needs an email round trip, and email delivery is deliberately not in this package. Registration is off by default; turning it on accepts this.
An address registration takes is held unproven. Whoever later proves it - by redeeming an /auth/email verification link, or by completing an invitation sent to it - takes it over, and the account that only typed it loses it. Otherwise registering somebody else's address first would lock its owner out of every way in.
For the same reason a local access token carries email only once the address is verified, and then it is the credential's address, read again on every issue including a refresh. email is the claim an identity provider's token uses too, and everything downstream reads it as proven: a token that asserted a typed address let anyone register as somebody else's and pass every policy keyed on it. If you replace IAccessTokenIssuer, write AccessTokenRequest.VerifiedEmail, not User.Email.
That email round trip - and two other ways to get someone into an account without open registration - are their own page: Provisioning accounts. Proving that an address belongs to whoever typed it, and changing it afterwards, is Email verification.
Revoking sessions means local sessions
A password change or reset revokes every refresh token this package issued. An access token your identity provider issued keeps working until it expires, because we cannot revoke it.
It also un-trusts every device and deletes every passkey on the account. Both are ways of signing in that a new password would otherwise not touch, and somebody changing their password is usually reacting to exactly that.
Expired tokens accumulate unless you sweep them
AddToamaisutaaTokenCleanup() runs a periodic delete over every expiring row this package writes - refresh tokens, reset tokens, invitation tokens, email verification tokens, magic-link tokens, and the two-factor challenge and trusted-device rows when those are configured. Without it, plan to call DeleteExpiredAsync on each of those stores from your own scheduler.
Refresh rows are the exception to "once expired". A rotated row is what reuse detection works from, so the sweep keeps it until its family is past RefreshTokenAbsoluteLifetime, not merely past its own expiry. If you schedule the refresh sweep yourself, pass now - (RefreshTokenAbsoluteLifetime - RefreshTokenLifetime) rather than now, or a stolen token replayed after two weeks is answered as unknown instead of revoking the session it was stolen from.
Refresh tokens
Stored hashed, never in the clear, and rotated on every use. Presenting a token that has already been rotated proves two parties hold the chain, so the whole family is revoked and the event is logged loudly - the standard mitigation for a stolen refresh token.
Rotation alone would keep a session alive forever, so a chain also has an absolute lifetime (RefreshTokenAbsoluteLifetime, 90 days) measured from the sign-in that started it.
A family is what this package means by a session, and a user can see and end their own: Sessions.
A new claim and the refresh path
If you replace IAccessTokenIssuer and add a claim, decide in the same change what RefreshAsync does with it: recompute it, carry it on the refresh token row, or drop it deliberately. All three are defensible; not having decided is not.
A refresh that silently drops a claim produces a token that is correct at sign-in and wrong exactly one AccessTokenLifetime later, which reads as a policy failure rather than a refresh failure. This package has made that mistake three times and never once caught it with a test.
Calling the services yourself
The endpoints are a convenience, not the API. IPasswordSignInService and IPasswordAccountService are public, so an application that routes everything through its own handlers - a mediator, a different transport, a background job - can skip MapToamaisutaaPasswordEndpoints entirely and call them directly.
public sealed class SignInHandler(IPasswordSignInService signIn)
{
public async Task<YourResult> Handle(YourCommand command, CancellationToken cancellationToken)
{
var result = await signIn.SignInAsync(
new PasswordSignInRequest
{
Identifier = command.Identifier,
Password = command.Password,
UserAgent = command.UserAgent, // yours to supply - Core never sees an HTTP request
IpAddress = command.IpAddress,
},
cancellationToken);
return result.Outcome switch
{
SignInOutcome.Succeeded => YourResult.SignedIn(result.Tokens!),
SignInOutcome.TwoFactorRequired => YourResult.NeedsCode(result.Challenge!),
_ => YourResult.Refused(),
};
}
}Two things worth knowing before you build on them:
SignInOutcometells you what really happened, and the endpoints deliberately throw that away.UnknownUser,InvalidPasswordandLockedOutare three different values here and one 401 on the wire, because telling a caller which one it was tells them which user names are real. If you shape your own response, keep that collapse.- Every result type is a sealed record with public
initmembers, so they compose into DTOs of your own and construct in a test without reflection.SignInResult.Succeededis computed fromOutcome, so set the outcome rather than looking for a setter.
The implementations behind these interfaces are internal. Inject the interface - which is what DI hands you - rather than expecting to construct one.
Configuration
| Key | Default | Notes |
|---|---|---|
LocalLogin:SigningKey | Base64, at least 32 bytes. HS256. Required unless SigningKeys is set, and there is no generated fallback either way | |
LocalLogin:SigningKeys | empty | Asymmetric keys, active one first, published as JWKS. See signing local tokens |
LocalLogin:Issuer | toamaisutaa | Changing it invalidates every token in flight |
LocalLogin:Audience | Oidc:ClientId | |
LocalLogin:AccessTokenLifetime | 00:15:00 | |
LocalLogin:RefreshTokenLifetime | 14.00:00:00 | |
LocalLogin:RefreshTokenAbsoluteLifetime | 90.00:00:00 | How long a rotating chain may live |
LocalLogin:Pbkdf2Iterations | 600000 | Startup floor; 50000000 is the ceiling a stored row may name |
LocalLogin:SaltSizeBytes / HashSizeBytes | 16 / 32 | Startup floor; HashSizeBytes has a ceiling of 1024 |
LocalLogin:Pepper / PepperVersion / RetiredPeppers | none / 1 / empty | See password hashing |
LocalLogin:LockoutEnabled | true | |
LocalLogin:MaxFailedAttempts | 5 | |
LocalLogin:LockoutWindow / LockoutDuration | 00:15:00 | |
LocalLogin:SignInRefusalFloor | 00:00:01 | The least time a refused /auth/login takes, so an unknown name, a wrong password and a locked account cannot be told apart by the clock |
LocalLogin:FirstPasswordProofWindow | 00:05:00 | How recent a sign-in must be to give a passwordless account its first password |
LocalLogin:MinimumPasswordLength | 8 | NIST: a length floor, no composition rules |
LocalLogin:MaximumPasswordLength | 128 | Not a strength rule - a bound on an anonymous endpoint |
LocalLogin:PasswordResetTokenLifetime | 01:00:00 | Single use |
LocalLogin:MailRequestCooldown | 00:01:00 | One reset or magic-link request per address, and one email-change link per account; zero turns it off |
LocalLogin:InvitationTokenLifetime | 7.00:00:00 | Single use |
LocalLogin:EmailVerificationTokenLifetime | 1.00:00:00 | Single use |
LocalLogin:MagicLinkTokenLifetime | 00:15:00 | Single use. See magic-link sign-in |
LocalLogin:RequireVerifiedEmailForPasswordReset | false | See email verification before turning it on |
LocalLogin:AllowSelfRegistration | false | When false the endpoint is not mapped at all |
LocalLogin:EndpointPrefix | /auth | |
LocalLogin:SessionEndpointPrefix | /sessions | Appended to EndpointPrefix. See sessions |
LocalLogin:IpAddressStorage | None | What a session row keeps of the caller's address |
LocalLogin:RateLimit:Enabled | true | Per caller address, fixed window. One budget across the anonymous endpoints and the signed-in ones that take a password or code as proof |
LocalLogin:RateLimit:PermitLimit / Window | 10 / 00:01:00 | |
LocalLogin:RateLimit:Nat64Prefixes:0 | A network-specific NAT64 prefix in front of you, such as 2001:db8:64::/96. Its IPv4 clients are keyed on their own address rather than all sharing the gateway's. 64:ff9b::/96 and 64:ff9b:1::/48 are handled without it | |
LocalLogin:TokenCleanupInterval | 06:00:00 | Only used by AddToamaisutaaTokenCleanup() |