# Authentication

URL: https://docs.hanzo.ai/docs/api-keys

> How a request proves who it is — the two Hanzo key types, which one may ship in a browser, and how to create, limit, edit and revoke keys.

Every request to the Hanzo platform is authenticated with a bearer token. There
are two kinds of key, they are not interchangeable, and the difference is what
each one resolves to when the edge reads it.

## The two types

| Prefix | Type        | What it is                                |
| ------ | ----------- | ----------------------------------------- |
| `sk-`  | secret      | session-equivalent, belongs on a server   |
| `pk-`  | publishable | org-identifying, safe in a browser bundle |

`sk-` belongs on a server and `pk-` may ship in a browser bundle. A key spelled any other way is refused at the edge — never guessed at, never
defaulted to the safer-sounding one.

### What each one resolves to

A **secret key** resolves to **you**. The edge looks it up and gets your user
row, which makes it session-equivalent: anything you can do in the console, a
holder of your `sk-` can do over HTTP. That is why it belongs on a server and
nowhere else.

A **publishable key** resolves to **your organization** and stops there — it
never yields a principal, so it cannot read anything. It identifies; it does not
authenticate. That is the property that makes it safe to ship in a browser
bundle, and it is enforced at the edge rather than promised: a `pk-` presented
as a bearer credential is refused outright, no matter which endpoint it arrives
at. One `pk-` covers events, product insights and error capture, which is why
you do not need a separate key or a separate DSN for each.

A publishable key minted alongside a project names that project rather than the
organization, so beacons from two sites attribute separately. It is the same
spelling and the same rules.

### Every key is its own credential

You hold as many keys as you like, of either type, and each is its own row in
IAM with its own name, permissions, budget, rate and expiry. Creating a key
never touches another, so a new key for a new service leaves every running
integration working, and revoking one stops only that one.

A key belongs to the organization it was created in and names the person who
created it. A member sees and manages their own keys; an admin of the
organization sees and can revoke every key it holds. Only the person a key
belongs to can edit it, and an admin's revoke of someone else's key is recorded
in the organization's audit trail.

Asking for a type that does not exist is an error, not a default. The API will
not hand you a secret key because you asked for something it did not recognize —
handing an `sk-` to someone who wanted a browser key is exactly the accident the
refusal exists to prevent.

## Creating keys

### In the console

