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

# Credentials

> Four bearer families, four surfaces, and a prefix that tells them apart.

Every credential the API mints follows one shape: high-entropy random bytes behind
a prefix that says what the token is, stored as a SHA-256 hash, shown in plaintext
exactly once.

The prefix does two jobs. It lets a leaked token be recognized — in a log, in a
git push, in a repository scan — and it keeps a credential from one surface from
ever opening another's routes.

## The four families

<CardGroup cols={2}>
  <Card title="uk_st_… — staff session" icon="user">
    A person signed into the panel. Administers the organization. Lives 30 days,
    revocable.
  </Card>

  <Card title="uk_sk_… — organization API key" icon="server">
    Your backend. Reads and writes the customer plane, scoped to the key's
    environment.
  </Card>

  <Card title="uk_pk_… — publishable key" icon="globe">
    Your pages. Public by design; the allowed-origins list is its boundary.
  </Card>

  <Card title="uk_ct_… — contact session" icon="user-check">
    One of *your* users. Reads their own contact and nothing else.
  </Card>
</CardGroup>

All four ride the `Authorization: Bearer` header, except the publishable key.
That one travels in the request **body** of the endpoints that accept it, and in
the **path** of the two public reads addressed by it —
`GET /v1/jwks/{publishableKey}` and `GET /v1/config/{publishableKey}`, which a
login screen and a backend fetch before anybody has signed in.

<Note>
  Nothing else is accepted as a credential. No query parameter — it would land in
  access logs and browser history. No cookie — this API is not cookie-authenticated,
  which is also why CORS never needs credentials.
</Note>

## Presenting the wrong one

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

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

That difference matters: one is a config mistake, the other is a security event,
and they should not read the same in your logs.

## Staff sessions

`uk_st_…`, minted by `/v1/auth/signup`, `/v1/auth/login` and
`/v1/auth/two-factor`. Valid for 30 days.

Long, because it is **revocable**. Signing out, resetting the password or losing
the membership all kill it server-side — unlike a self-contained JWT, which
stays valid until it expires no matter what happens to the account. Changing the
password from inside the account ends every *other* session and keeps the one
that made the change: the person doing it is the one who just proved the old
password.

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

A user can see where they are signed in and sign out everywhere else:

```bash theme={null}
curl -s $API/v1/account/sessions        -H "Authorization: Bearer uk_st_…"
curl -s -X DELETE $API/v1/account/sessions -H "Authorization: Bearer uk_st_…"
```

"Everywhere else" keeps the session that asked — nobody wants to be logged out of
the tab they clicked in.

## Organization API keys

`uk_sk_…`, server-side only. Created in the panel, shown once, stored as a hash.

```
uk_sk_live_a91c…    uk_sk_test_a91c…
```

The environment is part of the prefix and, more importantly, part of the stored
row: it is resolved from there on every request. See
[Environments](/en/concepts/environments).

Revoking stamps `revoked_at` rather than deleting the row, so the audit trail of
which key existed does not disappear with it.

<Warning>
  A key in a browser bundle is a key in every visitor's hands. `uk_sk_` belongs on
  your server. For pages, use a publishable key.
</Warning>

## Publishable keys

`uk_pk_…`, one per environment, seeded with it, stored in **plaintext** — because
a publishable key identifies and authorizes almost nothing, and the panel has to
be able to show it again.

It sits in your page's HTML by design. What keeps a stranger who read it from
flooding your tenant with junk contacts is the **allowed-origins list** on its row,
plus a per-key rate limit on boot.

```bash theme={null}
curl -s -X PATCH $API/v1/organization/publishable-keys/{id} \
  -H "Authorization: Bearer $UK_SESSION" \
  -H "X-Organization-Id: org_4b1e…" \
  -H 'Content-Type: application/json' \
  -d '{ "allowed_origins": ["https://app.example.com"] }'
```

Matching is exact on the normalized origin — no wildcards, no prefixes, because
the list is short and the ambiguity of anything cleverer is where bypasses live.

<Warning>
  An **empty list allows any origin**. That is the getting-started state, and it is
  the one thing to fix before going live.
</Warning>

With a non-empty list a missing `Origin` header is refused too: browsers always
send one cross-origin, so "no Origin" means "not a browser" — exactly the caller
the list exists to stop.

## Contact sessions

`uk_ct_…`, your user's own credential, minted by boot, by a redeemed magic link
or email code, by hosted sign-in, by social sign-in, or by email verification.
Valid for 30 days.

A contact session reads its own contact and nothing else. That scope *is* the whole
authorization model of `uk_ct_`.

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

It also carries a `verified` flag — whether the identity behind it was **proven**:

| How the session was minted          | `verified` |
| ----------------------------------- | :--------: |
| Boot with a valid HMAC hash         |      ✓     |
| Redeemed magic link                 |      ✓     |
| Email code from the inbox           |      ✓     |
| Hosted sign-in with a password      |      ✓     |
| Social sign-in                      |      ✓     |
| Email verification link             |      ✓     |
| Boot with `anonymous_id` only       |      ✗     |
| Boot with `external_id` and no hash |      ✗     |

An unverified session is usable in development and marked in the panel, but it is
barred from anything another person's data could leak through.

A contact can see and end their own sessions — `GET /v1/contact/sessions`,
`DELETE /v1/contact/sessions/{id}`, and `DELETE /v1/contact/sessions` for
everywhere-else. That is part of the token's safety case, not a nice-to-have: it
lives on your domain without `httpOnly`.

### The one non-opaque credential

Everything above is opaque — a random string whose meaning lives in our database.
The exception is the **session JWT**: five minutes, ES256, signed per environment,
minted from a `uk_ct_…` session at `POST /v1/contact/token` and verified by your
backend offline against `GET /v1/jwks/{publishableKey}`.

It exists so your API does not have to call ours on every request. It is short
precisely because it cannot be revoked. See
[Session tokens](/en/customer-auth/session-tokens).

## One-time tokens

Not credentials you hold, but the same shape — one prefix each, so none can be
mistaken for a session:

| Prefix    | What it is                                                        | Lifetime      |
| --------- | ----------------------------------------------------------------- | ------------- |
| `uk_2fa_` | Two-factor challenge, between the password step and the code step | 5 minutes     |
| `uk_rt_`  | Staff password reset                                              | 1 hour        |
| `uk_inv_` | Organization invitation                                           | 7 days        |
| `uk_ml_`  | Contact magic link                                                | 1 hour        |
| `uk_ec_`  | Contact email-code challenge — the page's half of an OTP sign-in  | 10 minutes    |
| `uk_c2_`  | Contact two-factor challenge                                      | 10 minutes    |
| `uk_os_`  | Social sign-in state, held while the provider has the browser     | 15 minutes    |
| `uk_cv_`  | Contact email verification                                        | 24 hours      |
| `uk_cr_`  | Contact password reset                                            | 1 hour        |
| `uk_is_`  | Environment identity secret (HMAC key, not one-time)              | until rotated |
| `org_`    | Organization public code — not a secret at all                    | forever       |

## What is stored

|                                | Storage                                                 |
| ------------------------------ | ------------------------------------------------------- |
| Tokens and API keys            | SHA-256. High entropy makes a fast unsalted hash right. |
| Passwords                      | bcrypt at default cost — deliberate slowness.           |
| Publishable keys, org codes    | Plaintext. They identify; they do not authorize.        |
| TOTP secrets, identity secrets | Encrypted at rest with AES-256.                         |

A database dump hands out no working credentials.
