Changelog
Guides

Sign users in

Sign users in with a password, a passkey, or GitHub or Google, and handle two-factor codes and recovery codes.

Pick the sign-in method from the table, then follow its section. The examples use the facade defaults: IdentityPath is /identity, PasskeyPath is /account, Jwt.Path is /auth, and the host maps external sign-in under /auth/external. To pick a stack for your client, see Choose a sign-in stack.

Compare the sign-in methods

MethodEndpointsResultCSRFTwo-factor
Password, cookie (facade default)POST /identity/loginApplication cookie, empty 200 bodyNo401 with title RequiresTwoFactor, or send the code in the first request
Password, Identity bearerPOST /identity/login, POST /identity/refreshAccessTokenResponse JSONNo401 with title RequiresTwoFactor
Password, JWTPOST /auth/create, POST /auth/refresh{ accessToken, tokenType } and an HttpOnly refresh cookiecreate: no. refresh: yes.401 with the extension requiresTwoFactor: true
PasskeyPOST /account/passkeys/requestOptions, POST /account/passkeys/loginCookie (with a query flag), Identity bearer tokens (no flag), or a JWT (JWT completer)YesSkipped
GitHub or GoogleGET /auth/external/login/{provider}, then the callbackPersistent application cookie, or a JWT refresh cookieNoSkipped

Every login request uses the AuthEndpoints.Login rate limit. JWT POST /auth/refresh uses it too. Identity bearer POST /identity/refresh has no rate limit. See Rate-limit policies.

The cookie facade maps LoginCookie on POST /identity/login. The body is Identity's LoginRequest: { "email", "password", "twoFactorCode"?, "twoFactorRecoveryCode"? }. AuthEndpoints passes email to PasswordSignInAsync as the user name. Registration sets the user name to the email, so the email works.

  1. Send the credentials with credentials: 'include'. Login does not need a CSRF token.
    const response = await fetch('/identity/login', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password })
    });
    
  2. To keep the cookie after the browser closes, add ?useSessionCookies=false. LoginCookie reads only useSessionCookies. It ignores useCookies.
  3. Check the response:
    • 200 with an empty body and a Set-Cookie header for .AspNetCore.Identity.Application: the user is signed in.
    • 401 with title RequiresTwoFactor: ask for a code. See Enter a two-factor code or a recovery code.
    • 401 with title Unauthorized and detail Invalid credentials.: the password is wrong, the account is locked out, or the email is not confirmed. AuthEndpoints returns one response for all three so that it does not reveal which one applies.
    • 429: the rate limit rejected the request.

Sign in with a password and Identity bearer tokens

Use this method for native and mobile clients. Turn it on with AddAuthEndpoints<TUser, TContext>(AuthEndpointsSignIn.IdentityBearer, ...), or compose AddBearerAuthEndpoints and MapBearerAuthEndpoints.

  1. Send the same LoginRequest body to POST /identity/login without query flags.
  2. On success, read { "tokenType", "accessToken", "expiresIn", "refreshToken" } from the 200 body. Send the access token as Authorization: Bearer <accessToken>.
  3. To get a new access token, send { "refreshToken" } to POST /identity/refresh.

useCookies=true on this login sets a persistent application cookie instead of returning tokens. useSessionCookies=true sets a session cookie. The 2FA challenge is the same as cookie login: 401 with title RequiresTwoFactor.

The bearer facade maps no /csrfToken route, but passkey ceremonies still need a CSRF token. To use passkeys on a bearer host, map an antiforgery token endpoint yourself. See Quick start.

Sign in with a password and a JWT

Turn on JWT with o.Jwt.Enabled = true and o.Jwt.Configure, or compose AddJwtEndpoints and MapJwtAuthEndpoints. Call modelBuilder.UseRefreshToken() on your DbContext and add a migration. See JWT module.

  1. Send { "email", "password", "twoFactorCode"?, "twoFactorRecoveryCode"? } to POST /auth/create with credentials: 'include'. The email field also accepts a user name.
    const { accessToken } = await fetch('/auth/create', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password })
    }).then(r => r.json());
    
  2. On success, the 200 body is { "accessToken", "tokenType": "Bearer" }. The response also sets the AuthEndpoints.Jwt.RefreshToken cookie. That cookie is HttpOnly, SameSite=Strict, and lasts 14 days.
  3. To get a new access token, call POST /auth/refresh with the cookie and a CSRF token from GET /auth/csrfToken. The body is { "accessToken" }, with no tokenType.

create returns these errors:

  • 400 { "error": "invalid_request", ... } when email or password is missing. This body is not problem details.
  • 401 with detail Invalid credentials. for a wrong password, a lockout, or an unconfirmed email.
  • 401 with the extension requiresTwoFactor: true when 2FA is on and the request has no code.

refresh returns 400 { "errors": [...] } when the cookie is missing, expired, revoked, or reused.

Enter a two-factor code or a recovery code