1. Go to [platform.hanzo.ai](https://platform.hanzo.ai)
2. Open **API Keys** and choose **Create new secret key**
3. Name it, and set its permissions, budget, rate and expiry if it needs them
4. Copy the secret immediately — it is shown once and never again

A publishable key is the exception: you can read it back at any time, because it
is public by construction and useless to you if you cannot.

### Over the API

Keys are one resource, `/v1/account/keys`, and the type is a field on it. The HTTP
method says what you mean, and a key is addressed by its `id`.

```bash
# Create a key. The response carries the secret, once, in `key`.
curl -X POST https://api.hanzo.ai/v1/account/keys \
  -H "Authorization: Bearer sk-your-key" \
  -H "Content-Type: application/json" \
  -d '{"name":"production","type":"secret"}'

# List the keys you may see. No secret material comes back — a secret key is
# named by its prefix (`sk-live-1a2b…`); a publishable key by its full value.
curl https://api.hanzo.ai/v1/account/keys -H "Authorization: Bearer sk-your-key"

# Change a key's name or policy. Its secret is unchanged and keeps working.
curl -X PATCH https://api.hanzo.ai/v1/account/keys/KEY_ID \
  -H "Authorization: Bearer sk-your-key" \
  -H "Content-Type: application/json" \
  -d '{"name":"production-eu"}'

# Revoke exactly one key. It stays listed as revoked, with who and when.
curl -X DELETE https://api.hanzo.ai/v1/account/keys/KEY_ID \
  -H "Authorization: Bearer sk-your-key"
```

Each listed key carries its `id`, `name`, `type`, `prefix`, `status` (`active`,
`expired` or `revoked`), `creator`, `created`, and when set its `expires`,
`limit`, `budget` and `rate`, what it has spent (`spend`) and when it was last
`used`.
Omitting `type` means `secret`. An organization holds at most 1,000 live keys.

## Limiting what a key may reach

A key reaches whatever its holder reaches. For a key that lives in one deployed
service that is more authority than the service needs, so a key can be narrowed:

```bash
curl -X POST https://api.hanzo.ai/v1/account/keys \
  -H "Authorization: Bearer sk-your-key" \
  -H "Content-Type: application/json" \
  -d '{"name":"trainer","limit":["product:train","read:billing","model:zen5"]}'
```

The entries are `kind:name`, and `kind:*` is the whole kind:

| Entry           | The key may                                        |
| --------------- | -------------------------------------------------- |
| `product:train` | read and write `/v1/train`                         |
| `read:billing`  | only read `/v1/billing` (`GET`, `HEAD`, `OPTIONS`) |
| `read:*`        | only read, anywhere: a read-only key               |
| `model:zen5`    | call only that model                               |
| `project:acme`  | act only in that project                           |

A key naming no `product:` or `read:` entry may reach every service; naming any
of them, it reaches only those, and a request anywhere else is refused `403`
saying what the key may not do. The services are the ones `GET /v1` lists. A kind
outside this set is refused when you create the key rather than stored. A limit nothing consults
would be worse than no limit: it would read as a restriction everywhere you
look and be one nowhere.

A limit \*_only ever narrows._\* It cannot grant a key reach its holder does not
have, so the worst a mistake costs is a call that should have worked. That is
also why an omitted `limit` means unrestricted: every key issued before limits
existed carries none, and a default that restricted would have revoked them all.

Restriction is per kind. A key limited to a project is not thereby limited to
zero models — naming one kind says nothing about the others, so adding a limit
later cannot silently switch off something that was working.

The limit belongs to the **credential, not the person**, which has two
consequences worth stating plainly. Your session and your deploy key can carry
different limits at the same time. And an admin's limited key is still limited:
a restriction you could shed by being an admin would not be one.

`GET /v1/account/keys` reports each key's `limit`, so you can see what a key reaches
without minting a new one to find out, and `PATCH` changes it in place.

A request made with a limited key — one with a limit, a budget or a rate — may
revoke keys but not create or edit them, so no key can mint a wider one. For the
same reason a limited key cannot log in to the container registry: the registry
does not enforce limits, so only an unlimited key works there.

### Budget, rate and expiry

```bash
curl -X POST https://api.hanzo.ai/v1/account/keys \
  -H "Authorization: Bearer sk-your-key" \
  -H "Content-Type: application/json" \
  -d '{"name":"batch","budget":{"month":5000,"total":0},"rate":60,"expires":"2027-01-01T00:00:00Z"}'
```

- **`budget`** caps what the key spends on models, in cents: `month` per calendar
  month (UTC) and `total` over its life; `0` is no cap. Past either, the key's
  model requests are refused `402` with code `key_budget`, naming the budget,
  while your other keys keep working. The listing shows `spend` beside it. The
  budget is checked when a request starts, so requests already running can carry
  a key past it by their own cost.
- **`rate`** caps the key's requests a minute; past it, `429`. It applies besides
  any limit your organization sets, never instead of one.
- **`expires`** (RFC 3339) is when the key stops working; a request with an
  expired key is refused `401` saying it expired. A revoked key is refused `401`
  saying so.

Two things worth knowing before you automate this. A browser session must echo
an anti-CSRF token — `GET /v1/account/csrf`, then send it back as `X-CSRF-Token` —
while a bearer caller does not, because a bearer request cannot be forged by a
cross-site page. And revocation reaches IAM immediately but cloud caches a
resolved key for up to a minute, so a request inside that minute may still be
served.

The full request and response shapes are in the [Keys
reference](https://docs.hanzo.ai/docs/openapi/openapi), generated from the same source as this page.

## Using keys

All Hanzo APIs take the key in the `Authorization` header with the `Bearer`
scheme:

```text
Authorization: Bearer sk-your-key
```

Every service answers on one host, `api.hanzo.ai`, under `/v1/<service>` — the
same key works across all of them:

| Service       | Path                                                   | Example                                     |
| ------------- | ------------------------------------------------------ | ------------------------------------------- |
| AI            | `/v1/chat/completions`, `/v1/models`, `/v1/embeddings` | `api.hanzo.ai/v1/chat/completions`          |
| Search        | `/v1/search`                                           | `api.hanzo.ai/v1/search`                    |
| Secrets       | `/v1/kms`                                              | `api.hanzo.ai/v1/kms/secrets`               |
| Deploys       | `/v1/platform`                                         | `api.hanzo.ai/v1/platform/projects`         |
| Events        | `/v1/event`                                            | `api.hanzo.ai/v1/event/overview`            |
| Observability | `/v1/o11y`                                             | `api.hanzo.ai/v1/o11y/status?product=cloud` |
| Tasks         | `/v1/tasks`                                            | `api.hanzo.ai/v1/tasks/namespaces`          |

### SDK configuration

Every [SDK](https://docs.hanzo.ai/docs/sdks) takes the key where it takes any bearer token, and each
already points at `https://api.hanzo.ai`:

**TypeScript**

```typescript
import { AiApi, Configuration } from 'hanzoai'

const config = new Configuration({ accessToken: process.env.HANZO_API_KEY })
const ai = new AiApi(config)
```

**Python**

```python
import os
from hanzoai.cloud import ApiClient, Configuration

client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
```

**Go**

```go
cfg := hanzoai.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := hanzoai.NewAPIClient(cfg)
```

**Rust**

```rust
let mut cfg = hanzo_client::apis::configuration::Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
```

**Java**

```java
ApiClient client = new ApiClient();
client.setBearerToken(System.getenv("HANZO_API_KEY"));
```

`@hanzo/ai` is the exception worth knowing, and it is an exception on purpose:
it names the field after the key type, so which key you are holding is a fact
the compiler checks rather than a comment.

```typescript
new Hanzo({ secretKey: process.env.HANZO_API_KEY })   // a server
new Hanzo({ publishableKey: 'pk-...' })               // a browser bundle
```

A third-party HTTP client written against the chat-completions shape works too —
point its base URL at `https://api.hanzo.ai/v1` and give it the same key.

### Calling from a browser

The API answers a cross-origin request only from an origin it can trust:
Hanzo's own hosts, and an `https` host your org has attached to a site and
verified over DNS — `POST /v1/projects/{slug}/domains`, then
`POST /v1/projects/{slug}/domains/{host}/verify`. Any other origin, `localhost`
included, gets no CORS headers, so the browser will not read the answer. A
secret key belongs on your server either way; a page carries only the
publishable key.

## Spend and usage

Spend for the calling credential comes from the billing and event APIs —
same host, same key:

```bash
# Current balance
curl https://api.hanzo.ai/v1/billing/balance \
  -H "Authorization: Bearer sk-your-key"

# Itemized metered usage
curl https://api.hanzo.ai/v1/billing/usage \
  -H "Authorization: Bearer sk-your-key"
```

```json
{ "balance": 0, "holds": 0, "available": 0 }
```

```json
{
  "count": 2000,
  "usage": [
    {
      "amount": 24,
      "createdAt": "2026-07-25T21:59:35Z",
      "metadata": { "model": "zen-image" },
      "transactionId": "use_daff3c10f561635114c7a78e8d0eea5d"
    }
  ]
}
```

For rolled-up request, token and spend counts across a range, use `GET
https://api.hanzo.ai/v1/event/overview?range=30d` — see
[Events](https://docs.hanzo.ai/docs/openapi/event).

## Rotation

1. **Rotate on a schedule.** Ninety days is a reasonable default for a secret
   key. A publishable key is public anyway; rotate it when you want to cut off a
   site, not on a calendar.
2. **Keep production keys in [KMS](https://docs.hanzo.ai/docs/openapi/kms).** Never in source, never
   in a committed `.env`.
3. **Create before you revoke.** Create the replacement, deploy it, watch it
   work, then revoke the old key by its id. The two run side by side, so the cut
   over needs no downtime.
4. **One identity per service.** Blast radius, and audit logs you can read.
5. **Watch spend.** `/v1/billing/usage` or the console. An unfamiliar spend
   pattern is what a leaked key looks like from the inside.

### KMS integration

A `KMSSecret` syncs secrets out of Hanzo KMS into a Kubernetes Secret your pods
already read. It authenticates as a machine identity, names a scope in KMS, and
names the Secret it manages:

```yaml
apiVersion: secrets.lux.network/v1alpha1
kind: KMSSecret
metadata:
  name: api-kms-sync
  namespace: acme
spec:
  hostAPI: https://kms.hanzo.ai
  resyncInterval: 60
  authentication:
    universalAuth:
      credentialsRef:
        secretName: acme-iam-creds
        secretNamespace: acme
      secretsScope:
        projectSlug: acme
        envSlug: prod
        secretsPath: /hanzo
        keys:
          - HANZO_API_KEY
  managedSecretReference:
    creationPolicy: Orphan
    secretName: api-secrets
    secretNamespace: acme
    secretType: Opaque
```

`creationPolicy: Orphan` means deleting the CR leaves the Secret in place, so
removing the sync does not take a running workload down with it.

## Revoking

From the console: **API Keys**, then the revoke button on the key's row. Over the
API: `DELETE /v1/account/keys/{id}`. IAM stops the credential immediately, calls
made with it fail `401` within a minute, and every other key keeps working. The
key stays listed as revoked, with who revoked it and when.

## Rules

- **Never commit a key.** Environment variables or KMS.
- **Never put a secret key in a browser.** It resolves to you. Use a publishable
  key, which resolves to your org and cannot read.
- **Always use HTTPS.** Every Hanzo endpoint enforces TLS; plain HTTP is
  rejected.
- **Ask for the type you need.** An unrecognized type is refused, so a typo
  fails loudly instead of quietly handing you the more powerful key.
