---
title: "Authentication"
description: "One bearer header, whether the caller is a person or a machine."
url: "https://saved.sh/docs/api/authentication"
---

```
Authorization: Bearer <credential>
```

There is one header and one code path. A human's session token and a machine's API key
resolve to the same internal principal, so a shared endpoint runs one handler regardless of
who called it.

## Credentials [#credentials]

| Credential    | Looks like                       | Obtained by                    |
| ------------- | -------------------------------- | ------------------------------ |
| Session token | A JWT, three dot-separated parts | `sctl login`, or the dashboard |
| API key       | An opaque string                 | `sctl apikey create`           |
| Worker key    | An opaque string                 | `sctl worker provision`        |

The API distinguishes the two forms by shape: a credential with two dots is validated as a
JWT, anything else as an opaque key. You never have to say which you are sending.

## Getting a key [#getting-a-key]

```bash
sctl apikey create ci-backups --perms backups:read,backups:write
```

The secret is shown once and never stored by us. See [API keys](/docs/api/api-keys).

## Bootstrapping without a credential [#bootstrapping-without-a-credential]

Three endpoints need no token:

| Method | Path                  | Purpose                          |
| ------ | --------------------- | -------------------------------- |
| `GET`  | `/healthz`            | Liveness                         |
| `GET`  | `/readyz`             | Readiness                        |
| `GET`  | `/v1/auth/cli/config` | The CLI's device-code parameters |

```bash
curl -fsS https://api.saved.sh/v1/auth/cli/config
```

```json
{
  "authkit_domain": "auth.saved.sh",
  "client_id": "client_01H...",
  "scopes": ["openid", "profile", "email", "offline_access"],
  "api_version": "v1"
}
```

## Who am I [#who-am-i]

```bash
curl -fsS https://api.saved.sh/v1/auth/cli/whoami \
  -H "Authorization: Bearer $SAVED_API_KEY"
```

```json
{
  "user_id": "018f3c2a-...",
  "email": "you@example.com",
  "workspace_id": "4b7e1d90-...",
  "workos_org_id": "org_01H...",
  "role": "admin",
  "permissions": ["backups:read", "backups:write", "..."]
}
```

This is the fastest way to debug a `403`: the `permissions` array is exactly what will be
enforced.

<Callout type="warn">
  `role` is **display only**. Authorization reads `permissions` and never the role, so a custom
  role you create is enforced correctly without us knowing it exists.
</Callout>

The full catalog is public to any authenticated caller:

```bash
curl -fsS https://api.saved.sh/v1/permissions -H "Authorization: Bearer $SAVED_API_KEY"
```

## Workspace scoping [#workspace-scoping]

A credential belongs to exactly one workspace. There is no token that spans two, and no
header that selects one.

* **Session tokens** are re-minted when you switch workspace.
* **API keys and worker keys** are bound to a workspace at creation and cannot be moved.

Many paths carry `{workspaceID}` anyway. It must match the credential's workspace; another
workspace's ID returns `403 wrong_workspace` or `404`, never data.

## What a machine credential cannot do [#what-a-machine-credential-cannot-do]

| Refused                                   | Why                                                                     |
| ----------------------------------------- | ----------------------------------------------------------------------- |
| `workers:*`                               | A compromised key could mint worker credentials                         |
| `api-keys:*`                              | One leaked key could mint more                                          |
| `billing:*`                               | The workspace's cards                                                   |
| `destinations:write`, `connections:write` | Long-lived storage keys, and a consent screen a machine cannot complete |
| `artifacts:confirm` on an API key         | Worker-only                                                             |
| `POST /v1/workspaces`                     | Requires a human principal                                              |

Asking for one at key creation fails with `422`. A key that acquired one another way has it
stripped on every validation.

## Worker credentials [#worker-credentials]

A worker key carries exactly `artifacts:upload` and `artifacts:confirm`, and authenticates
both to this API and to the orchestration hub.

```bash
curl -fsS https://api.saved.sh/v1/auth/worker/whoami \
  -H "Authorization: Bearer $WORKER_TOKEN"
```

```json
{
  "worker_id": "0f2c9a1e-...",
  "name": "prod-worker-1",
  "workspace_id": "4b7e1d90-...",
  "namespace": "4b7e1d90-...",
  "task_queue": "0f2c9a1e-...",
  "hub_address": "hub.saved.sh:443"
}
```

This is the only remote configuration a worker receives, and it carries no secrets.

## Revocation [#revocation]

| Credential    | Ends when                                             |
| ------------- | ----------------------------------------------------- |
| Session token | It expires, or the membership is removed              |
| API key       | `sctl apikey revoke`, within the validation cache TTL |
| Worker key    | `sctl worker rotate` or `delete`, within the same TTL |

<Callout type="warn">
  **Revocation is not instantaneous.** Validations are cached for about a minute, so a revoked
  credential may keep working briefly. Treat revocation as effective within a minute, and if a
  credential leaked, rotate whatever it could reach as well.
</Callout>

## Failure modes [#failure-modes]

| Status | Code              | Meaning                                                          |
| ------ | ----------------- | ---------------------------------------------------------------- |
| `401`  | `unauthenticated` | No `Authorization` header, or an empty bearer value              |
| `401`  | `unauthenticated` | The credential is invalid, expired or revoked                    |
| `403`  | `forbidden`       | Valid credential, missing permission. The message names the slug |
| `403`  | `wrong_workspace` | The credential belongs to another workspace                      |
| `403`  | `forbidden`       | A machine credential on a human-only route                       |

A `401` and a `403` mean genuinely different things here: the first is "I do not know who you
are", the second is "I know, and no".

## Next [#next]

<Cards>
  <Card href="/docs/api/errors" title="Errors" description="Every code the API returns." />

  <Card href="/docs/api/api-keys" title="API keys" description="Creating and scoping them." />

  <Card href="/docs/security/authentication" title="Authentication design" description="How this is built." />
</Cards>
