Changelog
Guides

Register users

Create accounts with an email and password, a passkey, or a GitHub or Google account.

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

MethodPackageEndpointsSends a confirmation emailCSRFSigns in on successTwo-factor
Email and passwordAuthEndpointsPOST /identity/register, then GET /identity/confirmEmailYesNoNoThe user turns on 2FA later.
PasskeyAuthEndpoints (passkeys are on by default)POST /account/passkeys/register/options, then POST /account/passkeys/registerYesYesOnly when CanSignInAsync passes. By default it does not.Passkey sign-in skips 2FA.
GitHub or GoogleAuthEndpoints.External.OAuth and AuthEndpoints.OAuth.GitHub or AuthEndpoints.OAuth.GoogleGET /auth/external/login/{provider}, then the callbackNoNo. The OAuth state cookie protects the callback.Yes, when the provider email is verifiedOAuth 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.

  1. Send the email and password to POST /identity/register. The body is Identity's RegisterRequest.
    const response = await fetch('/identity/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password })
    });
    
  2. Check the response:
    • 200 with an empty body: show a "Check your email" screen. The user is not signed in. A duplicate email also returns 200, so use the same copy.
    • 400 validation problem: show the errors. Examples are InvalidEmail and the password-rule codes such as PasswordTooShort.
    • 429: the AuthEndpoints.AccountAbuse rate limit (10 requests per minute per IP) rejected the request.
  3. AuthEndpoints calls IEmailSender<TUser>.SendConfirmationLinkAsync with a link to GET /identity/confirmEmail?userId=...&code=.... The user name is set to the email.
  4. The user opens the link. The response depends on EmailConfirmation.ConfirmEmailRedirectUri:
    • Not set: 200 with the text "Thank you for confirming your email.", or 401 when the link is not valid.
    • Set: 302 to that URI with status=confirmed or status=failed, and flow=confirm. See Email confirmation.
  5. 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

Use AuthEndpoints 3.1.1 or later. In 3.1.0, when JWT is not turned on, passkey registration and passkey sign-in return 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 TUser with a string or Guid key, such as IdentityUser or IdentityUser<Guid>.
  • To choose the minted user id, register an IPasskeyUserIdFactory with AddPasskeyUserIdFactory. The default id is Guid.NewGuid().

Send credentials: 'include' on both requests. The ceremony state lives in a cookie.

  1. Collect the email address.
  2. Get creation options from POST /account/passkeys/register/options with { "email" } and the CSRF header. The endpoint returns WebAuthn creation options even when the email is taken. An invalid email returns a 400 validation problem with the key Email.
  3. Call navigator.credentials.create. If the user cancels, stop. Do not call the register endpoint.
  4. Send the credential to POST /account/passkeys/register with { "email", "credentialJson" } and the CSRF header. Add ?useCookies=true or ?useSessionCookies=true so 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) })
    });
    
  5. 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.
    • 200 with a cookie: sign-in was allowed, for example because RequireConfirmedAccount is false. With JwtPasskeySignInCompleter, 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.": credentialJson was empty.
    • 400 validation problem InvalidPasskeyState: no ceremony was underway. Start again from step 2.
  6. AuthEndpoints sends the same confirmation email as password registration. The user confirms with the same GET /identity/confirmEmail link.
  7. 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.

  1. Install the provider packages:
    dotnet add package AuthEndpoints.OAuth.GitHub --prerelease
    dotnet add package AuthEndpoints.OAuth.Google --prerelease
    
  2. Register the services. AddExternalAuthEndpoints<TUser>() returns an ExternalAuthBuilder. AddGitHub and AddGoogle validate ClientId and ClientSecret at 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"]!;
    });
    
  3. 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
    
  4. Register the handler callback paths with each identity provider: /signin-github for GitHub and /signin-google for Google.
  5. Host an error page at ErrorPath (default /auth/external/error).
  6. 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 RequireVerifiedEmail is false.

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 CookieExternalLoginCompleter and JwtExternalLoginCompleter exist.
  • Built-in Apple or Microsoft providers. You can add a custom provider with IExternalAuthProvider and AddProvider.
  • Setting a first password on a passkey-only or OAuth-only account through POST /identity/manage/info. That endpoint requires oldPassword when you send newPassword.