# Integration keys

An integration key is a machine credential for a server-to-server caller. It begins `cbj_`, is bound to exactly one workspace and one explicit scope set, is stored only as a SHA-256 digest, and is shown in full exactly once — at issue. It opens no Owner surface, and it cannot widen its own scope. The public documentation surface accepts no key at all.

- **Status:** Available
- **Audience:** developer
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/developers/integration-keys/

## What a key is

Connect's application API is session-cookie authenticated: a browser, a Google sign-in, a person. Another *service* has neither, so the integration key exists to give a server-to-server caller an identity without pretending it is a human session. The design is deliberately narrow rather than general-purpose.

```text
cbj_<43 url-safe characters from 32 random bytes>

Authorization: Bearer cbj_…
```

| Field | Meaning |
|---|---|
| `key_id` | The identifier you refer to the key by. Safe to log |
| `name` | A human label, so a list of keys is readable months later |
| `key_hash` | SHA-256 of the plaintext. The plaintext itself is never stored |
| `workspace_id` | The single workspace the key is bound to |
| `scopes` | The explicit scope list. There is no `all` scope |
| `enabled` | A key can be disabled without being destroyed |
| `created_at`, `last_used_at`, `revoked_at` | Issued when, last presented when, revoked when |

> **Note** `last_used_at` is stamped on every successful verification. It is the field that answers the question that actually comes up — *is anything still using this key?* — before somebody revokes one and finds out the hard way.

## Scopes, and why there is no wildcard

Scopes are additive and explicit, and the set is closed. Issuing a key with a scope that is not known raises rather than quietly accepting it, and issuing with no scope at all is refused — a key that can do nothing is a credential in circulation with no reason to exist.

| Scope | Grants |
|---|---|
| `integration:verify` | Confirming the key and the workspace it belongs to |
| `demo:knowledge` | The demonstration knowledge capability |
| `demo:safety` | The demonstration safety capability |
| `demo:draft` | The demonstration drafting capability |

The absence of an `all` scope is the load-bearing decision. If one existed, every future capability would be granted retroactively to every key already issued, and the grant would happen at deploy time with nobody deciding it. Adding a capability to an existing integration has to be a deliberate act.

## Issuing, storing, rotating, revoking

1. Have a key issued for one integration, named after that integration, with the narrowest scope set that does the job.
   - Result: The plaintext is returned once. It is not recoverable afterwards, because only its digest was kept.
2. Put the plaintext straight into a secret manager or the deployment's secret store. Never into source control, a configuration file that is committed, a log line, or an issue.
   - Result: The only copy lives where secrets already live, and rotating it is a configuration change rather than a code change.
3. To rotate, issue the second key first, deploy the new value, confirm `last_used_at` is moving on the new key, and only then revoke the old.
   - Result: There is no window in which the integration has no working credential. Revoking first and issuing second is the same operation with an outage in the middle.
4. Revoke by key id when the integration is retired or the value may have been exposed.
   - Result: `revoked_at` is stamped and verification fails from that moment. Revoking a key that is already revoked reports that it was not changed, rather than reporting a fresh success.

Verification itself is unremarkable and deliberately so: the presented value must carry the `cbj_` prefix, its digest is compared in constant time against the stored hash, and the row must be enabled with no revocation date. A value that fails any of those is simply not a key.

## What a key does not open

It grants no access to the Owner API surface. Owner routes are the operator's own — running the platform rather than using Connect — and a tenant credential reaching them would defeat the separation the middleware allowlist exists to enforce.

It also has nothing to do with the public developer surface documented in this section. `/api/public/*` holds no session and takes no credential; the MCP endpoint's discovery document states its authentication as "none for the public documentation tools", and there is no authenticated MCP tool to present a key to. If you are reading this because a documentation call returned 401, the problem is elsewhere — those routes do not authenticate at all.

And a key never bypasses the services that own the records. A caller with a scope goes through the same domain service a person's action would, so autonomy rules, approvals, suppression, safe-sales limits, metering and the audit trail all still apply. There is no route that runs SQL and none that proxies an arbitrary internal endpoint.

## Questions

### I lost the plaintext. Can it be recovered?

No. Only the SHA-256 digest is stored, which is the same treatment session tokens get. Issue a replacement, move the integration onto it, then revoke the one you lost.

### Can one key serve two workspaces?

No. `workspace_id` is a single value on the key and the scope of everything the key can reach. Two workspaces means two keys, which is also what makes the audit trail readable.

### Does a key expire on its own?

The record carries no expiry date — it carries `enabled` and `revoked_at`, both of which are acts. Treat rotation as a scheduled task of your own rather than something the credential will remind you about.

## Related

- [API authentication](https://connectbyjbrh.com/developers/api-authentication/)
- [Building an agent on Connect safely](https://connectbyjbrh.com/developers/agent-safety/)
- [Verifying a Connect webhook signature](https://connectbyjbrh.com/developers/webhook-signatures/)
- [Security and isolation](https://connectbyjbrh.com/docs/security/)
- [Account and access](https://connectbyjbrh.com/docs/account/)

## What this page is based on

- `backend/app/integration_auth.py` — KEY_PREFIX, KNOWN_SCOPES, issue/verify/revoke
- `docs-source/facts.py` — CAPABILITY_STATUS['integration_keys']
- `backend/app/mcp_server.py` — the public endpoint's authentication statement
