> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Hosted authentication

> UserKit owns the account: sign-up, sign-in, verification and recovery, none of them enumerable.

In hosted mode UserKit stores the password and owns the account. You build the
forms; these endpoints are the rules behind them.

Every endpoint here is public surface reached with a **publishable key** in the
request body, from your own domain. The posture is the strictest in the API:
non-enumerable responses, constant-time password paths, strict rate limits, and
one-time tokens for anything that arrives by email.

<Note>
  These endpoints require the environment to be in `hosted` mode. On a federated
  environment they answer `409 environment_not_hosted`.
</Note>

## Sign up

```bash theme={null}
curl -s $API/v1/contact-auth/signup \
  -H 'Content-Type: application/json' \
  -d '{
    "publishable_key": "uk_pk_live_…",
    "email": "grace@example.com",
    "password": "Compiler1",
    "name": "Grace Hopper",
    "anonymous_id": "anon_2f9c1b"
  }'
```

```json 202 — always theme={null}
{ "message": "confira seu e-mail para confirmar o endereço" }
```

Always `202`. The response never reveals whether the address already has an
account. Three different things happen behind that one answer:

<Steps>
  <Step title="A new address">
    The contact is created with the password attached — there is nothing here to
    take over — and a verification email goes out. `email_verified` stays false
    until the link is clicked.
  </Step>

  <Step title="An address that already has a password">
    Nothing is created. A "you already have an account" notice goes to the
    **inbox**, never to the caller.
  </Step>

  <Step title="A contact your server minted, with no password yet">
    The chosen password waits **on the verification token** and attaches only when
    the link proves the email.
  </Step>
</Steps>

<Note>
  While the environment is in [waitlist mode](/en/customer-auth/waitlist), this
  same call puts the person in line instead of creating an account, and answers
  `{ "waitlist": true }`. The password they chose parks on the entry until they
  are let in — the third case below, one layer over.
</Note>

<Warning>
  That third case is the one worth understanding. If the password attached
  immediately, anyone could sign up with the address of a contact you pre-created and
  capture the account. Making the password wait on the token closes that
  structurally, not with a check somebody could forget.
</Warning>

Passing `anonymous_id` folds the visitor this person already was into the new
contact, attribution included.

Rate limited to 10 per hour per IP.

## Verify the email

The link in the email carries a `uk_cv_…` token, valid 24 hours. Your page reads it
from the query string and posts it:

```bash theme={null}
curl -s $API/v1/contact-auth/verify-email \
  -H 'Content-Type: application/json' \
  -d '{ "token": "uk_cv_…" }'
```

```json 200 theme={null}
{
  "contact": { "id": "…", "identified": true, "email_verified": true },
  "token": "uk_ct_…",
  "expires_at": "2026-08-28T12:00:00Z",
  "verified": true
}
```

Three things happen at once: the email is proven, any password waiting on the token
attaches, and a **verified session** comes back — the person clicked from their
inbox, do not make them sign in again.

### Resend

```bash theme={null}
curl -s $API/v1/contact-auth/resend-verification \
  -H 'Content-Type: application/json' \
  -d '{ "publishable_key": "uk_pk_live_…", "email": "grace@example.com" }'
```

`202` always. Nothing goes out for unknown addresses or already-verified ones.
Rate limited to 5 per hour per IP — each request mails somebody's inbox.

## Sign in

```bash theme={null}
curl -s $API/v1/contact-auth/login \
  -H 'Content-Type: application/json' \
  -d '{
    "publishable_key": "uk_pk_live_…",
    "email": "grace@example.com",
    "password": "Compiler1"
  }'
```

```json 200 theme={null}
{
  "contact": { "id": "…", "name": "Grace Hopper" },
  "token": "uk_ct_…",
  "expires_at": "2026-08-28T12:00:00Z",
  "verified": true
}
```

Unknown email, a contact with no password, and a wrong password all produce the
same `401 invalid_credentials` — and take roughly the same time, because the
no-password path runs a throwaway bcrypt compare. Without that, only real accounts
would pay the tens of milliseconds, and latency alone would answer "does this
address exist".

Proving the password proves the account, so the session is verified.

Rate limited to 20 per 5 minutes per IP.

## Recover a password

<Steps>
  <Step title="Ask">
    ```bash theme={null}
    curl -s $API/v1/contact-auth/forgot-password \
      -H 'Content-Type: application/json' \
      -d '{ "publishable_key": "uk_pk_live_…", "email": "grace@example.com" }'
    ```

    Unconditional `202`. Rate limited to 5 per hour per IP.
  </Step>

  <Step title="Reset">
    ```bash theme={null}
    curl -s $API/v1/contact-auth/reset-password \
      -H 'Content-Type: application/json' \
      -d '{ "token": "uk_cr_…", "password": "Nanoseconds1" }'
    ```

    `204`. Rate limited to 10 per hour per IP.
  </Step>
