> ## Documentation Index
> Fetch the complete documentation index at: https://docs.blobhub.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Service Accounts

> A machine identity that is a user in its own right — not a borrowed human identity.

[Concepts](/general/concepts) covers API Keys as a way to grant programmatic access to an Organization or a
Blob. This page is about the third thing a key can be scoped to — a **user** — and what that makes possible.

Every write in BlobHub is stamped with a `user_id` — the account that authored it. Look at any Blob, any
Revision, or any message posted into a workflow thread, and that field is what tells you who is responsible.
Today, that user is always a person, because the only way to authenticate programmatically is an API key, and
every API key belongs to whoever minted it. A **service account** is what changes that.

## What a Service Account Is

A service account is a machine identity that is a **user**, not a special kind of API key. It gets its own
`id`, from the same id space every human user gets. It shows up wherever a `user_id` shows up — member lists,
author fields, thread attribution — because as far as the rest of the platform is concerned, it *is* one.

Contrast that with what happens today. When you mint an API key, you mint it **as yourself**. Every Blob a CI
job creates with that key, every Revision a deploy script commits, every message a bot posts into a thread —
all of it records your `user_id`, because the key never had an identity of its own to give. It carries
authority (what the holder is allowed to do) but not identity (who is actually doing it). Three different
scripts sharing one key all look, in the history, like one person.

Give the work its own service account instead, and a Revision it commits is authored by `ci-deploy`, not by
the engineer who set `ci-deploy` up. That's the entire idea. Everything else on this page is either a
consequence of it or a rule for keeping it safe.

## Ownership vs. Membership

Two different questions govern a service account, and BlobHub keeps them completely separate:

* **Who may administer it** — rename it, mint or revoke its keys, retire it — is decided by **ownership**.
  Every service account is owned by exactly one user or one organization, decided at creation and fixed from
  then on.
* **What it can reach** — which organizations and Blobs it can read, write, or administer — is decided by
  **membership**, exactly the way it's decided for a human. You add a service account as a member of an
  organization or a Blob, with a role, the same way you'd add a colleague.

These never overlap. Owning an account grants no reach through it — administering `ci-deploy` doesn't hand you
its memberships. And being a member of an organization grants a service account no say over its own
administration. **Granting a service account access to something is the ordinary "add a member" flow.** There
is no separate mechanism for machine identities — you pick the account the same way you'd pick a person.

```mermaid theme={null}
flowchart LR
    OWNER(["Owner<br/>user or org"])
    SA(["Service Account<br/>(a user)"])
    MEM["Memberships<br/>(same as any user)"]
    REACH["Reach<br/>every org &amp; blob<br/>those memberships grant"]

    OWNER -.->|administers| SA
    SA -->|joins as member| MEM
    MEM -->|grants access to| REACH

    classDef sa fill:#0D9373,stroke:#07C983,stroke-width:1px,color:#ffffff;
    class SA sa;
```

The dashed line is administration; the solid chain is reach. Both start at the account, and they never touch
each other again.

## Identity, Not Alias

A service account has:

* **An `id`** — a UUID, assigned the moment it's created, from the same id space every user gets.
* **A `name`** — free-form text you choose, one to a few words. It does not have to be unique: `ci-deploy` can
  exist in ten different organizations at once.

It does **not** have an alias, and that's deliberate, not an oversight. Every human user can claim one short,
memorable, globally-unique alias, and that namespace is flat, global, and permanently reserved — once claimed,
an alias is never freed, even after the user is gone. If service accounts could claim aliases too, every CI
pipeline and worker fleet on the platform would be drawing from the same limited, permanent namespace real
people use for their handles, and a machine could squat a name a person might otherwise want. So service
accounts don't compete for it at all. Look one up by its `id` or its `name`; there is no alias to find.

## The Recipe

Here's the whole lifecycle end to end, using an organization's own admin-scoped API key throughout, via the
`X-API-Key` header — the same credential your CI pipeline would use. `$ORG_API_KEY` needs the `admin` role on
`org_456`.

