Skip to content

Users

Run a sign-in flow

Drive sign-up, sign-in, recovery and a second factor from your own code.

A sign-in is a short conversation between your form and Lessly Users: you open an attempt, you send whatever the method needs sending, and you submit the proof. Every method runs through that same machine, so you learn it once and adding a method later changes almost nothing in your frontend.

Choose a method

Turn on as many as you want. They combine on one sign-in screen, and a user who signed up with one can later add another.

MethodWhat the end-user doesWhat your code sends
PasswordTypes an address and a password.attempt({ strategy: 'password', password })
Email one-time codeTypes a six-digit code from their inbox.prepare({ strategy: 'email_code' }), then attempt with the code
Magic linkOpens a link and presses a button.prepare({ strategy: 'email_link' }), then wait
GoogleSigns in with their Google account.prepare({ strategy: 'oauth:google' }), then redirect
GitHubSigns in with their GitHub account.prepare({ strategy: 'oauth:github' }), then redirect
InvitationFollows a link you sent and sets a first credential.nothing — the link runs the sign-up flow

No method is recommended over another; pick by what your customers already have. Every one of them is switched on in Configure authentication, and none of them changes the three calls below.

Open an attempt, prepare, submit

An attempt is a short-lived object on our side that remembers how far the user has got. Three calls drive it, and the status you get back tells you what to render next.

const { signIn } = useSignIn()

await signIn.create({ identifier: 'ada@example.com' })
await signIn.prepare({ strategy: 'email_code' })   // sends the email
const result = await signIn.attempt({ strategy: 'email_code', code })

signUp has the same three calls. There is also a single entry that accepts an address and works out on its own whether this person is signing in or signing up; it tells you which one happened only once the flow has finished, so the form never leaks whether an address is already registered.

Four things are true of every attempt:

  • It is bound to the client that created it. Creating an attempt hands your browser a one-time secret that every later call must present. The client libraries hold it for you. An attempt id on its own authorises nothing, which is why a link that lands on another device cannot quietly finish a sign-in someone else started.
  • It expires after about fifteen minutes, and can be completed only once.
  • It tells you nothing about the user until a first factor passes. Before that, you get the set of methods your product has enabled — never the ones this particular person has. Responses for a known and an unknown address are identical, in content and in timing.
  • A verified second factor gates every method. Password, email code, magic link and Google or GitHub all land in needs_second_factor if the user has one, and a password reset does not get around it.

Read the status

StatusWhat it meansWhat you render
needs_first_factorThe attempt is open; nothing has been proven yet.The methods in result.supportedStrategies.
needs_second_factorThe first factor passed; the account has a second one.A code field, and a link to use a backup code.
missing_fieldsSign-up only: your product requires fields not yet supplied.Inputs for result.missingFields.
failedThe proof was wrong, expired, or used too many times.An error next to the field. The attempt may still be retried unless result.code says otherwise.
completeThe flow is finished and a session exists.Send the browser to result.redirectUrl.

A wrong password and an expired code are outcomes, not errors: the call succeeds and the status carries the news, with a stable result.code string you can branch on.

Three cases deserve their own handling:

  • Rate-limited. Too many attempts on one address, or from one place, and the call raises an error carrying the number of seconds to wait. Show the wait rather than a retry button that will fail. The limits are per address and per product.
  • We could not email you. If the address bounces or has previously unsubscribed, the attempt says so instead of leaving the user on “check your inbox” forever. Offer another method, or another address.
  • Locked. Repeated failures suspend an account temporarily. The user is emailed a way to unlock it, and the lock lifts on its own.

Sign a user up with a password

  1. Call signUp.create({ identifier, password }). The password is checked against your policy and against a public breach database before the account exists, and the address is checked against your sign-up restrictions.
  2. The account is created, and a verification email goes out.
  3. The status is complete, or missing_fields if your product asks for more.

Whether an unverified user may sign in at all is your product’s decision.

Sign a user in with a password

  1. Call signIn.create({ identifier }).
  2. Call signIn.attempt({ strategy: 'password', password }).
  3. A failure feeds the lockout counters. A success is either complete or needs_second_factor.

Send a one-time code

  1. prepare({ strategy: 'email_code' }) sends a six-digit code.
  2. attempt({ strategy: 'email_code', code }) submits it.

