# Authentication: keys and sessions

Every call identifies itself with the `Authorization: Bearer <value>` header, and the API recognizes the type of credential from the prefix of the value. For integrations, the API key is the default choice. This page lists the prefixes, the rules for keys, their lifecycle, and when a session is the better choice.

> **Type:** reference
> **For:** Anyone who builds a credential into an integration or has to interpret a 401.
> **Prerequisite:** an account in an organization whose package includes API access

## The prefixes

| Prefix | Credential | For integrations |
|---|---|---|
| `ak_` | API key of an organization | yes, the default for your own server |
| `oat_` | Access token of a delegated access grant | yes, when a person gives your application permissions, see [Delegated access with OAuth 2.1](/api/oauth-delegated-access) |
| `eyJ` | Access token of a session | for user interfaces and the command line with a password |
| `sct_` | SCIM token of an organization | only for `/scim/v2`, see [Provisioning with SCIM 2.0](/api/scim-provisioning) |
| `opk_` | Operator key | platform operations only |
| `rdr_` | Renderer token of a paired device | no |
| `inv_` | Invitation token before the first sign-in | no |
| `dev_` | Device credential for the initial registration | no |

`ak_` and `opk_` are **distinct** prefixes: `opk_` does not start with `ak_`. A key therefore says by itself which kind it is. Do not confuse the secret values with the **identifiers** of their records (`akm_`, `sctm_`, `oac_`). Identifiers appear in lists and logs, while the secret value itself appears exactly once, in the response that issues it.

An `sct_` token is accepted on no route other than `/scim/v2` and is rejected with `403` everywhere else. Conversely, neither an `ak_` nor an `oat_` credential reaches the SCIM surface.

A value with an unknown prefix counts as **unknown**, not as anonymous: The response is `401`, not `404`. Without the header, you reach only public routes, for example `GET /v1/openapi.json`, `GET /health/ready`, or `POST /v1/auth/login`. In the reference, you recognize them by an empty `security` list and the sentence “Öffentlich — keine Anmeldung erforderlich” (public, no sign-in required). Exactly 46 of the 443 operations work this way.

## The API key

| Rule | Value |
|---|---|
| Form | `ak_` and 26 characters, e.g., `ak_01KQRWJRC0APKEY00000000001` |
| Plaintext | appears exactly once, in the response to `POST /v1/me/api-keys` (field `plainKey`) |
| Recognition | Lists show only `keyPrefix` (`ak_` and 9 characters) and the identifier `akm_…` |
| Lifetime | does not expire after 15 minutes. `expiresAt` sets an expiration date, `null` means no expiry |
| Permissions | a list of specific permissions (`scope`). A class (`read-only`, `media-automation`, `content-authoring`, `playout-control`, `full`) only selects this list, and `custom` is the free choice. What gets sent is always `scope`, and the list is not kept in sync with the class afterward |
| Upper limit | a key can never do more than its creator (`403 ApiKey.ScopeEscalationBlocked`) |
| Key capability | only permissions that the catalog provides for keys (`422 ApiKey.ScopeNotKeyCapable`) |
| Organization | a key belongs to exactly one organization |

`GET /v1/permission-catalog` tells you which permissions a key can carry. It needs no special permission, only authentication. Every entry carries `appliesTo`, and only permissions with `apiKey` in this list are key-capable. In the core catalog, that is currently 56 permissions: media (`asset.*`), scenes and layout templates (`scene.*`, `layout-template.*`), playlists and programs (`playlist.*`, `schedule.*`), locations (`location.*`), the displays (`fleet.player.list`, `fleet.player.read`, `fleet.player.update`, `fleet.player.command.issue`), the web displays (`fleet.web-display.manage`), advertising (all eight `advertising.*`), the proofs (`analytics.proof.read`, `analytics.proof.manage`), as well as `billing.usage.read` and `analytics.query.run`. Not key-capable are, among others, the device list `identity.device.list`, the playback history `fleet.playback-history.read`, creating and removing displays (`fleet.player.pair`, `fleet.player.delete`), account administration `identity.*`, webhook administration `webhook.*`, and everything under `platform.*`. What is listed there stays reserved for sessions.

The command line shows the allow list with `szenera api-key issue --list-allowed` and the whole catalog with `szenera permission catalog`.

A key acts with the permissions of the account that created it. If that account is suspended, the key is dormant: Authentication answers `401 Auth.UserSuspended`, and the key works again once the suspension is lifted. If the account is deleted, the key is revoked (reason `user-deleted-cascade`). The same applies to a connected application and the account that consented to it.

Under `/v1/me`, a key reaches only the read routes that an integration needs: `GET /v1/me` (who the credential is), `GET /v1/me/permissions`, `GET /v1/me/capabilities`, `GET /v1/me/apps/store`, and `GET /v1/me/licensing`. Everything else there belongs to the person and requires a session.

`lastUsedAt` shows the last use. The value is updated at most once per minute.

## Key lifecycle