<Steps>
  <Step title="Create the account">
    <CodeGroup>
      ```bash Request theme={null}
      curl -X POST https://api.blobhub.io/v1/users/target/org/org_456 \
        -H "X-API-Key: $ORG_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "type": "service",
          "name": "ci-deploy"
        }'
      ```

      ```json Response theme={null}
      {
        "status": "success",
        "user": {
          "id": "user_901",
          "name": "ci-deploy",
          "type": "service",
          "status": "active",
          "owner_target": "org",
          "owner_target_id": "org_456",
          "owner_target_target_id": "org#org_456"
        }
      }
      ```
    </CodeGroup>

    `owner_target_target_id` is an internal index key the API happens to serialize — safe to ignore, along
    with `target_target_id` further down. `type` is the extension point: `"service"` is the only value today,
    and a future kind of account will be a new value here, not a new endpoint.
  </Step>

  <Step title="Add it as a member">
    Owning `org_456` doesn't make `ci-deploy` a member of it — that's the invariant above, in action. It has
    to be added like anyone else:

    <CodeGroup>
      ```bash Request theme={null}
      curl -X POST https://api.blobhub.io/v1/members/target/org/org_456 \
        -H "X-API-Key: $ORG_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "user_id": "user_901",
          "role": "write"
        }'
      ```

      ```json Response theme={null}
      {
        "status": "success",
        "member": {
          "id": "mem_301",
          "user_id": "user_901",
          "role": "write",
          "target": "org",
          "target_id": "org_456",
          "target_target_id": "org#org_456",
          "created_at": "2026-08-03T21:12:03Z"
        }
      }
      ```
    </CodeGroup>
  </Step>

  <Step title="Mint its key">
    Same endpoint you'd use for an Organization or a Blob, targeted at the account instead:

    <CodeGroup>
      ```bash Request theme={null}
      curl -X POST https://api.blobhub.io/v1/api-keys/target/user/user_901 \
        -H "X-API-Key: $ORG_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "role": "write",
          "description": "ci-deploy runtime key"
        }'
      ```

      ```json Response theme={null}
      {
        "status": "success",
        "api_key": {
          "id": "key_701",
          "role": "write",
          "description": "ci-deploy runtime key",
          "target": "user",
          "target_id": "user_901",
          "target_target_id": "user#user_901",
          "user_id": "user_901",
          "created_by_user_id": "user_042",
          "created_at": "2026-08-03T21:13:47Z",
          "key_prefix": "K7mP2xQ9***",
          "key": "K7mP2xQ9vT4nB8wR3jL6yE1sA5hZ0cFd"
        }
      }
      ```
    </CodeGroup>

    Two fields carry the whole feature. `user_id` is the account — not whoever's `$ORG_API_KEY` called this
    endpoint — because this key **authenticates as** that principal. `created_by_user_id` is provenance: the
    human on whose authority the key exists. They are never the same field; conflating them is exactly the
    bug service accounts close, where a key's `user_id` was always its creator, so a worker key always looked
    like whoever set it up. `key` is returned once, in this response, and never again — store it now.
  </Step>

  <Step title="See it write as the account">
    Anything from here on uses `ci-deploy`'s own key, not the org key:

    <CodeGroup>
      ```bash Request theme={null}
      curl https://api.blobhub.io/v1/users/me \
        -H "X-API-Key: K7mP2xQ9vT4nB8wR3jL6yE1sA5hZ0cFd"
      ```

      ```json Response theme={null}
      {
        "status": "success",
        "user": {
          "id": "user_901",
          "name": "ci-deploy",
          "type": "service",
          "status": "active",
          "owner_target": "org",
          "owner_target_id": "org_456",
          "owner_target_target_id": "org#org_456",
          "role": "owner"
        }
      }
      ```
    </CodeGroup>

    That's `ci-deploy` looking itself up. Notice there's no `alias` key at all — not `null`, simply never
    written. (`role` here is the caller's own standing on whatever it just read, `owner` because it's reading
    itself — not a stored attribute of the account.) The same resolution happens on every write: a Blob this
    key creates, a Revision it commits, a message it posts into a thread all carry `author_id` or `user_id`
    equal to `user_901` — `ci-deploy`, in the product's own records, not whoever was behind `$ORG_API_KEY`.
  </Step>
</Steps>

## Key Reach

A key minted at the account is the account — there's no extra scoping step, and nothing to configure beyond
the account's own memberships:

* Add `ci-deploy` as a member of three organizations, and the **one key** from the recipe reaches all three.
  You don't mint a separate key per organization it needs to touch.
