Register users
AuthEndpoints creates accounts in three ways. Every account needs an email address. When RequireConfirmedAccount is true (the default), password and passkey registration do not sign the user in. GitHub and Google registration signs the user in when the provider returns a verified email.
The examples use the cookie facade defaults: IdentityPath is /identity, PasskeyPath is /account, and the host maps external sign-in under /auth/external.
Compare the registration methods
| Method | Package | Endpoints | Sends a confirmation email | CSRF | Signs in on success | Two-factor |
|---|---|---|---|---|---|---|
| Email and password | AuthEndpoints | POST /identity/register, then GET /identity/confirmEmail | Yes | No | No | The user turns on 2FA later. |
| Passkey | AuthEndpoints (passkeys are on by default) | POST /account/passkeys/register/options, then POST /account/passkeys/register | Yes | Yes | Only when CanSignInAsync passes. By default it does not. | Passkey sign-in skips 2FA. |
| GitHub or Google | AuthEndpoints.External.OAuth and AuthEndpoints.OAuth.GitHub or AuthEndpoints.OAuth.Google | GET /auth/external/login/{provider}, then the callback | No | No. The OAuth state cookie protects the callback. | Yes, when the provider email is verified | OAuth sign-in skips 2FA. |
A duplicate email never tells the caller that the address is taken. Password registration returns 200. Passkey registration returns the generic 400 "Unable to complete registration." GitHub and Google refuse the sign-in with auto_link_disabled unless you turn on AutoLinkByEmail.
Get a CSRF token
Passkey ceremonies require an antiforgery token. Password registration does not. On the cookie facade, get the token from GET /identity/csrfToken and send it in the RequestVerificationToken header:
const { csrfToken } = await fetch('/identity/csrfToken', { credentials: 'include' }).then(r => r.json());
A missing or wrong token returns 400 with the plain-text body Invalid or missing CSRF token. The Antiforgery (CSRF) rules page lists every route that needs a token.
Register with an email and password
Before you start, register a real IEmailSender<TUser>. In Production, startup fails while Identity's no-op sender is registered, unless you set RequireEmailSenderInProduction to false. Set password rules with ConfigureIdentity.
- Send the email and password to
POST /identity/register. The body is Identity'sRegisterRequest.const response = await fetch('/identity/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) }); - Check the response:
200with an empty body: show a "Check your email" screen. The user is not signed in. A duplicate email also returns200, so use the same copy.400validation problem: show the errors. Examples areInvalidEmailand the password-rule codes such asPasswordTooShort.429: theAuthEndpoints.AccountAbuserate limit (10 requests per minute per IP) rejected the request.
- AuthEndpoints calls
IEmailSender<TUser>.SendConfirmationLinkAsyncwith a link toGET /identity/confirmEmail?userId=...&code=.... The user name is set to the email. - The user opens the link. The response depends on
EmailConfirmation.ConfirmEmailRedirectUri:- Not set:
200with the text "Thank you for confirming your email.", or401when the link is not valid. - Set:
302to that URI withstatus=confirmedorstatus=failed, andflow=confirm. See Email confirmation.
- Not set:
- Sign the user in. See Sign users in.
Resend the confirmation email
POST /identity/resendConfirmationEmail sends the link again to the signed-in user's email. It requires a signed-in user and a CSRF token, and it ignores the email field in the body.
When RequireConfirmedAccount is true, an unconfirmed user cannot sign in. That user cannot call this endpoint. Resend helps only when RequireConfirmedAccount is false, or after a signed-in user changes their email. AuthEndpoints has no anonymous resend endpoint.
Register with a passkey
500. So does any other CSRF-protected endpoint called without a session. To upgrade, run dotnet add package AuthEndpoints --version 3.1.1.A passkey account has no password. Before you start:
- Set
Passkeys.ServerDomain. It is required in Production. - Use a
TUserwith astringorGuidkey, such asIdentityUserorIdentityUser<Guid>. - To choose the minted user id, register an
IPasskeyUserIdFactorywithAddPasskeyUserIdFactory. The default id isGuid.NewGuid().
Send credentials: 'include' on both requests. The ceremony state lives in a cookie.
- Collect the email address.
- Get creation options from
POST /account/passkeys/register/optionswith{ "email" }and the CSRF header. The endpoint returns WebAuthn creation options even when the email is taken. An invalid email returns a400validation problem with the keyEmail. - Call
navigator.credentials.create. If the user cancels, stop. Do not call the register endpoint. - Send the credential to
POST /account/passkeys/registerwith{ "email", "credentialJson" }and the CSRF header. Add?useCookies=trueor?useSessionCookies=trueso that a successful sign-in sets a cookie instead of returning bearer tokens.const headers = { 'Content-Type': 'application/json', 'RequestVerificationToken': csrfToken }; const options = await fetch('/account/passkeys/register/options', { method: 'POST', credentials: 'include', headers, body: JSON.stringify({ email }) }).then(r => r.json()); const credential = await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options) }); const response = await fetch('/account/passkeys/register?useCookies=true', { method: 'POST', credentials: 'include', headers, body: JSON.stringify({ email, credentialJson: JSON.stringify(credential) }) }); - Check the response:
200 { "credentialId": "..." }with no cookie: the account exists but cannot sign in yet, because the email is not confirmed. Show the same "Check your email" screen as password registration.200with a cookie: sign-in was allowed, for example becauseRequireConfirmedAccountisfalse. WithJwtPasskeySignInCompleter, the body is{ "accessToken", "tokenType" }and the response sets the JWT refresh cookie.400"Unable to complete registration.": the email is taken, the attestation failed, or the user id already exists. Show generic copy.400"The browser did not provide a passkey.":credentialJsonwas empty.400validation problemInvalidPasskeyState: no ceremony was underway. Start again from step 2.
- AuthEndpoints sends the same confirmation email as password registration. The user confirms with the same
GET /identity/confirmEmaillink. - After confirmation, the user signs in with the passkey. See Sign in with a passkey.
Passwordless registration never adds a passkey to an existing account. To add a passkey to a signed-in account, see Add, rename, and remove passkeys.
Register with GitHub or Google
External OAuth ships in separate preview packages. MapAuthEndpoints does not map it.
- Install the provider packages:
dotnet add package AuthEndpoints.OAuth.GitHub --prerelease dotnet add package AuthEndpoints.OAuth.Google --prerelease - Register the services.
AddExternalAuthEndpoints<TUser>()returns anExternalAuthBuilder.AddGitHubandAddGooglevalidateClientIdandClientSecretat startup.builder.Services.AddExternalAuthEndpoints<AppUser>(o => { o.RequireVerifiedEmail = true; // default o.DefaultReturnUrl = "/"; }) .AddGitHub(o => { o.ClientId = builder.Configuration["Authentication:GitHub:ClientId"]!; o.ClientSecret = builder.Configuration["Authentication:GitHub:ClientSecret"]!; }) .AddGoogle(o => { o.ClientId = builder.Configuration["Authentication:Google:ClientId"]!; o.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"]!; }); - Map the routes:
var external = app.MapGroup("/auth/external"); external.MapGitHubAuthEndpoints<AppUser>(); // GET login/github, login/github/callback external.MapGoogleAuthEndpoints<AppUser>(); // GET login/google, login/google/callback - Register the handler callback paths with each identity provider:
/signin-githubfor GitHub and/signin-googlefor Google. - Host an error page at
ErrorPath(default/auth/external/error). - Start the flow with a top-level navigation, not
fetch:window.location.assign('/auth/external/login/github?returnUrl=' + encodeURIComponent('/dashboard'));
The callback creates an account when all of these are true:
- No local user has this provider login yet.
- The provider returned an email.
- No local user has that email.
- The provider marked the email as verified, or
RequireVerifiedEmailisfalse.
The new user has no password. EmailConfirmed matches the provider's verified flag. AuthEndpoints links the login, signs the user in, and redirects to returnUrl. It does not send a confirmation email.
If RequireVerifiedEmail is false and the email is not verified, the callback still creates the account. It then refuses the sign-in with user_not_allowed, because RequireConfirmedAccount applies.
GitHub reads verified addresses from GET https://api.github.com/user/emails. Google reads email_verified from the userinfo response.
When the callback refuses, it redirects to ErrorPath?error=...&error_description=.... A client that prefers application/json over text/html gets a problem details response instead. The error codes are listed in Responses and errors.
To get a JWT instead of a cookie, register JwtExternalLoginCompleter<TUser> with AddCompleter. After the redirect, the client calls POST /auth/refresh with a CSRF token to get an access token.
What registration does not support
AuthEndpoints does not include these features:
- Sign-up with a magic link or an emailed code.
- Sign-up with a phone number or SMS.
- Sign-up with a user name and no email.
- An anonymous endpoint that resends the confirmation email.
- GitHub or Google sign-up that returns Identity bearer tokens. Only
CookieExternalLoginCompleterandJwtExternalLoginCompleterexist. - Built-in Apple or Microsoft providers. You can add a custom provider with
IExternalAuthProviderandAddProvider. - Setting a first password on a passkey-only or OAuth-only account through
POST /identity/manage/info. That endpoint requiresoldPasswordwhen you sendnewPassword.