---
title: "API keys"
description: "Machine credentials, what they may hold, and what they may not."
url: "https://saved.sh/docs/security/api-keys"
---

An API key is a machine credential scoped to one workspace, carrying a subset of the
[permission catalog](/docs/security/permissions) that an admin chooses when it is created.

It is not a user. It has no session, no MFA and no email, which is why what it is allowed to
hold is deliberately narrower than what a person can hold.

## Creating one [#creating-one]

```bash
sctl apikey create ci-deploy --permission backups:read --permission backups:write
```

```
Created API key "ci-deploy" (7c1e...).

  Secret (copy now, not shown again):
  sk_live_...
```

<Callout type="error">
  The secret is shown **once** and is never stored by us. We keep the key's identifier so we
  know which key is calling; the secret itself lives only where you put it. If you lose it,
  create a new key and revoke the old one.
</Callout>

Grant the narrowest set that does the job. A key with `backups:read` cannot read your data;
a key with `artifacts:download` can read all of it.

## What a key may not hold [#what-a-key-may-not-hold]

Some permissions are refused on any machine credential, whatever you ask for.

| Refused              | Reason                                                                                  |
| -------------------- | --------------------------------------------------------------------------------------- |
| `workers:*`          | A compromised key could mint worker credentials                                         |
| `api-keys:*`         | One leaked key could mint more, and revoking the original would not revoke what it made |
| `billing:*`          | The workspace's cards                                                                   |
| `destinations:write` | Linking a bucket hands over a long-lived S3 key pair                                    |
| `connections:write`  | The OAuth flow ends at a browser consent screen a machine cannot complete               |
| `artifacts:confirm`  | Worker-only. Confirming an upload is a worker's signal about its own work               |

Asking for one at creation fails with `422`. A key that somehow acquired one anyway, minted
before the gate existed or edited directly in the identity provider, has it **stripped on
every validation**, and the strip is logged as a warning naming the key.

<Callout>
  If you see that warning in your own review, the key is over-granted in the identity provider
  and wants revoking. The stripping means it cannot use the permission, but the record of the
  grant is still wrong.
</Callout>

## Storing a key [#storing-a-key]

The key is a password. Everything that applies to passwords applies here.

| Do                                                             | Do not                                             |
| -------------------------------------------------------------- | -------------------------------------------------- |
| A CI secret store: GitHub Actions secrets, GitLab CI variables | Commit it to a repository                          |
| A secrets manager: Vault, AWS Secrets Manager, 1Password       | Put it in a Dockerfile or an image layer           |
| An environment variable read at runtime                        | Paste it into a ticket, a chat, or a support email |
| A file with restrictive permissions                            | Log it, or echo it in CI output                    |

Setting it as an environment variable, per platform:

```bash
# Linux and macOS
export SAVED_API_KEY='sk_live_...'
```

```powershell
# Windows PowerShell, current session
$env:SAVED_API_KEY = 'sk_live_...'

# Windows PowerShell, persisted for the user
[Environment]::SetEnvironmentVariable('SAVED_API_KEY', 'sk_live_...', 'User')
```

```cmd
:: Windows cmd.exe
set SAVED_API_KEY=sk_live_...
```

<Callout type="warn">
  On Linux and macOS, a leading space before `export` keeps the line out of shell history in
  most shells. Better still, read the key from a secrets manager rather than typing it.
</Callout>

## Using a key [#using-a-key]

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

The same credential works with the CLI for non-interactive use, which is the usual shape in
CI. See [CI/CD](/docs/cicd/api-keys).

## Rotation [#rotation]

Rotate on a schedule, and immediately whenever someone with access to the key leaves or the
key may have been exposed.

The zero-downtime order:

1. Create the **new** key with the same permissions.
2. Deploy it everywhere the old one is used.
3. Confirm traffic is working on the new key.
4. Revoke the old key.

```bash
sctl apikey list
sctl apikey revoke <key-id>
```

<Callout type="warn">
  **Revocation is not instantaneous.** Validations are cached for about a minute, so a revoked
  key can keep working briefly. Treat revocation as "effective within a minute". If a key
  leaked, also rotate anything it could reach: source credentials for backups it could
  reconfigure, and destination credentials if it could read them.
</Callout>

## Keys outlive people [#keys-outlive-people]

A key belongs to the workspace, not to the person who created it. Removing a member does
**not** revoke the keys they made.

That is deliberate, so automation does not break when someone leaves. It also means
offboarding has a second step that is easy to forget:

```bash
sctl apikey list
```

Review the list, and revoke anything whose owner or purpose you cannot account for. Name keys
after their **use** rather than their creator (`ci-deploy`, `monitoring`, not `sams-key`), so
that review is possible at all.

## Auditing [#auditing]

Key lifecycle is on the audit trail, and the granted permissions are recorded with the
creation.

| Action            | Recorded                                           | Never recorded |
| ----------------- | -------------------------------------------------- | -------------- |
| `api_key.created` | The key ID, name, and the permission slugs granted | The secret     |
| `api_key.revoked` | The key ID and name                                |                |

Actions taken **by** a key carry `actor.type: machine`, identified by the key's ID, so you can
distinguish "an admin deleted this" from "a key deleted this" without reading names. See
[Audit log](/docs/security/audit-log).

## API keys versus worker keys [#api-keys-versus-worker-keys]

They are the same kind of credential with different rules, and they are not interchangeable.

|                                             | API key                | Worker key                                      |
| ------------------------------------------- | ---------------------- | ----------------------------------------------- |
| Created by                                  | `sctl apikey create`   | `sctl worker provision`                         |
| Permissions                                 | An admin-chosen subset | Exactly `artifacts:upload`, `artifacts:confirm` |
| May hold `artifacts:confirm`                | **No**                 | Yes, and must                                   |
| Also authenticates to the orchestration hub | No                     | Yes                                             |
| Rotating                                    | Create new, revoke old | `sctl worker rotate`, keeping the worker ID     |

Do not put an API key in a worker's `config.yaml`. It will authenticate to our API and then
fail to be admitted by the hub, because it lacks the permission the hub requires.

## Next [#next]

<Cards>
  <Card href="/docs/security/permissions" title="Permissions" description="The catalog a key draws from." />

  <Card href="/docs/cicd/api-keys" title="CI/CD" description="Using a key from a pipeline." />

  <Card href="/docs/workers/registration" title="Worker registration" description="The other machine credential." />
</Cards>