* Remove `ci-deploy` from one of those organizations, and that access is gone **immediately, without touching
  the key at all**. The key is still valid; it simply has one less place left to use it.

This is the same rule that governs every human user's access. A service account's key just makes it visible
from the outside, because a key is the only way the account has to prove who it is.

## The Access Perimeter

A service account authenticated by its own key may do **product work**. It may not change **who can access
what**. That one sentence is the whole capability model, and it is worth holding as a boundary rather than as
a list of refusals, because the list will grow over time and the boundary will not.

Product work is everything the platform is for: reading and writing Blobs, committing Revisions, running
workflows, posting into threads, uploading session objects, listing anything it can reach. The account does
all of it under its own `user_id`, at whatever role its memberships give it, with no special cases anywhere.

The **access perimeter** is the much smaller set of operations that decide who *else* can get in. Every one
of them refuses a service account's own key with `403`:

| What it changes        | Endpoint                                                                                                       |
| :--------------------- | :------------------------------------------------------------------------------------------------------------- |
| Mint an API key        | [`POST /api-keys/target/:target/:target_id`](/rest-api/shared/create-api-key)                                  |
| Revoke an API key      | [`DELETE /api-keys/:id`](/rest-api/shared/delete-api-key)                                                      |
| Store a credential     | [`POST /credentials/target/:target/:target_id`](/rest-api/shared/create-credential)                            |
| Delete a credential    | [`DELETE /credentials/:id`](/rest-api/shared/delete-credential)                                                |
| Grant access           | [`POST /members/target/:target/:target_id`](/rest-api/shared/add-member)                                       |
| Revoke access          | [`DELETE /members/:id`](/rest-api/shared/delete-member)                                                        |
| Create an account      | [`POST /users/target/:target/:target_id`](/rest-api/users/create-account)                                      |
| Retire an account      | [`DELETE /users/:user_id`](/rest-api/users/delete-user)                                                        |
| Mint an acting token   | [`POST /auth/core/impersonate`](/rest-api/auth/impersonate)                                                    |
| Publish or unpublish † | [`PATCH /orgs/:id`](/rest-api/orgs/update-org), [`PATCH /blobs/:org_id/:blob_id`](/rest-api/blobs/update-blob) |

