Run a sign-in flow
Build sign-up, sign-in, recovery, and second-factor flows with Users clients.
Use an authentication attempt to run sign-up, sign-in, or recovery from your own form. Create the attempt, prepare the chosen method when needed, then submit proof. The returned status tells your form what to show next.
Before you start
- Set up a browser client with a Users publishable key (
upk_) and an allowed origin. Follow the quickstart. - For a browser handoff, allowlist your backend callback and supply its
redirectUri, the PKCE challenge, andstateas shown in the quickstart. The backend exchanges the code with a product public key (lpk_); keep that key out of the browser. - Enable the method in Configure authentication.
Choose a method
Choose methods your users can access. You can offer several on one screen, and users can add another method after signing up.
Method | What the end-user does | What your code sends |
|---|---|---|
Password | Types an address and a password. | attempt({ strategy: 'password', password }) |
Email one-time code | Types a six-digit code from their inbox. | prepare('email_code'), then attempt with the code |
Magic link | Opens a link and presses a button. | prepare('email_link'), then wait |
Google | Signs in with their Google account. | handle.prepare({ strategy: 'oauth:google' }), then redirect |
GitHub | Signs in with their GitHub account. | handle.prepare({ strategy: 'oauth:github' }), then redirect |
Passkey | Picks a passkey the browser offers. Types nothing. | users.passkey.signIn({ redirectUri, codeChallenge }) — see below |
Invitation | Follows a link you sent and sets a first credential. | nothing — the link runs the sign-up flow |
The methods below share the attempt lifecycle. Passkey helpers run the browser ceremony for you; invitations start from the link the user receives.
Open an attempt, prepare, submit
An attempt stores the progress of one authentication flow for a limited time.
- Call
createto open it. For a browser handoff, include the callback and PKCE options from the quickstart. - Call
preparewhen the method must send a code, create a link, or start a provider redirect. - Call
attemptwith the proof, then handle the returned status.
This excerpt shows the three calls for ada@example.com, an example address:
const { create, prepare, attempt } = useSignIn()
const handle = await create({
identifier: 'ada@example.com',
redirectUri: 'https://app.example.com/auth/callback',
})
await prepare('email_code') // sends the email
const result = await attempt({ strategy: 'email_code', code })useSignUp() exposes the same three calls. The combined entry accepts an address and chooses sign-in or sign-up. It reveals which happened only after completion, keeping the form from exposing whether the address is registered.
Each attempt has these security properties:
- Client binding. The client receives a one-time secret when it creates the attempt. Libraries retain and present that secret on subsequent calls. An attempt ID alone grants no access; opening a link on another device cannot silently complete the original device’s sign-in.
- Limited lifetime. Attempts expire after about fifteen minutes and complete once.
- No account discovery before proof. Before a first factor passes, responses list the product’s enabled methods, not a particular user’s methods. Known and unknown addresses receive matching content and timing.
- Second-factor enforcement. Password, email code, magic link, Google, and GitHub sign-ins return
needs_second_factorwhen required. Password recovery does not bypass it.
Read the status
Status | What it means | What you render |
|---|---|---|
needs_first_factor | The attempt is open; nothing has been proven yet. | The methods in handle.strategies. |
needs_second_factor | The first factor passed; the account has MFA. | A code field, a link to use a backup code, and a passkey button when result.factors names webauthn. |
missing_fields | Sign-up only: your product requires fields not yet supplied. | Inputs for result.missingFields. |
failed | The proof was wrong, expired, or used too many times. | An error next to the field. The attempt may still be retried unless result.error.code says otherwise. |
complete | The flow is finished. | Post result.code and the attempt’s PKCE verifier to your own callback, which redeems them. |
With redirectUri, completion returns code, redirectUri, and codeExpiresIn for exchange by your backend. The headless browser path sends the code and handle.pkceVerifier to that backend. A redirecting prebuilt component posts the code to the callback, where the backend already holds the verifier. Without redirectUri, the supported trusted path returns tokens directly; do not use that path to keep long-lived tokens in browser JavaScript.
A wrong password or expired code is a flow outcome: the request completes, and result.error.code identifies the failure. Handle transport errors separately.
Provide a useful next action for these cases:
- Rate limit: read the error’s wait time in seconds and show when the user can retry. Limits apply by address, source, and product. CAPTCHA settings add checks to these flows.
- Email delivery failure: a bounced or unsubscribed address produces an outcome instead of an indefinite inbox prompt. Offer another method or address.
- Account lock: repeated failures can temporarily suspend sign-in. The user receives an unlock email, and the lock also expires automatically.
Sign a user up with a password
- Call
create({ identifier, redirectUri })onuseSignUp()to open the attempt. Retain the returned handle for its PKCE verifier. - Submit
attempt({ strategy: 'password', password }). Users checks the password policy, public breach database, and sign-up restrictions before creating the account. - The user receives a verification email after account creation. Handle the returned status; on
complete, post the code andhandle.pkceVerifierto your backend as in the quickstart. Collect required fields if the flow reportsmissing_fields.
Your configuration determines whether an unverified user can sign in.
Sign a user in with a password
- Open the attempt with
create({ identifier }). - Submit
attempt({ strategy: 'password', password }). - On success, handle
completeorneeds_second_factor. Failed proofs increment lockout counters.
Send a one-time code
- Open an attempt, then call
prepare('email_code')to send a six-digit code. - Submit it with
attempt({ strategy: 'email_code', code }). - Handle the returned status, including a second-factor challenge when required.
Codes allow a limited number of tries. Resends are limited to roughly one per minute and a daily ceiling. Sending another email does not invalidate the code already in the inbox.
For an unknown address, this flow creates a sign-up when public sign-up is enabled. With sign-up closed, it returns the same response as for a known address.
Send a magic link
A magic-link email lets the user confirm sign-in from a page opened by the link.
- Prepare the
email_linkstrategy. - The user opens the link and presses its confirmation button. Opening it alone does not consume it, so mail scanners and previews cannot complete the action. Repeated failures invalidate the link and notify the account.
- Complete sign-in on the device that started the attempt:
The starting device receives the session.
- Same device: pressing the button completes the original attempt. The waiting sign-in page receives the updated status.
- Another device: the confirmation page shows the starting device, browser, and approximate location. After confirmation, it displays a short code. The user enters that code on the starting device to finish there.
Keep the page that called prepare mounted and observe its attempt state. Include a field for the cross-device code:
await prepare('email_link')Submit a cross-device code with email_code, just like a one-time email code. Same-device confirmation completes directly. This flow does not require polling.
You can include both the code and link in one email so the user can choose either route.
Recover a password
- The user requests a password reset and receives a link.
- The link authorizes setting a new password; it does not create a session.
- Saving the new password revokes every existing session.
- The user signs in again and completes any required second factor.
Inbox access therefore cannot bypass an enrolled second factor.
Change an email address
An email change requires confirmation from the new address and approval from the current one. The primary address changes only after both steps succeed.
The old address receives a revert link valid for about a month. Reverting restores that address, revokes every session, requires a password reset, and blocks further email changes during a cooling-off period. This gives the owner a recovery path after a mailbox takeover.
Invite someone in
An invitation creates the account before the user sets their first credential.
- Invite the address from the app, an import, or an agent.
- The user follows the invitation link, sets a credential, and signs in.
Use invitations for invite-only products and to admit people from a waitlist.
Add a second factor
Enroll a signed-in user from your application’s settings screen:
- Start enrollment and render the returned secret as a QR code.
- Ask for a code from the user’s authenticator app to verify the factor. It remains inactive until verification succeeds.
- Show the returned single-use backup codes once and ask the user to save them for recovery.
When sign-in returns needs_second_factor, submit attempt({ strategy: 'second_factor', code }) for either an authenticator code or a backup code. handle.submitSecondFactor(code) performs the same call. Use a webauthn assertion for a passkey. Show the remaining backup-code count. Each code works once; exhausting the set requires regeneration.
If the user loses both the authenticator and backup codes, an operator can reset factors in the app after identity proofing. The delayed reset revokes every session and notifies the user, who can cancel it while it is pending.
Sign in with a passkey
A discoverable passkey identifies the user through the authenticator. The flow creates an unnamed attempt with create({ discoverable: true }), obtains browser options with prepare({ strategy: 'passkey' }), runs the browser ceremony, and submits attempt({ strategy: 'passkey', response }). The helper runs all four steps:
const outcome = await users.passkey.signIn({ redirectUri, codeChallenge })For this convenience helper, generate the PKCE pair on your backend before rendering. Pass only its challenge and the exact allowlisted callback to the browser; retain the verifier on the backend. Handle outcome: on completion, form-POST its code and your application’s state to the callback for state validation and exchange. The helper returns the outcome, not a handle with a verifier. Client libraries shows the handoff, individual steps, and conditional autofill over an email field.
Build the screen around these behaviors:
- Offer a passkey button when the browser supports the ceremony. Passkeys are not advertised in
handle.strategiesfor an unnamed discoverable attempt. Renderpasskey_not_enabledas an explanation if the product has not enabled them. - Request options do not list user credentials, for either first-factor or second-factor use. The authenticator performs discovery without exposing account membership.
- A user-verified assertion reaches
aal2and completes without another factor. An assertion without user verification counts as one factor and follows the usual challenge rules. See Add passkeys.
For second-factor use, factors includes webauthn when the user has a passkey. Submit the resulting ceremony with attempt({ strategy: 'webauthn', response }).
Handle passkey_not_enabled when the product has no relying-party configuration, invalid_passkey when verification fails, and passkey_challenge_expired by starting a new attempt after the five-minute challenge window. passkey_last_way_in applies to removal in settings, not sign-in.
Sign in with Google or GitHub
- Call
prepareto obtain the provider URL. - Redirect the browser there. Google or GitHub returns it to Lepsto.
- Lepsto redirects to your registered URI with the outcome in the URL fragment. Complete the handoff on that page.
const handle = await users.signIn.create({
identifier: 'ada@example.com',
redirectUri: 'https://app.example.com/auth/callback',
})
const { url } = await handle.prepare({ strategy: 'oauth:google' })
window.location.assign(url)What the callback lands with
The fragment at your allowlisted redirect URI identifies the outcome:
Fragment | What it means |
|---|---|
#attempt=<id>&status=complete&handoff=<h> | The provider authenticated the user and the attempt is ready to be claimed. |
#attempt=<id>&status=failed&code=<code> | The attempt ended badly; code is the usual flow error code. |
#link=pending&handoff=<h> | A linking flow started from your settings screen came back and is waiting to be completed. |
#link=failed&code=<code> | That linking flow ended badly. |
URL fragments are not sent in requests or Referer headers. Claiming the result requires both the returned handoff and the attempt’s client secret stored by the originating client. Neither value grants access by itself.
What your page does
Call the completion helper on your landing page:
const outcome = await users.oauth.completeFromCallback()The helper reads the fragment, submits the handoff and client secret, then clears the fragment. It returns the sign-in outcome, a failure code, or a pending link. For linking, it calls identities.linkComplete({ handoff }) using the signed-in user’s access token from fresh authentication.
Configure storage: localStorageAdapter() so the attempt survives navigation to the provider and back. Storage entries are per attempt, allowing simultaneous sign-ins in separate tabs.
The OAuth handoff does not depend on Lepsto cookies or credentials: 'include'. Your application’s own session storage remains separate.
When there is nothing to hand back
If Lepsto cannot match the provider state to an open attempt, it has no trusted return address. The browser stays on …/users/oauth/failed?code=oauth_state_mismatch for an unknown, expired, or already claimed attempt. A provider refusal lands on …/users/oauth/failed?code=oauth_provider_error. Both require a new sign-in.
Linking from settings
Start provider linking from a signed-in settings screen after fresh authentication. The callback returns #link=pending&handoff=<h>. Your page must complete the link with that handoff and the user’s access token; completion works once.
Without the client libraries
Implement the same handoff with these requests:
POST /flows/{attemptId}/result
{ "clientSecret": "...", "handoff": "..." }
POST /account/identities/link/complete
Authorization: Bearer <access token>
X-Access-Token: <access token>
{ "handoff": "..." }Send both access-token headers, as the libraries do: the public edge strips Authorization on public routes, while a local stack reads it.
The result endpoint returns the flow outcome. Failed attempts do not carry or require a handoff. Link completion returns { "status": "linked" }. Unknown, expired, spent, or mismatched secrets all return oauth_state_mismatch, without revealing which check failed.
Automatic linking at sign-in
Automatic linking requires a provider-verified email that matches the verified email of exactly one existing user. An unverified address or ambiguous match does not link. Ask the user to sign in with an existing method and link from settings after fresh authentication.
Shared Lepsto provider credentials are available for development. Register your own Google or GitHub application for production so the consent screen identifies your product. See Configure authentication.
Sign a user out
Sign out this device, every other device, or all devices. The latter two require fresh authentication. See Sessions and tokens for the effect and timing of revocation.
What every flow does without being asked
Every flow enforces exact redirect-allowlist matching, rate limits by address, source, and product, and your CAPTCHA policy. Before proof, responses do not disclose whether an address exists. Attempts appear in the user’s security history in the app and emit user events.
To verify your integration, complete one enabled method, exchange its handoff on your backend, and confirm an authenticated request succeeds. Repeat with an invalid proof and an account that requires MFA: the form should show the failure or second-factor challenge without creating an authenticated application session.
Next steps
- Sessions and tokens: exchange, verify, refresh, and revoke.
- Client libraries: clients, hooks, and components.
- Passkeys: relying-party setup and assurance levels.
- Authentication configuration: enable and restrict methods.