# Configure authentication

Everything your product decides about authentication lives in one configuration. It applies to your product only, it takes effect on the next flow, and — apart from the origin and redirect lists — changing it never requires a code change on your side.

**Users → Configuration** is the screen. It reads the whole configuration and writes back only what you changed, in eight sections: **Signup**, **Password policy**, **Multi-factor authentication**, **Passkeys**, **Sessions**, **Email**, **OAuth providers** and **Waitlist widget**. Edits are held as a draft until **Save changes**, and **Discard** throws the draft away.

Two things below are part of the same configuration but have no field on that screen — the origin and redirect allowlists — and the keys are not part of it at all: they have a screen of their own, **Users → Keys**. Each says so where it is described. Everything here is available to an agent as well.

## Turn on a sign-in method

A method is a way an end-user proves who they are. 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.

**Password.** An email address and a password, hashed with argon2id. Subject to the password policy below.

Neither email method is offered to your users until this product has a sender address on a domain verified in its mail toolkit. That address is `email.from`, in the **Email** section of the same screen.

**Email one-time code.** A six-digit code is emailed and typed back into your sign-in form. No password is involved, so nothing can be reused or leaked from another site. A code is short-lived, may be attempted a handful of times, and a resend is rate-limited both by a cooldown of about a minute and by a daily cap.

**Magic link.** The same email, rendered as a link instead of a code. Opening the link never signs anyone in by itself: it lands on a page that asks for a click, which prevents mail scanners from consuming the link on the way. If the user opens the link on a different device from the one they started on, they confirm the waiting device and carry a short code back to it — the session is only ever created on the device that began the sign-in.

**OAuth.** Google and GitHub. In development you can use the shared Lessly credentials to try the flow; in production you register your own application with the provider and store its client id and secret in the **OAuth providers** section of **Users → Configuration**, where the secret is held encrypted.

> **WARNING**
> A provider account is linked to an existing user automatically only when the provider states the email is verified **and** it matches a verified address of exactly one of your users. Anything less unambiguous than that is never linked silently.

**Passkeys.** A WebAuthn credential held on the user's own device — the passwordless method, and on most devices an identifier-less one: the browser offers the passkeys it holds and the authenticator names the user. Off until you turn it on with the `passkeys` block below. A passkey is also a second factor over any other method. [Add passkeys](/ship/users/passkeys) is the subject end to end.

**Multi-factor authentication.** An authenticator app (TOTP) and single-use backup codes. Enrolment must be confirmed before the factor counts. Once a user has a verified second factor, every first method is gated by it — password, email code, magic link and OAuth alike, and a password reset does not bypass it. If a user loses both their factor and their backup codes, an operator can reset MFA from that user's record; the reset is recorded, delayed, and the user is emailed so they can stop it. [Manage your end-users](/ship/users/user-management) has the screen, and [Add two-factor authentication](/ship/users/mfa) is the screen your own product renders to enrol people. The settings below are the policy around it.

## Choose who may sign up

The **Signup** section of **Users → Configuration** carries the choice of three modes:

| Mode | Who creates the account |
|---|---|
| **Public** | Anyone may create an account. The default. |
| **Invite-only** | Accounts are created by you, by inviting a person from the directory screen or by running an import, and the user sets their first credential from the invitation email. |
| **Waitlist** | A public sign-up form collects people without creating a way in; you invite them in batches later. See [Run a waitlist](/ship/users/waitlist). |

Independently of the mode you can restrict which addresses are accepted: an allowlist or a blocklist of domains, blocking of disposable-mailbox providers, and blocking of `+` sub-addresses. Those rules are set through the product's API; the **Signup** section carries the mode — `signupMode` — and nothing else.

A CAPTCHA is on by default at sign-up and is also triggered at sign-in when a particular account or address starts failing repeatedly.

## Set the password policy

The policy applies at sign-up and at every password change, and is edited in the **Password policy** section of **Users → Configuration**.

| Setting | Default |
|---|---|
| Minimum length | 8 characters |
| Maximum length | at least 64 characters, never silently truncated |
| Breached-password check | on |

Length is the control that matters, so there are no composition rules to configure — no required digit, symbol or mixed case. Submitted passwords are checked against a public database of breached credentials without the password or its full hash leaving the platform; if that check is unreachable it does not block a registration, and the same check runs again after sign-in so an affected user is flagged and asked to change their password.

Repeated failures lock an account temporarily, per account and per source. The lockout expires on its own and the user is emailed a way to unlock it sooner; a failed password is never a permanent lockout. The breached-password check and the lockout ceilings are `protection` fields on the configuration, alongside the rate limits.

