---
title: Authentication
path: getting-started/authentication
status: published
---

# Authentication

ScaiVault accepts bearer tokens issued by [ScaiKey](https://scaikey.scailabs.ai). Every request except `/v1/health` and `/v1/health/ready` requires one. There are three token flavors, for three different use cases.

## Token types

| Type | Use when | Shape | Lifetime |
|------|----------|-------|----------|
| Personal access token | A human scripts against the API | `skt_...` | Configurable (default 90d) |
| Client credentials JWT | A service calls the API | JWT starting with `eyJ` | 1h, refresh via client-credentials flow |
| User session JWT | A user is signed into a ScaiLabs app | JWT starting with `eyJ` | 1h, refresh via OAuth refresh token |

All three go in the `Authorization: Bearer ...` header. ScaiVault validates the signature against ScaiKey's public keys (JWKS cached locally).

**Use client-credentials tokens for server-to-server traffic.** Personal access tokens are fine for scripting and CI, but they belong to a human — if that human leaves, their tokens should be revoked. Services should authenticate as services.

## Getting a client-credentials token

Create a service account in ScaiKey, assign it the scopes it needs, and get back a `client_id` and `client_secret`. Then exchange them for an access token:

```bash
curl -X POST https://scaikey.scailabs.ai/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=$CLIENT_ID" \
  -d "client_secret=$CLIENT_SECRET" \
  -d "scope=secrets:read secrets:write"
```

```python
import os
import httpx

resp = httpx.post(
    "https://scaikey.scailabs.ai/oauth/token",
    data={
        "grant_type": "client_credentials",
        "client_id": os.environ["CLIENT_ID"],
        "client_secret": os.environ["CLIENT_SECRET"],
        "scope": "secrets:read secrets:write",
    },
)
token = resp.json()["access_token"]
```

```typescript
const resp = await fetch("https://scaikey.scailabs.ai/oauth/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "client_credentials",
    client_id: process.env.CLIENT_ID!,
    client_secret: process.env.CLIENT_SECRET!,
    scope: "secrets:read secrets:write",
  }),
});
const { access_token } = await resp.json();
```

Tokens last one hour. Your client should cache and re-request on expiry; all official SDKs do this automatically.

## Scopes

A token can only do what its scopes allow. The set of scopes is intersected with the caller's policies — both must permit the action.

| Scope | What it lets you do |
|-------|---------------------|
| `secrets:read` | Read secret values |
| `secrets:write` | Create and update secrets |
| `secrets:delete` | Delete secrets |
| `secrets:list` | List secrets and metadata |
| `secrets:rotate` | Trigger rotations |
| `policies:read` | View policies |
| `policies:write` | Create, update, delete policies |
| `audit:read` | Query audit logs |
| `audit:export` | Export audit data |
| `pki:issue` | Issue and sign certificates |
| `pki:admin` | Manage PKI roles, CAs, revocation |
| `dynamic:read` | View dynamic engines, roles, leases |
| `dynamic:generate` | Generate dynamic credentials |
| `dynamic:manage` | Configure engines and roles |
| `dynamic:revoke` | Revoke leases |
| `subscriptions:read` / `subscriptions:write` | Consume events |
| `federation:read` / `federation:write` | Manage federated backends |
| `identity:read` / `identity:admin` | Read identity cache / trigger sync |
| `admin` | All of the above |

Ask for the narrowest set of scopes that does the job. A token that can only read from one path is dramatically less dangerous than an `admin` token.

## Using the token

Every request:

```bash
curl -H "Authorization: Bearer $SCAIVAULT_TOKEN" \
     https://scaivault.scailabs.ai/v1/secrets/app/db/password
```

If you omit the header, you get `401 authentication_required`. If the token is malformed or expired, `401 token_expired`. If the scope is insufficient, `403 access_denied`.

## Request headers

| Header | Required | Description |
|--------|----------|-------------|
| `Authorization` | Yes | `Bearer <token>` |
| `Content-Type` | On `PUT`/`POST`/`PATCH` | `application/json` |
| `X-Request-ID` | No | Client-supplied ID for end-to-end tracing |
| `X-Partner-ID` | No | Explicit partner context (partner admins) |
| `X-Tenant-ID` | No | Explicit tenant context (partner admins acting cross-tenant) |

## Tenant context

The token's `tenant_id` claim determines which tenant you act as. Most API paths (`/v1/secrets/*`, `/v1/policies/*`) are tenant-scoped — you only see and touch your own tenant's data.

Three exceptions:

- **Partner admins** can use the `/v1/t/{tenant_id}/` prefix to act as a specific tenant under their partner:

  ```bash
  GET /v1/t/tnt_acme_dev/secrets/app/db/password
  ```

- **Partner-scoped secrets** live under `/v1/partner/secrets/*` and are visible to every tenant under the partner. Useful for things like "shared trust anchors" or "partner-wide default configs".

- **System endpoints** like `/v1/identity/partners` have their own scope checks.

See [Multi-tenancy](../core-concepts/multi-tenancy) for the full model.

## Tokens and rotation

Service-account client secrets should be rotated periodically. The recommended pattern: store the secret in ScaiVault (yes, really), rotate it on a schedule, and let your service fetch it at startup.

Chicken-and-egg: the service needs *some* credential to talk to ScaiVault. That credential — the bootstrap token — is usually injected by your deployment platform (Kubernetes service account token, AWS IAM role, Nomad Workload Identity, GCP Workload Identity) and exchanged for a ScaiKey JWT on boot.

## What's next

- [Your First Integration](./your-first-integration) — a service reading secrets end-to-end.
- [Multi-tenancy](../core-concepts/multi-tenancy) — tenant and partner scoping.
- [Authentication Reference](../reference/authentication) — endpoint details.
