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

# API keys

> Minting secret keys, publishing public ones, and the origin list that guards them.

Two kinds of key, and they are not interchangeable.

## Secret keys

`uk_sk_…`, your server's credential. Created in the panel, shown once, stored as
a SHA-256 hash.

```bash theme={null}
curl -s $API/v1/organization/api-keys \
  -H "Authorization: Bearer $UK_SESSION" \
  -H "X-Organization-Id: org_4b1e…" \
  -H 'Content-Type: application/json' \
  -d '{ "name": "Production backend", "environment": "live" }'
```

```json 201 theme={null}
{
  "id": "…",
  "name": "Production backend",
  "prefix": "uk_sk_live_a91c",
  "environment": "live",
  "key": "uk_sk_live_a91c…"
}
```

Requires `api_keys:write`.

<Warning>
  `key` is the only time the value is readable. What is stored is its hash, so a
  database dump hands out no working credentials — and a lost key is replaced, never
  recovered.
</Warning>

A key is born in an environment and never leaves it. The environment is chosen
here, minted into the prefix, and resolved from the stored row on every request.
Nothing the caller sends later can change it.

### Listing

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

```json theme={null}
{
  "api_keys": [
    {
      "id": "…",
      "name": "Production backend",
      "key_prefix": "uk_sk_live_a91c",
      "environment_kind": "live",
      "last_used_at": "2026-07-29T11:58:00Z",
      "created_at": "…"
    }
  ]
}
```

Requires `api_keys:read`. Only the display prefix comes back — enough to recognize
a key in a list, never enough to use one.

`last_used_at` is stamped at minute granularity, best-effort: writing on every
request would turn a read path into a write on a hot row.

### Revoking

```bash theme={null}
curl -s -X DELETE $API/v1/organization/api-keys/{id} \
  -H "Authorization: Bearer $UK_SESSION" \
  -H "X-Organization-Id: org_4b1e…"
```

`204`. The row survives with `revoked_at` stamped, so the audit trail of which key
existed does not disappear with the key. Revoked keys stop authenticating
immediately.

### Rotating

There is no rotate endpoint, because rotation is two calls and a deploy:

<Steps>
  <Step title="Create the replacement">
    Same environment, a name that says why it exists.
  </Step>

  <Step title="Deploy it">
    Both keys work at once — nothing is exclusive.
  </Step>

  <Step title="Revoke the old one">
    Check `last_used_at` first: if it is still moving, something is still holding
    it.
  </Step>
</Steps>

## Publishable keys

`uk_pk_…`, one per environment, seeded when the organization is created. You never
create or delete them; you configure them.

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

```json theme={null}
{
  "publishable_keys": [
    {
      "id": "…",
      "key": "uk_pk_live_3d8e…",
      "allowed_origins": ["https://app.example.com"],
      "environment_kind": "live",
      "created_at": "…"
    }
  ]
}
```

Returned in **plaintext**, unlike every secret: the value is public by definition —
it sits in your page's HTML — and the panel is where a developer copies it from.

A publishable key identifies and authorizes almost nothing. It opens `/v1/boot` and
the `/v1/contact-auth/*` flows, and nothing else.

### The allowed-origins list

This list is the actual security boundary of a key that is public by design.

```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", "https://example.com"] }'
```

Requires `api_keys:write`.

Entries are scheme, host and optionally port. No path, no query, no fragment — an
`Origin` header never carries one, and an entry that did would never match
anything. Malformed entries answer `400`.

Matching is **exact** on the normalized origin. No wildcards, no prefixes: 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 so nobody's
  first ten minutes are spent on a CORS screen. Fill it in before you go live.
</Warning>

With a non-empty list, a request with **no** `Origin` header is refused as well.
Browsers always send one cross-origin, so "no Origin" means "not a browser" —
exactly the caller the list exists to stop.

A rejected origin answers:

```json 403 theme={null}
{
  "error": {
    "code": "origin_not_allowed",
    "message": "this publishable key does not accept calls from this origin"
  }
}
```

### The other guard

Boot is throttled per publishable key — 240 calls a minute — on top of the per-IP
limit. Bots and landing-page traffic mint visitor rows forever otherwise, and the
key is the tenant boundary a flood arrives through.

## Which key goes where

| Where                       | Key       | Why                                                   |
| --------------------------- | --------- | ----------------------------------------------------- |
| Your backend, server-side   | `uk_sk_…` | Full customer-plane access, scoped to its environment |
| Your web pages, in the HTML | `uk_pk_…` | Public by design, guarded by the origin list          |
| Your mobile app             | `uk_pk_…` | Anything shipped to a device is public                |
| The panel                   | `uk_st_…` | A person, not a machine                               |

<Warning>
  A `uk_sk_` key in a browser bundle is a `uk_sk_` key in every visitor's hands.
  Ship publishable keys to clients; keep secret keys on servers.
</Warning>

## If a key leaks

<Steps>
  <Step title="Revoke it">
    `DELETE /v1/organization/api-keys/{id}`. It stops working on the next request.
  </Step>

  <Step title="Create a replacement and deploy">
    Same environment.
  </Step>

  <Step title="If it was federated, rotate the identity secret too">
    A leaked `uk_is_…` is worse than a leaked API key: it mints verified sessions
    for *any* user id. See [Federated identity](/en/customer-auth/federated#rotating-the-secret).
  </Step>
</Steps>

Every credential carries a recognizable prefix precisely so a repository scan or a
log grep can find it before somebody else does.