## Set the second-factor policy

The **Multi-factor authentication** section of **Users → Configuration** carries the `mfa` block, and an agent writes the same fields with `users_config_upsert`. Every field is merged on its own, so setting one leaves the rest alone.

| Field | Default | Range | What it decides |
|---|---|---|---|
| `required` | `false` | — | Whether a second factor is mandatory for this product |
| `backupCodeCount` | 10 | 1–20 | How many single-use backup codes a batch contains |
| `resetDelayHours` | 72 | 0–720 | The delay on an operator-initiated factor reset — the window the user is emailed to abort it |
| `sensitiveAuthAgeMinutes` | 10 | 1–1440 | How recently the caller must have authenticated to enrol or regenerate a factor |
| `maxSecondFactorAttempts` | 5 | 1–50 | Wrong second-factor codes before the account is locked |
| `maxBackupCodeAttempts` | 5 | 1–50 | Wrong backup codes before the account is locked, counted separately from the app's codes |
| `stepUpGrantMinutes` | 5 | 1–60 | How long a passed step-up challenge stays spendable |

A step-up grant is single-use whatever `stepUpGrantMinutes` says: the window only decides how long the user has to spend it, never how many times.

**There is no on/off switch, and that is deliberate.** A second factor belongs to the user who enrolled it, not to the product — turning the feature off would have to strip verified factors from accounts that rely on them, and silently weaken every one of those sessions. So enrolment is always offered, and your product decides only how far it goes: leave `required` at `false` and it is opt-in, or set it and it becomes a floor.

**`required` is an assurance floor, not a migration.** It does not enrol anybody and it does not lock existing users out; what it changes is that a user who has a verified factor may no longer unenrol their last one — the attempt is refused as `last_factor_required`, and the prebuilt screen says so in words. Independently of this setting, nobody may remove their last remaining way to sign in at all; that refusal is `last_way_in`.

Lowering `sensitiveAuthAgeMinutes` makes enrolling and regenerating ask for a fresh sign-in more often; raising it asks less often, and a stolen open session gets a wider opening. Ten minutes is the balance for most products.

## Turn on passkeys

The **Passkeys** section of **Users → Configuration** carries the `passkeys` block, which turns your product into a WebAuthn relying party. It sits between **Multi-factor authentication** and **Sessions**, and an agent writes the same fields through the product's API. Every field is merged on its own, so setting one leaves the rest alone.

| Field | On the screen | Default | What it decides |
|---|---|---|---|
| `enabled` | **Offer passkeys** | `false` | Whether this product accepts passkeys at all. Off, the method is not offered and no credential can be enrolled |
| `rpId` | **Relying party ID** | none | The domain credentials are bound to, when your allowed origins cannot decide it alone |
| `rpName` | **Relying party name** | none | The product name the operating system's passkey prompt shows. Defaults to the domain |
| `requireUserVerification` | **Require user verification** | `false` | Refuse an assertion the authenticator did not verify. A verified one is `aal2` either way |

`rpId` and `rpName` are the two text boxes, and **an empty box means "derive it"** rather than an empty value. `rpId` is a bare domain — no scheme, no port, no path — and the screen refuses a save that puts `https://acme.com` in the box before the call is made.

**The domain is derived from your allowed origins, and refused rather than guessed.** In order:

1. `passkeys.enabled` off — no domain, and the method is not advertised.
2. `rpId` set — it must be the host of, or a **parent domain** of, *every* entry in your origin allowlist. A parent means a whole label: `example.com` is a parent of `app.example.com`, and is not a parent of `notexample.com`, which merely ends with the same letters.
3. `rpId` not set — the single host your allowed origins share, when there is exactly one.
4. Anything else — two hosts with no `rpId`, or an `rpId` that does not cover them — and passkeys stay off rather than binding credentials to a domain nobody meant.

> **WARNING**
> **A credential is bound to that domain for life.** The authenticator only ever asserts it back to that one domain, so changing `rpId` migrates nothing: every passkey your users already enrolled keeps asserting the old domain and stops being recognised. Decide it before you invite people to enrol, and treat it as fixed afterwards. The domain is always yours, never ours.

Leave **Require user verification** at its default unless you have a reason. An unverified assertion is still a real factor and still goes through the second-factor challenge; refusing it outright turns some security keys away at the door for no assurance you were not already getting from `aal`.