A code may be tried a handful of times before it is burned. Resends are held to roughly one a minute with a daily ceiling, and a resend never invalidates the code already in the user’s inbox.

If the address is unknown and your product allows public sign-up, this flow becomes a sign-up. If sign-up is closed, the response is exactly what a known address would have produced.

The same email, rendered as a link. Two things about it are unusual, and both are deliberate.

Opening the link signs nobody in. It lands on a page with a button. Only pressing the button consumes the link, so a mail scanner or a link preview cannot spend it on the way to the user. Repeated failed attempts on one link invalidate it and notify the account.

A session is only ever created on the device that started the sign-in.

  • Same device. The browser that opened the link is the browser that created the attempt, so pressing the button finishes it. Your waiting page — the one that called prepare — sees the status change and moves on.
  • Another device. The phone that opened the link does not finish the attempt on the laptop. Instead the page shows the laptop’s device, browser and rough location, asks for a confirmation, and then displays a short code. The user types that code into the laptop, which finishes the flow there.

Both cases are handled for you. Keep the page that called prepare mounted, watch the attempt, and offer a field for the short code.

await signIn.prepare({ strategy: 'email_link' })

const result = await signIn.waitForCompletion()   // resolves when the link is used
if (result.status === 'complete') window.location.assign(result.redirectUrl)

The email can render the code and the link together, in which case the user has both routes from one message.

Recover a password

  1. The user asks for a reset and gets a link.
  2. The link authorises exactly one thing: setting a new password. It does not sign them in.
  3. Setting the password ends every existing session.
  4. The user signs in normally — which means that if they have a second factor, they are asked for it.

Access to an inbox is never a way past a second factor.

Change an email address

Both addresses have to agree. The new address confirms that it exists and is reachable; the current address approves the change. Only then does the primary address move.

The old address also gets a way to undo it, valid for about a month. Using it restores the old address, ends every session, forces a password reset, and blocks further email changes for a cooling-off period — the recovery path for a mailbox that has been taken over.

Invite someone in

You create the account and the user completes it.

  1. Invite an address — from the management App, an import, or an agent.
  2. The user gets a link, sets their first credential, and is signed in.

This is the whole of sign-up when your product runs invite-only, and it is also how people are let off a waitlist. See Run a waitlist.

Add a second factor

Enrolment happens on a signed-in user, from your own settings screen.

  1. Start enrolment. The call returns the secret to show as a QR code.
  2. The user proves it works with a code from their authenticator app. The factor does not count until they do.
  3. Confirming hands over a set of single-use backup codes. Display them once, and tell the user they are the way back in if they lose the phone.

The challenge is the needs_second_factor status above. Call signIn.attempt({ strategy: 'totp', code }) for the authenticator app, or strategy: 'backup_code' for a backup code. Backup codes are single-use, the remaining count is shown to the user, and running out forces a regeneration.

If someone loses both the app and the codes, an operator resets the factors from the management App. The reset takes identity proofing, waits, ends every session, and emails the user so they can stop it while it is pending.

Sign in with Google or GitHub

  1. prepare returns the provider’s URL.
  2. The browser goes there and the provider returns the user to us.
  3. We finish the attempt and return them to your callback like any other method.
const { url } = await signIn.prepare({ strategy: 'oauth:google' })
window.location.assign(url)

The one decision worth understanding is linking. A provider account is attached to an existing user of yours automatically only when the provider states the email address is verified and that address is a verified address of exactly one of your users. An unverified provider email never links, and neither does an ambiguous match; the user is asked to sign in with what they already have and link the account from their settings instead. Linking from settings also requires a fresh authentication.

In development you can use the shared Lessly credentials to try the flow. In production you register your own application with Google or GitHub, so the consent screen carries your name — see Configure authentication.

Sign a user out

Three scopes: this device, every other device, or all of them. The last two are sensitive operations and ask for a fresh authentication first. Sessions and tokens covers what revocation does and how quickly it bites.

What every flow does without being asked

  • Rejects any return address that is not on your redirect allowlist, exactly matched.
  • Rate-limits by address, by source and by product, and applies your CAPTCHA settings.
  • Answers identically for addresses that exist and addresses that do not.
  • Records the attempt in the user’s security history, which you read in the management App.
  • Emits an event you can subscribe to — see Receive user events.

Next steps

Was this page helpful?
Esc

Start typing to search the docs.

navigateselect