† The only conditional row: `PATCH` is refused solely when `visibility` *actually changes*, and every other
field goes through untouched — see [Publishing Is the Subtle One](#publishing-is-the-subtle-one) below.

The refusal never depends on role. An account holding an `admin` membership passes every access check that
membership implies and is still refused here, because `admin` is standing on a target and this rule asks a
different question — what is behind the credential — that no role can answer. Every refusal is the same
response:

```json theme={null}
{
  "status": "failure",
  "error": "forbidden",
  "message": "Request forbidden -- authorization will not help"
}
```

Reads are never on the perimeter. Listing an organization's members, its API keys or its credentials is
ordinary work for an account that can reach them — seeing the perimeter is not changing it.

### Publishing Is the Subtle One

`PATCH` on an Organization or a Blob is not a perimeter operation. It is refused only when it *actually
changes* `visibility`, in either direction:

* **Renaming, re-describing, re-tagging, setting a `url`** — always allowed, including in the same request as
  anything else. These are ordinary product work and stay that way.
* **Sending `visibility` with the value already stored** — allowed. It moves nothing, so there is nothing to
  refuse. This matters more than it looks: an idempotent client that PATCHes its whole desired state on every
  run would otherwise start failing forever the moment that state included `visibility`.
* **Flipping `private` → `public`, or `public` → `private`** — refused.

Creating a Blob is untouched, whatever visibility you create it with: a new public Blob exposes only content
the account itself authored. (Creating an Organization is refused for an unrelated reason — see
[Restrictions](#restrictions).)

### Impersonation Is the Escape Hatch

Nothing on the perimeter is permanently out of reach. It is out of reach *of the key*. Behind every operation
in that table the platform asks one question — **is there a human behind this request?** — and the account's
own key answers no, always, whoever holds it and whatever role it carries. An
[acting token](/rest-api/auth/impersonate) answers yes, and names them.

Mint one as yourself and, for the next 60 minutes, you hold a token that authenticates as the account and
carries you as the actor. It does everything the account's key does, *plus* the perimeter operations its
access reaches, because the request now has a human behind it — you, on the record, for whatever it does.
That is the trade the whole design turns on: the perimeter is not sealed shut, it is made attributable.

One operation the token does not carry all the way across is minting an API key at an **org or blob** target,
which is refused while acting with `400` and `error: cannot_mint_while_acting`. Such a key authenticates as
whoever mints it, so one minted here would record the account rather than you, and would then be refused by
every operation in the table above for the rest of its life. Mint it as yourself — an account is given reach
into an organization or a Blob by membership, never by that target's own key. Minting the account **its own**
key, at `target=user`, is untouched — that is the operation acting mode exists for.

The hatch is deliberately unreachable from inside. A service account's key cannot mint the first acting
token, and an acting token cannot mint another, so no automation can walk itself across the perimeter — a
human starts every chain, every time.

### Why the Line Is Drawn Here

This is what makes it safe to hand a key to something you don't fully control: a worker on a machine you
don't own, a third-party CI runner, a script pulled from somewhere else. If that key leaks, whoever holds it
can act as the account within the memberships it already has — and nothing more. They cannot mint a
longer-lived replacement, add the account to another organization, stand up a second account to survive your
rotating the leaked one, or flip a private Blob public and walk away with a URL that outlives the key.

Revoke the key and the access ends completely, in one step, because the leak never had a way to spread past
it.

(All of this is about the account's *own* key. Administering the account — minting it a fresh key, for
instance — is still perfectly possible, but only through the paths in the next section, none of which run
through the key the account already holds.)

## Who May Administer One

Administering a service account — renaming it, minting or revoking its keys, retiring it — always traces back
to a human, or to a credential a human has already trusted with broad authority over the account's owner:

* **The owning user**, signed in as themselves.
* **An admin of the owning organization**, when the account is owned by an organization rather than a person.
* **An org-scoped API key minted with the `admin` role, for the owning organization.** Every administrative
  operation on a service account requires `admin` specifically — a `write`-scoped key won't reach any of
  them.
* **An acting token, for the one account it acts as** — and only that one. It carries the human who minted
  it, so it satisfies the perimeter rule, but it is scoped to the single account it names: it cannot
  administer a sibling account the same human happens to own.

That third path is easy to miss, and it's the one automation actually depends on: **your CI pipeline's own
admin-scoped org key can administer the service accounts that belong to that same organization** — create,
rename, key, and retire them — exactly as an admin of that organization could by hand. You don't need a
personal API key with a human's name on it in the loop just to manage the accounts your pipeline depends on.
It's also the credential the recipe above used throughout.

What's never on this list: the service account's own key, administering anything — including itself. See
[The Access Perimeter](#the-access-perimeter), above.

## Lifecycle

A service account starts `active` the moment it's created and stays that way until someone retires it.
Retiring one:

1. Deletes every API key it holds.
2. Deletes every credential stored **on the account itself** — third-party provider secrets at
   `target=user/{account}`. Credentials stored on an Organization or a Blob are untouched: those belong to the
   Organization or the Blob and outlive every account that ever read them.
3. Removes every membership it holds.
4. Sets its `status` to `retired`.

Retirement is **terminal.** There's no reactivating a retired account, and nothing to free up or reuse — it
never held an alias to begin with. What retirement does *not* do is delete the record itself: the `id` stays
resolvable, so a Revision `ci-deploy` authored a year ago still shows `ci-deploy`, not a dangling reference.
Retiring an account ends its future; it cannot rewrite its past.

## Restrictions

Three things a service account can never do, by design:

* **Own an organization, or another account.** Creating either is rejected outright — with
  `service_account_cannot_own_org` or `service_account_cannot_own_account` where the request gets far enough
  to be told why, and a plain `403` where it does not. Anything owned by a service account would become
  unreachable the moment whoever administers that account lost access to it, with no way back in.
* **Change the access perimeter with its own key.** Covered above, under
  [The Access Perimeter](#the-access-perimeter) — no exception for role, no exception for target, and the same
  answer whether it is granting, revoking, or publishing. Impersonation is the only way across.
* **Sign in.** There's no password and no OAuth identity behind a service account — the only way to act as one
  is a key minted for it, or an acting token minted by a human.

A service account is deliberately narrow: an identity with exactly the reach its memberships give it, and
nothing that lets it grant itself more.