There is deliberately **no `required` flag and no per-user switch.** A passkey is something an end-user adds to their own account, so the only product-level question is whether your product accepts them; whether one counted as two factors is decided per sign-in, by what the authenticator actually did.

## Allow your origins and redirect URIs

Two allowlists guard the browser side, and both are matched exactly — scheme, host and port, no wildcards and no prefix matching.

**Origins** are the web origins your frontend runs on. A browser call presenting your publishable key is rejected unless it comes from one of them. This is what makes a public key in your bundle harmless.

**Redirect URIs** are the addresses an end-user can be returned to when a flow finishes — your sign-in callback, your OAuth return route, the page after a verification email. A flow that asks to return anywhere else is refused rather than followed.

Each list holds up to twenty entries, and a write replaces the whole list rather than appending to it — send every entry you want kept.

> **WARNING**
> **Neither list has a field on Users → Configuration.** They are part of the configuration, but no screen edits them: both are `allowedOrigins` and `redirectUriAllowlist` on the product's authentication configuration, so an agent or your backend is the only way to change them today. Two things depend on the redirect allowlist and will not tell you why: a browser call presenting your publishable key, and **Sign in as this user** on a record, which is offered only once the list has an entry in it.

Keep development and production entries separate, and remove an entry as soon as the environment it belonged to is gone.

## Rotate a key

Your product has two keys, and they are never mixed. A publishable key is refused on the endpoints that administer users, and a server key is refused on the public flow endpoints.

| | Publishable key | Server key |
|---|---|---|
| Prefix | `upk_` | `usk_` |
| Runs in | the browser, and any client library | your backend only |
| Secret | no | yes |
| Can do | start and advance sign-up and sign-in flows | exchange and refresh tokens, read and administer users |
| Protected by | the origin allowlist | secrecy |

The publishable key also tells the client libraries which address to talk to, so there is no base URL to configure and no way to point a client at the wrong product.

**Users → Keys** is where both kinds of key live, in two sections. **API keys** lists what the product has issued and what state each one is in, **Issue an API key** mints one — you name it, because the name is the only way to tell two apart, and you pick whether it is a server key or a publishable one — and each row revokes. A publishable key stays readable there; a server key is shown once, at the moment it is minted, and never again.

> **WARNING**
> Copy a server key out of that dialog before you close it: nothing on the platform can show it to you a second time. It is stored only as a hash afterwards. A product can hold more than one server key, so a rotation needs no downtime: create the new key, deploy it, revoke the old one.

**JWT signing keys** is the second section: the keys Lessly Users mints access tokens with, listed by key id with the state each is in.

| State | What it means |
|---|---|
| Standby | Generated, not published, verifying nothing yet |
| Current | Signs every token from now on |
| Retired | No longer signs, but stays in JWKS until its overlap expires |
| Revoked | Evicted from JWKS, so tokens it signed no longer verify |

**Rotate signing key** mints a new current key, and a row revokes. Rotation invalidates nothing on its own — that is what the overlap is for. Everything on this screen is available to an agent as well.

## Set session lifetimes

The **Sessions** section of **Users → Configuration** carries all three.

| Setting | Default | Notes |
|---|---|---|
| Access token lifetime | 10 minutes | `accessTokenTtlMinutes`, 5 to 60 |
| Session lifetime | 720 hours | `sessionTtlHours` — the session's own lifetime |
| Session inactivity window | 30 days | `refreshSlidingDays`; every refresh slides it forward |
| Session absolute lifetime | 365 days | `refreshAbsoluteDays`, counted from sign-in and never extended |

Shorter access tokens make a revoked session stop working sooner, because your backend verifies tokens locally and does not ask us on each request; longer tokens mean fewer refreshes. Ten minutes is the balance for most products. When you need a session to stop working immediately — a ban, a sign-out from a stolen device — the server library can verify a token against the live session instead, at the cost of one call. [Sessions and tokens](/ship/users/sessions-and-tokens) covers that in full.

Some events end a user's sessions regardless of these settings: a password change, an email change, an MFA reset, a ban, and an erasure.

## Next steps

- [Run a sign-in flow](/ship/users/auth-flows): what each method looks like step by step.
- [Add two-factor authentication](/ship/users/mfa): the prebuilt two-step screen your product renders.
- [Add passkeys](/ship/users/passkeys): turning passkeys on, and the screens your product renders.
- [Sessions and tokens](/ship/users/sessions-and-tokens): verifying, refreshing and revoking.
- [Manage your end-users](/ship/users/user-management): the management App and the audit trail.