</Steps>

The reset does three things beyond setting the password:

* **every** session of that contact is revoked, immediately — a reset usually means
  somebody else may hold the credential;
* the email counts as proven, because the link arrived in it;
* a contact that was never identified becomes identified.

<Note>
  This flow also works for contacts that never had a password — the ones your server
  created with `POST /v1/contacts`. Receiving the link proves the email, so "reset"
  doubles as "set a password".
</Note>

## Magic links

Passwordless sign-in, and it works in **both** modes. In federated mode it is also
the only thing that can upgrade an email from attribute to proven identity.

<Steps>
  <Step title="Request">
    ```bash theme={null}
    curl -s $API/v1/contact-auth/magic-link \
      -H 'Content-Type: application/json' \
      -d '{ "publishable_key": "uk_pk_live_…", "email": "grace@example.com" }'
    ```

    Unconditional `202`. Valid one hour. Rate limited to 5 per hour per IP.
  </Step>

  <Step title="Redeem">
    ```bash theme={null}
    curl -s $API/v1/contact-auth/magic-link/redeem \
      -H 'Content-Type: application/json' \
      -d '{ "token": "uk_ml_…" }'
    ```

    Answers the contact and a verified `uk_ct_…` session.
  </Step>
</Steps>

The redeem is atomic in SQL, so the link works exactly once. Used, expired and
never-existed all answer the same `401` — three different truths, one response, on
purpose.

## Email codes

The same flow presented as six digits, and it works in **both** modes too. Reach
for it instead of a link when the code will be typed on the device that asked
for it: a code survives a mail client that opens links in its own browser, and
it works when the inbox is on a phone and the sign-in is on a laptop.

<Steps>
  <Step title="Request">
    ```bash theme={null}
    curl -s $API/v1/contact-auth/email-code \
      -H 'Content-Type: application/json' \
      -d '{ "publishable_key": "uk_pk_live_…", "email": "grace@example.com" }'
    ```

    ```json 202 — always theme={null}
    { "challenge_token": "uk_ec_…", "expires_at": "2026-03-01T12:10:00Z" }
    ```

    Hold the challenge in the page. It is not a session and can never be used as
    one. Valid ten minutes. Rate limited to 5 per hour per IP.
  </Step>

  <Step title="Verify">
    ```bash theme={null}
    curl -s $API/v1/contact-auth/email-code/verify \
      -H 'Content-Type: application/json' \
      -d '{ "publishable_key": "uk_pk_live_…", "challenge_token": "uk_ec_…", "code": "418902" }'
    ```

    Answers the contact and a verified `uk_ct_…` session. The code arrived in the
    inbox, so it proves the address exactly like the link does.
  </Step>
</Steps>

The challenge comes back for **every** address, whether or not it has an
account — otherwise this endpoint would be the place somebody learns which
addresses do. The verify holds the same line: a wrong code, an expired one, a
spent one, and a challenge that never had a code behind it all answer the same
`401 invalid_code`.

<Note>
  Two rules make six digits safe to accept, and both matter:

  * **Each code carries five attempts.** A guess costs one whether or not it was
    right, and the code dies when they run out. Six digits is a million, which a
    rate limit alone does not close.
  * **Asking for a new code spends the outstanding one.** Otherwise every request
    would add another five guesses against another number.

  The code alone is not enough either: it is only valid against the challenge it
  was issued for, so a code read over somebody's shoulder opens nothing without
  the browser that asked for it.
</Note>

## Password policy

At least 8 characters, with a lowercase letter, an uppercase letter and a digit.
Anything shorter or simpler answers `400 weak_password`.

Deliberately modest: length is what matters, and a policy people fight produces
`Password1!` everywhere.

**And the password must not be a known-breached one.** Every password set here —
sign-up, reset, "set a password" — is checked against a public corpus of
passwords that have appeared in breaches, and a match answers `400
breached_password`. That single rule stops more account takeovers than any
composition requirement, because credential stuffing is what actually happens
to your customers.

<Note>
  The password does not leave our process, and neither does anything that
  identifies it. It is hashed locally and only the first five characters of that
  hash are sent; the corpus answers with every candidate under that prefix and the
  comparison happens here. The service learns that somebody asked about one of
  several hundred passwords, and nothing else.

  The check **fails open**. If the corpus cannot be reached the password is
  allowed and we log it: refusing every sign-up because a third party is down
  would be an outage we imported into your product.
</Note>

## The session

Every successful flow here hands back a `uk_ct_…` contact session, valid 30 days,
with `verified: true`. Use it for the contact's own reads:

```bash theme={null}
curl -s $API/v1/contact/me     -H "Authorization: Bearer uk_ct_…"
curl -s -X POST $API/v1/contact/logout -H "Authorization: Bearer uk_ct_…"
```

Signing out deletes the session — the next lookup simply matches nothing.