These steps apply to all three password methods. To turn 2FA on, see Turn on two-factor authentication.

  1. Send the email and password.
  2. If the response is the 2FA challenge, ask the user for a 6-digit authenticator code or a recovery code. Cookie and Identity bearer login return title RequiresTwoFactor. JWT create returns the extension requiresTwoFactor: true.
  3. Send the same request again with twoFactorCode or twoFactorRecoveryCode added.

If the client already has the code, send it in the first request. A valid code succeeds in one round trip.

A wrong code on cookie or Identity bearer login returns 401 with detail Invalid credentials. JWT create returns detail Invalid two factor code. for a wrong authenticator code, and the Identity error description for a wrong recovery code.

Remembered browsers

A persistent cookie login (?useSessionCookies=false) with a valid twoFactorCode sets the Identity two-factor remember-client cookie. Later password logins from that browser skip the 2FA challenge. These logins do not set the cookie:

  • A session cookie login (the default).
  • A login with twoFactorRecoveryCode. Each recovery code works once.
  • JWT POST /auth/create.

To clear the cookie, send POST /identity/manage/2fa with { "forgetMachine": true }, or call POST /identity/logout.

Sign in with a passkey

Passkeys are on by default. The default completer is IdentityPasskeySignInCompleter. To get a JWT instead, register JwtPasskeySignInCompleter<TUser> with AddPasskeySignInCompleter. Turning on JWT does not select that completer for you.

Passkey sign-in skips two-factor authentication. A user who turned on 2FA can sign in with a passkey alone.
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.

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

  1. Get request options from POST /account/passkeys/requestOptions. Send {} for a discoverable credential, or { "email" } to sign in identifier-first. An email lets the response reveal through allowCredentials that the account has passkeys.
  2. Call navigator.credentials.get. If the user cancels, stop.
  3. Send the credential to POST /account/passkeys/login with { "credentialJson" }.
    • On the cookie facade, always add ?useSessionCookies=true for a session cookie or ?useCookies=true for a persistent cookie. These flags follow Identity bearer Login, not LoginCookie.
    • With no flag, the default completer returns an Identity bearer AccessTokenResponse.
    const headers = { 'Content-Type': 'application/json', 'RequestVerificationToken': csrfToken };
    
    const options = await fetch('/account/passkeys/requestOptions', {
      method: 'POST', credentials: 'include', headers, body: '{}'
    }).then(r => r.json());
    
    const credential = await navigator.credentials.get({
      publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options)
    });
    
    const response = await fetch('/account/passkeys/login?useSessionCookies=true', {
      method: 'POST', credentials: 'include', headers,
      body: JSON.stringify({ credentialJson: JSON.stringify(credential) })
    });
    
  4. Check the response:
    • 200 with an empty body and a cookie: the user is signed in.
    • 200 with { "accessToken", "tokenType" } and a refresh cookie: the JWT completer signed the user in.
    • 401 with detail Invalid credentials.: the account is locked out or the email is not confirmed.
    • 400 with title Invalid Credential: the assertion failed or credentialJson was empty.
    • 400 validation problem InvalidPasskeyState: no ceremony was underway. Start again from step 1.

Sign in with GitHub or Google

Set up the packages and routes as in Register with GitHub or Google. The same callback signs users in.

  1. Send the browser to the login route with a top-level navigation:
    window.location.assign('/auth/external/login/google?returnUrl=' + encodeURIComponent('/dashboard'));
    
  2. The callback signs in a user who already has this provider login. It refuses a local user with the same email and no link, with the error auto_link_disabled. With AutoLinkByEmail set to true, it links that user only when the provider email is verified and the local email is confirmed.
  3. On success, the response is a 302 to returnUrl:
    • CookieExternalLoginCompleter (the default) sets the application cookie. The cookie is persistent because IsPersistent defaults to true.
    • JwtExternalLoginCompleter sets the JWT refresh cookie. Call POST /auth/refresh with a CSRF token to get the access token.

returnUrl must be a rooted local path, or an absolute URL whose origin is in AllowedReturnUrlOrigins. Any other value falls back to DefaultReturnUrl.

OAuth sign-in skips two-factor authentication with both built-in completers.

Help a user who cannot sign in

  • Forgotten password. POST /identity/forgotPassword always returns 200. AuthEndpoints sends mail only to a confirmed account. See Reset a forgotten password.
  • Lost authenticator. The user signs in with a recovery code instead of a TOTP code.
  • Locked out. Lockout uses Identity's defaults. Change them with ConfigureIdentity. See Configuration options.
  • Unconfirmed email. The user opens the confirmation link first. See Register users.

What sign-in does not support

AuthEndpoints does not include these features:

  • Sign-in with a magic link or an emailed one-time code.
  • Two-factor codes by SMS or email. Identity has an email token provider, but no endpoint exposes it.
  • A passkey as a second factor after a password. Passkeys are a first factor only.
  • Built-in Apple or Microsoft providers.
  • GitHub or Google sign-in that returns Identity bearer tokens.
  • An endpoint that lists or revokes a user's other sessions.
  • An admin endpoint that unlocks an account.