> ## 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.

# Authentication

> Which credential opens which endpoint, and what happens when you bring the wrong one.

Four credential families, four surfaces. A credential from one never opens
another's routes — that is enforced by the prefix, before any database lookup.

| Family    | Header                  | Opens                                                                              |
| --------- | ----------------------- | ---------------------------------------------------------------------------------- |
| `uk_st_…` | `Authorization: Bearer` | The staff plane: account, organizations, members, roles, keys, panel contact reads |
| `uk_sk_…` | `Authorization: Bearer` | `/v1/me`, `/v1/contacts`                                                           |
| `uk_pk_…` | request **body**        | `/v1/boot`, `/v1/contact-auth/*`                                                   |
| `uk_ct_…` | `Authorization: Bearer` | `/v1/contact/me`, `/v1/contact/logout`                                             |

Full detail in [Credentials](/en/concepts/credentials).

## Staff sessions

Get one from sign-up, sign-in, or the two-factor exchange:

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

```json theme={null}
{
  "token": "uk_st_9f3c…",
  "expires_at": "2026-08-28T12:00:00Z",
  "user": { "...": "..." },
  "organizations": [{ "...": "..." }],
  "active_organization_id": "…",
  "active_organization_code": "org_4b1e…"
}
```

Two-factor accounts answer this instead, and no session is issued yet:

```json theme={null}
{ "two_factor_required": true, "challenge_token": "uk_2fa_…" }
```

Branch on `two_factor_required`, then exchange the challenge at
`/v1/auth/two-factor` within 5 minutes. See
[Two-factor](/en/guides/two-factor#signing-in-with-it).

Then send it on every request:

```bash theme={null}
curl -s $API/v1/session \
  -H "Authorization: Bearer uk_st_9f3c…" \
  -H "X-Organization-Id: org_4b1e…"
```

Sessions live 30 days and are revocable: signing out, changing the password,
resetting the password and losing the membership all kill them server-side.

## API keys

Server-side only. Created in the panel, shown once.

```bash theme={null}
curl -s $API/v1/me -H "Authorization: Bearer uk_sk_live_a91c…"
```

The key's environment is resolved from its stored row on every request. Call
`/v1/me` once at start-up to confirm which one you are holding.

## Publishable keys

Not a header. It goes in the body of the endpoints that accept it:

```bash theme={null}
curl -s $API/v1/boot \
  -H 'Origin: https://app.example.com' \
  -H 'Content-Type: application/json' \
  -d '{ "publishable_key": "uk_pk_live_3d8e…", "anonymous_id": "anon_2f9c1b" }'
```

The gate is not CORS — those paths answer any origin, deliberately. The gate is the
key's [allowed-origins list](/en/guides/api-keys#the-allowed-origins-list).
Empty means any origin; non-empty means exact matches only, and a missing `Origin`
header is refused.

## Contact sessions

Minted by boot, a redeemed magic link, hosted sign-in, or email verification. Reads
that contact and nothing else.

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

`verified` on the session says whether the identity behind it was **proven**. An
unverified session is usable and marked, but barred from anything another person's
data could leak through.

## Failures

<ResponseField name="401 unauthorized">
  Missing, malformed, expired or revoked credential — and also "you are not a
  member of that organization", because the membership JOIN matches nothing and
  distinguishing the two would leak that the organization exists.
</ResponseField>

<ResponseField name="403 forbidden">
  Authenticated, but your role does not carry the permission. Different from `401`:
  signing in again will not help.
</ResponseField>

<ResponseField name="404 not_found">
  Also the answer for a resource in another organization or another environment.
  The 404 never reveals which.
</ResponseField>

### Bringing the wrong family

A well-formed credential from another family is told so, rather than getting a flat
"invalid":

```json 401 theme={null}
{
  "error": {
    "code": "unauthorized",
    "message": "this endpoint expects a staff session token, not an organization API key"
  }
}
```

One is a config mistake, the other is a security event. They should not read the
same in your logs.

## Not revealing whether an account exists

Several endpoints answer identically whether or not an address is known. This is
deliberate and worth preserving if you touch these paths:

| Endpoint                                    | Behaviour                                                                                                                       |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `POST /v1/auth/login`                       | One `401` for unknown email, no-password account and wrong password — with a throwaway bcrypt compare so the timing matches too |
| `POST /v1/auth/forgot-password`             | `202` always                                                                                                                    |
| `POST /v1/contact-auth/signup`              | `202` always — three different things happen behind it                                                                          |
| `POST /v1/contact-auth/login`               | One `401`, same timing equalization                                                                                             |
| `POST /v1/contact-auth/forgot-password`     | `202` always                                                                                                                    |
| `POST /v1/contact-auth/resend-verification` | `202` always                                                                                                                    |
| `POST /v1/contact-auth/magic-link`          | `202` always                                                                                                                    |
| `POST /v1/contact-auth/magic-link/redeem`   | One `401` for used, expired and never-existed                                                                                   |

Without the timing equalization only real accounts would pay bcrypt's tens of
milliseconds, and latency alone would answer the question the status code refuses
to.
