# Manage your end-users

The management App is where you and your support team work with the directory: find an end-user, read what Lessly Users knows about them, and act — invite, ban, revoke a session, reset a lost second factor, revoke a passkey, or erase an account. Everything here is scoped to one product.

Lessly Users is the **Users** entry in the product's navigation, and it has seven screens:

| Screen | What it is |
|---|---|
| **Users → Overview** | The product's sign-in numbers |
| **Users → Users** | The directory |
| **Users → Keys** | The API keys and the JWT signing keys — see [Configure authentication](/ship/users/configuration) |
| **Users → Webhooks** | The endpoints and their delivery logs — see [Receive user events](/ship/users/webhooks) |
| **Users → Waitlist** | The pre-launch queue — see [Run a waitlist](/ship/users/waitlist) |
| **Users → Import** | The import wizard — see [Migrate users in and out](/ship/users/migration) |
| **Users → Configuration** | The authentication configuration |

Two screens sit a level down: **Users → Users → \<user\>**, a single end-user's record, and **Users → Webhooks → Deliveries**, one endpoint's delivery log.

That is the whole of the App, and nearly every operation on this page has a screen on it. The few that do not — editing a metadata bag, reading a single session by its id, reading the operator audit across the product, and the retention and service-level readings — are called out where they are described. Those are performed through the product's API, and each is available to an agent as well, so a support workflow can be automated even where nothing in the App performs it. The generated REST reference at [`/reference/openapi/users`](/reference/openapi/users) is the source of truth for the paths and fields involved.

Every operation that does have a screen is available to an agent too, so a workflow can be automated once you have done it by hand and know what it does.

## Find a user

**Users → Users** opens the directory: a list of your end-users, most recently created first, walked a page at a time. Its search box matches an email address — whole or partial, in the form the user typed or in its normalized form — and the user's name. It does **not** match the stable user id: to land on a record from an id you hold in your own logs or in a token, read that user directly with `users_users_get`, or go to its address in the App. A row opens **Users → Users → \<user\>**.

Above the list sits one row of filters, and it filters on lifecycle status only: **All**, **Active**, **Banned** and **Waitlisted**. The screen offers no other filter — not by sign-in method, not by whether an address has been verified, not by a date range. Search and status are what it narrows by, and a row's badge is where a deleted or erased record announces itself. Soft-deleted users are hidden from the list, and the App offers no control that shows them; `users_users_list` has `includeDeleted` for that.

People who signed up to a waitlist are also directory records, and they are kept out of the default list so they do not drown the users who can actually sign in. Pick **Waitlisted** to see them, or work with them in the funnel described in [Run a waitlist](/ship/users/waitlist).

## Read a record

**Users → Users → \<user\>** is the record. It is laid out as four sections — **Identifiers**, **Sessions**, **MFA factors** and **Audit trail** — under a header that carries the user's status and the operator actions.

**Identity.** The stable opaque id — the one you store in your own database and the one that appears in every access token and event — plus the name and image if your product collects them.