| Action | Route | Permission | Note |
|---|---|---|---|
| Create | `POST /v1/me/api-keys` | `identity.api-key.create` | Body: `name`, `scope` (required, list of key-capable permissions), `expiresAt` (required, `null` = no expiry). Optional: `originClass` (label of the chosen class, default `custom`), `rotationGraceHours` (0–72, default 24), and `rateLimit` (the key’s own rate limit in the form `600/1m`, `null` = the default of the environment, see [Throttling](/api/rate-limits)). The class only selects the permission list, and what gets sent is always `scope` |
| Read your own keys | `GET /v1/me/api-keys` | Session | only keys you created yourself. Called with a key, the route answers `401` |
| All keys of the organization | `GET /v1/api-keys` | `identity.api-key.list` | including those of former members. Parameters: `includeRevoked`, `createdById`, `search`, `sort`, `order` |
| Rotate | `POST /v1/me/api-keys/{id}/rotate` | `identity.api-key.create` | Body `graceHours` 0–72. The successor takes over the name, permissions, and rate limit, and the old key stays valid until `oldRevokesAt` |
| Revoke | `DELETE /v1/me/api-keys/{id}` | `identity.api-key.revoke` | Body `reason`: `owner-rotation` (no longer needed or replaced) or `compromised` (probably in the wrong hands). Takes effect immediately and cannot be undone |

```bash
curl -sS -X POST "$SZENERA_API/v1/me/api-keys/akm_01KQRWJRC0APKEY00000000001/rotate" \
  -H "Authorization: Bearer $SZENERA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "graceHours": 24 }'
```

The response, with both key objects trimmed to the fields an integration needs:

```json
{
  "newApiKey": {
    "id": "akm_01KQRWJRC0APKEY00000000002",
    "keyPrefix": "ak_APKEY0002",
    "name": "Warenwirtschaft-Anbindung",
    "originClass": "read-only",
    "scope": ["asset.read", "playlist.read"],
    "expiresAt": "2027-05-04T07:00:00.000Z",
    "revokesAt": null
  },
  "newPlainKey": "ak_01KQRWJRC0APKEY00000000002",
  "oldApiKey": {
    "id": "akm_01KQRWJRC0APKEY00000000001",
    "keyPrefix": "ak_APKEY0001",
    "name": "Warenwirtschaft-Anbindung",
    "originClass": "read-only",
    "scope": ["asset.read", "playlist.read"],
    "expiresAt": "2027-05-04T07:00:00.000Z",
    "revokesAt": "2026-07-21T09:15:00.000Z"
  },
  "oldRevokesAt": "2026-07-21T09:15:00.000Z"
}
```

In the portal, you find the same actions under **Settings › Organization › API keys** with the buttons **Create key**, **Rotate**, and **Revoke**. The grace period is called **Old key valid until** there, with the options **Invalid immediately**, **1 hour**, **24 hours**, and **3 days**. How to create your first key is described in [Create an API key](/getting-started/create-api-key).

The responses to an invalid key are `401 Auth.ApiKeyUnknown`, `401 Auth.ApiKeyExpired`, and `401 Auth.ApiKeyRevoked`, all listed under [Error codes: possible on any route](/api/error-codes-general).

## The session

A session is the way in for user interfaces and for people at the command line, where it is called credential type `password`. It needs refreshing, a key does not.

| Step | Route | Body | Result |
|---|---|---|---|
| Sign in | `POST /v1/auth/login` | `email`, `password`, `audience` (`tenant`), `deviceFingerprint` | `201` with `outcome`: `session`, `mfa-required`, or `organization-choice` |
| Second factor | `POST /v1/auth/mfa/verify` | `mfaSessionId`, `code` | `outcome: session` |
| Refresh | `POST /v1/auth/refresh` | no body, the cookie travels with the request | new access token |

| Component | Rule |
|---|---|
| Access token | `accessToken` in the body, `expiresAt` gives the expiry. Lifetime 15 minutes. Travels as `Authorization: Bearer eyJ…` |
| Refresh cookie | `refresh_token`, `httpOnly`, path `/v1/auth`, `SameSite=Lax`, 7 days. Only `POST /v1/auth/refresh` reads it and renews it |
| Cookie rule | A cookie is set only if the response is actually a session (`outcome: session`) |
| Other origins | Browser clients send `credentials: 'include'`, otherwise the cookie stays behind |
| Cookie host | The cookie carries no `Domain` attribute and belongs to the host that sets it: sign-in and refresh go to the same host. If your environment has a sign-in host (`auth.<your-domain>`), it is that one |

Regular API requests are not authenticated with cookies. The Bearer header is always the credential. If an account belongs to several organizations, sign-in ends in `organization-choice`. A key does not face this question because it belongs to exactly one organization.

Company sign-in through your organization’s provider and the platform-wide providers are round trips through the browser, not ways to integrate. How to set them up is described in the handbook under [Organization](https://szenera.com/docs/organization).

## Key or session?

| Situation | Choice |
|---|---|
| Server-to-server, scripts, CI | Key |
| Permissions for media, scenes, playlists, programs, locations, advertising, and proofs | Key |
| Controlling devices, managing accounts or webhooks | Session, because no key carries these permissions |
| A quick lookup without creating a key | Session through the command line with credential type `password` |
| Your application runs at other customers’ sites and should see their inventory | Delegated access `oat_…`: a person consents instead of handing over a key |
| Maintaining accounts from an HR directory | SCIM token `sct_…` |

## Related

- [Create an API key](/getting-started/create-api-key)
- [Delegated access with OAuth 2.1](/api/oauth-delegated-access)
- [Provisioning with SCIM 2.0](/api/scim-provisioning)
- [Error codes: accounts and sessions](/api/error-codes-accounts)
- [Handbook: Organization](https://szenera.com/docs/organization)