**Identifiers.** The **Identifiers** section lists the email addresses that reach this person, each marked verified or unverified and one of them marked primary. A user may hold several. Each address carries its own verification state, so a record can hold a verified address and an unverified one at the same time. "Is this user verified?" is always a question about an address. The section is a read-out: nothing on it adds or removes an address. Moving the primary address has its own screen, described under [Edit a record](#edit-a-record).

**Sign-in methods.** Separately from the addresses, the ways this person can prove who they are: a password, a linked Google or GitHub account. A user always keeps at least one usable way in — the last one cannot be removed. The record has no section for these, and no screen unlinks a provider or clears a password; that is done through the API.

**Sessions.** The **Sessions** section holds one row per signed-in device, naming it by the user agent and the address it connected from, with when it last refreshed and when it expires. Ended sessions stay in the list, marked as such. This is the list an end-user sees as "your devices" in your own product, and it is the list you revoke from.

**Factors.** The **MFA factors** section lists the enrolled multi-factor methods: an authenticator app once its enrolment has been confirmed, and how many single-use backup codes remain. The same section lists the user's **passkeys**, one row per credential, with the name the user gave it, how it is reached and when it was last used. A passkey counts as a verified factor, so an account holding one has two-step verification whether or not it has an authenticator app.

**Profile and metadata.** Three bags of your own data hang off the record, and they differ in who may write them and who may read them:

| Bag | Written by | Read by | Notes |
|---|---|---|---|
| Public metadata | your backend | your backend and the end-user | A size-capped projection of it travels in the access token |
| Private metadata | your backend | your backend | Never leaves your side |
| Unsafe metadata | the end-user | everyone | Never base an authorization decision on it |

Use public metadata for anything your backend wants without a lookup — a plan name, a role, a feature flag. Because it rides in the token, it is capped, and an over-large write is refused rather than silently breaking that user's next sign-in.

> **NOTE**
> None of the three appears on the record, and no screen edits one. Reading and writing them is done through the product's API, and is available to an agent. The **Profile** section of the record is a different thing and does have a screen: it edits the display name, the avatar URL, whether the person is still a waitlist signup, and whether they must reset their password before signing in. It sends only the fields you changed, and emptying a box clears that field.

**Security history.** The record's own audit trail, shown in its **Audit trail** section and described at the end of this page.

## Create and invite a user

**Invite user** on **Users → Users** opens a dialog that takes an email address and, optionally, a display name, and invites that person. No password is set this way; the user receives an invitation email and chooses their first credential from it, which is also how invite-only sign-up works — see [Configure authentication](/ship/users/configuration).

Creating an account outright — a record written straight away, with no email sent and nobody asked to do anything — is a different operation and a different button. **Create user** on the same screen takes an email address and an optional display name, and offers one checkbox: record the address as already verified. Nothing checks it, so tick it only for addresses you already trust. Metadata cannot be set from either dialog — `users_users_create` and `users_users_invite` take `publicMetadata` and `privateMetadata`, so put it on through the tool instead. Inviting is idempotent by address: re-inviting returns the same user rather than creating a second.

An invitation is single-use, expires, and can be revoked before it is accepted; revoking it leaves the record in place so you can send a fresh one. Re-inviting the same address returns the same user rather than creating a second, so pressing the button twice cannot make two records. Accepting it runs the ordinary sign-up flow, so everything your product's configuration requires — a password that meets the policy, a second factor if you demand one — still applies.

## Edit a record

**Metadata and profile** take effect immediately once written. A change to public metadata reaches your backend on the user's next access token, within the token lifetime. The profile has a screen — the **Profile** section of the record, above — and the three metadata bags do not; those are written through the product's API, which is available to an agent.

**The primary identifier** can be switched to any other address on the record. **Set primary email** on the record opens a dialog that takes the new address and a written reason — what identity proofing you did, under which ticket — and will not submit without one, because the reason is recorded verbatim against your name. What it submits is a *request*, not a field edit: like the operator email-change override in [Run a sign-in flow](/ship/users/auth-flows) it takes effect after a delay — the operator-override delay in this product's `emailChange` settings — and both addresses are notified in the meantime so a mistaken or malicious change can be stopped. The primary address is what Lessly Users uses to reach the person and what your backend receives in the token's email claim, and only a verified address may become primary.

**Adding or removing an address** is subject to the rule that keeps a user reachable: the last verified address that a person can receive a sign-in code at is not removable while it is their only way in.

> **WARNING**
> If an end-user cannot complete an email change themselves because the old address is dead — an imported account, a closed mailbox — an operator can complete it for them. That override asks you to confirm you have identified the person, is recorded, takes effect after a delay, and notifies both addresses in the meantime, so a mistaken or malicious change can be stopped.

## Reset a lost second factor

When a user loses both their authenticator app and their backup codes, an operator resets their factors with **Reset MFA** on the record's **MFA factors** section. The reset clears the enrolled factors, revokes all of the user's sessions, and lets them enrol again on their next sign-in.

> **WARNING**
> Because a support-desk reset is exactly what an attacker would ask for, it is deliberately not instant: the dialog will not submit without a written reason — what identity proofing you did, under which ticket — which is recorded verbatim against your name; the removal takes effect after a delay; and the user is emailed as soon as you schedule it, with a way to stop it during that window.

## Revoke a passkey

**Revoke** on a passkey row of the record's **MFA factors** section removes that one credential. The dialog will not submit without a written reason — what identity proofing you did, under which ticket — which is recorded verbatim against your name in the audit trail. The same revoke is available through the product's API, and to an agent.

Unlike the factor reset it takes effect **immediately**: a lost or stolen device is exactly the case where waiting three days is the wrong answer, and removing one credential leaves the account's other ways in untouched, so there is nothing for the user to abort.

It is refused when that passkey is the account's last way to sign in. That refusal is decided against the whole record — every identifier and every other credential — and no operator permission overrides it; the user adds another way in first.

> **NOTE**
> There is deliberately no way for an operator to *add* a passkey to somebody's account: a passkey an operator could enrol is a passkey an operator could keep. [Add passkeys](/ship/users/passkeys) covers the user's own side of this.

## Revoke a session

The record's **Sessions** section is where sessions are ended. **Revoke** on a row ends that one session — that device signs out and nothing else changes — and **Revoke all** on the section header ends every session the user has, after a confirmation. A row that has already ended offers no button. A revoked session's refresh stops working immediately.

Reading a single session by its id, rather than the user's whole list, has no screen; it is done through the product's API. The section already carries every field that call returns, so what it is for is the jump the other way — you have a session id in one of your own log lines and want the session behind it without knowing whose it is.

Because your backend verifies access tokens locally, a revoked session's current access token remains valid for the rest of its lifetime, ten minutes by default. Where that matters — a stolen laptop, a fraud case — the server library can check a token against the live session on the calls that matter. [Sessions and tokens](/ship/users/sessions-and-tokens) covers both modes and [Configure authentication](/ship/users/configuration) covers shortening the token lifetime.

## Ban and unban

A user is banned in either of two places: the **Ban** action on their row in **Users → Users**, or the **Ban** button in the header of their record. Both ask for confirmation. Where a user is already banned, the same two places offer **Unban** instead, and it takes effect without a confirmation step.

Banning refuses every future sign-in and revokes all existing sessions at once. The record, its identifiers and its metadata stay exactly as they were, so a ban is reversible: unbanning restores the account, and the person signs in again with the credentials they already had. Use a ban for abuse and for accounts you may want back; use deletion for accounts that are leaving.

> **NOTE**
> A ban is not the automatic lockout after repeated failed sign-ins. A lockout is temporary, expires by itself, and the user is emailed a way to lift it sooner; it is not something you have to clear by hand.

## Impersonate a user for support

To reproduce what a user is seeing, you can open a session as them. **Sign in as this user** in the header of **Users → Users → \<user\>** starts one, behind a dialog that states the thirty-minute ceiling and will not submit without a written reason. The button is offered only once the product has at least one redirect URI allowed, because an impersonated session has nowhere legal to land otherwise.

What you get back is a **token to copy**, shown on the screen and held nowhere else — not in a link, not in an address, not written back into anything the page caches. The App performs no redirect and hands the session to nothing: you take the token to your own product yourself. That is deliberate, and it is what keeps an operator session from being one click away from anyone who reaches the screen. It is the strongest operation Lessly Users offers, and it is fenced accordingly:

- it requires the dedicated permission, not merely access to the directory;
- the session is capped at thirty minutes and cannot be refreshed;
- nothing about the account can be changed while you are inside it — no password, no factors, no addresses, no session revocations;
- your backend can tell: the session is marked as an impersonation and the server library exposes it as such, so you can hide sensitive screens or refuse destructive actions during one;
- it is recorded with your name as the actor, your reason is recorded verbatim, and — unless you turn the notice off for your product — the user is emailed that it happened, with your reason in the email.

## Delete or erase an account

Two different operations, for two different reasons. Both have a screen: **Delete** and **Erase** in the header of **Users → Users → \<user\>** each act on that user behind a confirmation that spells out what will be gone.

| | Delete | Erase |
|---|---|---|
| What it is for | Retiring an account. | A data-protection request. |
| Sessions | Revoked at once. | Revoked at once. |
| The record | Kept for a grace period, so a mistaken deletion can be undone and your own systems can reconcile. | Purged: addresses, profile, metadata and credentials are gone; a tombstone stands in for the identity. |
| The email address | Released for re-use after the grace period. | Free again. |
| Undo | Yes, during the grace period. | None. Erasure is final. |
| Security-log entries | Kept. | Kept, with the identity in them pseudonymized. |

> **DANGER**
> After an erasure the address is free again. If the same person signs up later with the same address they get a **new** user id — a deliberate consequence of erasure, and one more reason to key your own tables by the id rather than the email.

Before either, you can export. **Export** on **Users → Users** writes a CSV of exactly the view on screen — whatever you searched for and filtered to — walking the whole result set rather than the page you can see, and reporting the running row count while it goes. An export is itself recorded; [Migrate users in and out](/ship/users/migration) describes the format and the export of password hashes.

What erasure means for the data itself, and how it fits the platform's retention and deletion model, is on [Data and privacy](/privacy/data-and-privacy).

## Import users in bulk

Bringing an existing user base with you is a first-class operation in the App, not a support ticket. **Users → Import** is the wizard, and [Migrate users in and out](/ship/users/migration) covers it in full. You paste rows or choose a file — the email address, a name, and optionally the password hash you hold today — and say which column is which. Before anything is sent, the wizard reports how many rows it will import and how many it rejected on the spot for being unreadable. A row that collides with an address already in the directory is not caught there; it comes back as a rejection in the report the import writes afterwards.

What happens on the run:

- each accepted row becomes a directory record with its address in the verification state you declared;
- a supplied password hash is stored as it is, and the password keeps working — it is verified in its original format the first time that person signs in, then transparently re-stored in argon2id;
- a hash that cannot be used marks the account as needing a password reset, so the user is asked to set a new password rather than being locked out;
- rows with no credential at all become invitable accounts, which you then invite from the App as above.

The whole import is recorded in the security history.

## Read the audit trail

Two records are kept, for two audiences.

**The user's security history** is the **Audit trail** section of **Users → Users → \<user\>**, and it is the thing you read during a support conversation. Each entry names the action, when it happened and who the actor was, and a **Refresh** button re-reads it without leaving the record. It appends an entry for each sign-in, successful or failed, each new device, each change of password, address or factor, each session revocation, each ban, each impersonation with the operator who did it, each import that touched the account and each erasure. Entries are append-only: nothing in the App edits or removes one.

**The operator audit** is the mirror image — the same events organized by who did them rather than by whom they were done to. It answers "what did this member of my team do last Tuesday", covers actions taken by an agent as well as by a person in the App, and is the record you reach for during an incident or an audit. The App shows the trail one user at a time and no more: there is no screen that lists it across the product or narrows it to an actor. Reading it that way is done through the product's API, and is available to an agent.

Every entry carries the actor, the action, what it targeted, the address and browser it came from, and when it happened. Note what the filters do not offer: `users_audit_list` narrows by action, by target and by time range, and there is no way to ask it what one member of your team did — an actor is something you read off an entry, not something you can filter for. Together with the notification emails Lessly Users sends on its own — a changed password, a new device, a requested email change with its revert link, a pending MFA reset — this is what makes an operator override safe to offer: nothing sensitive happens quietly, either to your team or to your end-user.

## Read the numbers

**Users → Overview** is where the product's sign-in stands, in five blocks: **Users** (total, active, waitlisted, banned, deleted and erased), **Sessions**, **Second factors**, **Sign-ins** (succeeded, failed and the success rate) and **Abuse signals** (lockouts, MFA lockouts, failed sign-ins, breached passwords seen and refresh-token reuse detected).

Read the window selector — 24 hours, 7 days, 30 days or 90 days — carefully, because it does not move everything. The user, live-session and factor populations are counted as of right now and ignore it entirely; the sign-in counters, the sessions established, and the whole abuse block are computed over the window. Each number carries its scope on screen, so nothing has to be inferred. A success rate reads "No attempts" rather than 0% when nothing was tried, because an idle window and a broken one are opposite facts.

Two read-only operations still have no screen anywhere in the App, and it is worth knowing they are there:

- **Retention settings** — how long Lessly Users keeps what it keeps.
- **Service-level readings** — the service level it is meeting for your product.

Both are read through the product's API and are available to an agent.

## Next steps

- [Configure authentication](/ship/users/configuration): the sign-in methods, password policy and session lifetimes the operations above obey.
- [Read the token contract](/ship/users/sessions-and-tokens): why a revoked session can outlive its revocation by one token lifetime, and how to close that gap.
- [Migrate users in and out](/ship/users/migration): the import file format, and getting your users back out.
- [Run a waitlist](/ship/users/waitlist): the directory before your product is open to everyone.
