App keys
Enroll, export, rotate and revoke an application's Ed25519 key without sending its private seed to Core.
Enroll one Ed25519 key pair for each application. Generate the pair locally;
Core stores only its public key and lifecycle metadata. An application can have
at most two active, unexpired keys. Core and Gateway cannot have app keys.
Other IDs, including oauth, can be enrolled before their first start.
Enrollment is available in Administration → Apps, through cld, and through the
administrator API. Keep your
current runtime configuration:
enrolling a key alone does not configure or start an application.
You need a Cloud administrator credential. OAuth credentials also need write
or admin scope for creation and revocation. Use HTTPS when talking to Core
across machines.
App-paid capability calls
Applications may use the same enrolled key to call capabilities explicitly offered for app authority. The application declares each required platform permission; Core enforces it per capability. No service account token needs to be created or distributed for this path. See Call as the application.
The existing security.rate_limit_per_second setting bounds aggregate calls
per application across keys, in addition to signed ingress and per-key limits.
For Geo, each app shares the existing geo.person_daily_credits allowance
across its keys and remains under geo.monthly_budget_credits and its provider
plan limits. Daily app usage is stored under app:<appId> in the existing
subject ledger. No new deployment setting or provider credentials are needed.
Capability execution history adds nullable actor_app_id and actor_key_id
columns on migration. These identify the application and enrolled key without
assigning user grants. Revocation is checked on every signed transmission and
again at the provider before execution, including previously issued app
invocations. Operators can stop one app's access by revoking its enrolled
keys. Platform permissions are declared by the application author; there is
no separate operator approval toggle. Removing a permission from that
declaration takes effect at the next Core admission.
Create keys with cld
Select the intended administrator profile explicitly:
cld --profile operator admin apps keys create inventory --out-dir ./secrets
cld --profile operator admin apps keys create oauth --grant oauth:issue --out-dir ./secrets
cld --profile operator admin apps keys create --all --apps inventory,chat --out-dir ./secrets
cld --profile operator admin apps keys create --all --apps ./apps.txt --out-dir ./secretsapps.txt contains app IDs separated by commas, whitespace, or newlines.
--all requires this explicit inventory; it does not infer running apps or
include optional applications. App IDs match ^[a-z][a-z0-9-]{0,79}$.
--out-dir is required. The default --format file creates <app>.key, whose
contents are the private key string followed by a newline. Its JSON result
includes environment.CLOUD_APP_KEY_FILE with the absolute filename. Other
formats are:
--format |
File | Contents |
|---|---|---|
env |
<app>.env |
CLOUD_APP_KEY=<private key> |
compose |
<app>.compose.yaml and <app>.key |
An override for service app-<app> with a Docker secret mount and the Core origin; the companion key path is absolute |
k8s-secret |
<app>.secret.yaml |
A Kubernetes Secret with stringData.app.key (mount as a file) |
These files use the deployment names CLOUD_APP_KEY and CLOUD_APP_KEY_FILE;
use the upgrade guide before replacing
an existing runtime environment block.
The Kubernetes Secret is named <app>-app-key; long app IDs use a shortened
name with a stable hash suffix. Secret files, including YAML, are private
material. Keep them out of Git and deployment logs.
Every secret file has mode 0600. A newly created output directory has mode
0700. --uid <number> sets file ownership when run as root or with your own
UID; the target service must also be able to traverse the directory.
--name "Deployment" sets a human-readable key name. Text and --json output
contain paths, IDs, and fingerprints, never private keys.
Resume after an interruption
Before contacting Core, cld atomically saves the seed and public key as
.<app>.enrollment.json in the output directory. It then registers the public
key and atomically writes the final artifact. Core treats a repeated public
key for the same app as the same enrollment, including after a lost response.
Repeat the same command and output directory after any interruption. Keep the enrollment file: repeated creation uses that same key and never silently rotates it. A different server, corrupted enrollment file, or existing output without an enrollment record causes an error; preserve the file and repair the cause before retrying. Use a new output directory for a new key or rotation. The enrollment file is private material and needs the same protection as the final key.
Use the key at start
After enrollment, supply either CLOUD_APP_KEY or
CLOUD_APP_KEY_FILE=/run/secrets/inventory.key to the application process, plus
CLOUD_CORE_INTERNAL_ORIGIN=https://core.example.internal. Mount the key file
so the application user can read it. Do not supply both key sources: that
stops startup. The key's app ID must match the built or development app ID.
Core and Gateway keep their infrastructure environment values and refuse keys.
Cloud releases 0.56.0–0.65.0 refuse resource-principal reads signed with an app key, including
Chat readability checks. Keep CLOUD_APP_KEY and CLOUD_APP_KEY_FILE unset on
those releases. Enable them only after Core and all applications run a release
containing the resource-principal read fix (#2377).
With that fix, these checks accept app keys with the same resources:readers
admission as legacy workload credentials. A key identifies the application;
it grants no person or resource authority. Revocation and expiry are checked
again before the provider runs the check.
Key-only operation also requires the release containing the complete platform-client and signed-request migration. Retain infrastructure values on older releases and for third-party apps with old Bun-global clients. Use the checked upgrade path to change an existing installation.
The platform requests its connections once before loading application code.
Explicit environment values win per value; Core supplies missing values.
Connection changes require a rolling restart. Use verified TLS across hosts;
install a private CA through NODE_EXTRA_CA_CERTS before startup. See
Deployment requirements
for replica consistency and startup probes.
Invalid, unknown, revoked or mismatched keys stop startup. If Core is
unreachable or answers HTTP 429 or 503, startup retries for at most five minutes
without opening an HTTP listener. A retry signs a new proof even if the first
response was lost. Successful bootstrap updates lastUsedAt. Revocation blocks
new bootstrap requests on every Core replica immediately; it does not close
already established database or NATS connections or recall delivered secrets.
Background and OAuth authority
The platform signs final requests to Core with the configured app key.
Every key has identity:invoke. OAuth issuance additionally requires the
operator-owned oauth:issue grant, stored as oauth_issue_allowed on
auth.service_account_credentials, beside connections_allowed. Core reads
the grant directly on each request; any app, including a third-party app, can
hold it. Mandates and current OAuth grants still limit each operation.
Every new key defaults to no issuance permission, including a key for oauth
and a replacement for a granted key. Enrollment never inherits grants from
other keys. Pass --grant oauth:issue explicitly whenever creating or rotating
an issuer key. The flag applies to every app named in that command; enroll
issuer keys separately from apps that do not need issuance.
For the built-in OAuth app's first enrollment on a fresh installation, setup and development tooling must run:
cld --profile operator admin apps keys create oauth --grant oauth:issue --out-dir ./secretsThe one-time migration grants built-in issuer keys enrolled before this grant existed. Repeated migrations preserve operator changes. New keys always need an explicit grant, on upgraded installations too.
The administrator API accepts grants: ["oauth:issue"] on enrollment; an omitted
grants or grants: [] creates an ungranted key. cld also accepts --no-grant
for an explicit empty choice. These options apply only to a new public key;
repeating enrollment preserves its original grants, name, and expiry. To
withdraw issuance authority, create an ungranted replacement in a new output
directory, deploy it, and revoke every granted key for that app. A granted key
can still issue during the rotation overlap. Revoke it immediately if issuance
must stop immediately. There is no grant-editing UI.
Issuance serves clients and one-shot grants registered in Cloud's OAuth
registry. A third-party app holding oauth:issue uses that same registry and
Core's issuance checks. A separate client or grant registry owned by a
third-party identity provider is not supported by this contract.
Core owns the issuer and OAuth signing keys. Every granted app uses the same Core issuance endpoint and internal JWKS; changing the granted app leaves the public issuer and JWKS unchanged.
Keys with connections_allowed=false can sign requests but cannot retrieve
startup connections. Revoking a key rejects its next bootstrap or background
request on every Core replica. An invocation already admitted keeps its
existing short lifetime. See background mandates.
Create keys in Administration
Open Administration → Apps, the app, and Keys
(/admin/apps?app=<id>&view=keys). Create key asks for an optional name; the
browser creates the pair and sends only the public key. The private key appears
once, as the key, a Compose snippet or a Kubernetes Secret, with Copy and a
download; closing before either asks first. A lost answer offers Check, which
registers the same pair again instead of creating a second key. An app that never
ran is added with Add app and its ID. Browsers without HTTPS or without
Ed25519 support show the cld command instead. The row menu of a key copies its
fingerprint or public key and revokes it. Keys created in the browser carry no issuance grant;
create an issuer key with cld … --grant oauth:issue (see
Background and OAuth authority).
The Apps list and the app page show the key state of each app. An app whose
running instances took their connections from their own environment still
starts with its environment, also when it already has a key; the registry
reports how each instance started. Its page links to Create key, or to the
keys when one exists, and to Use the key at start. When
Core recorded a refused start (lastRejectedStart), Start refused and its
reason lead the list row and the app page until the next successful start. The
way out follows what still blocks the next start: Turn on again while
background access is off, Create new key while no other valid key exists,
and otherwise the deployment and a restart. The keys column marks
the refused key with the time of the refusal. An app with
only revoked or expired keys has no valid key: its running instances keep
working, the next start fails, and Create new key is the page's one filled
action next to Rotate or recover a key. Two active
keys mean a rotation in progress; the keys column marks the older key as
(old) and shows when each key was last used, so you revoke the old one once
no instance uses it.
Inspect and revoke keys
cld --profile operator admin apps keys list inventory --json
cld --profile operator admin apps keys revoke inventory <keyId> --yesThe list includes the public key, fingerprint, name, creation and creator,
optional expiry, last use, grants, and revocation metadata. Status is active,
expired, or revoked; accountStatus is active or disabled. A disabled
account prevents authentication with all its keys. Successful bootstrap updates
lastUsedAt; a key that has never authenticated has a null value. Lists accept --page and --per-page
(default 100, maximum 500).
The per-app key list also reports lastRejectedStart: the time, key ID and
reason (revoked, expired, disabled, or connections_not_permitted) of the
last refused bootstrap whose signature and request claims were verified against
that app's stored key. A successful bootstrap of the app clears it. Unknown
keys, unverified proofs, and proofs already used successfully are not recorded
because they do not prove a new start by that app. cld shows the rejection on
one line when set.
Core adds auth.app_start_rejections on migration, keeping at most one row per
app; operators need no new configuration.
Revocation is idempotent. Retrying enrollment of a revoked or expired public
key returns its original ID and never reactivates it. Generate a new pair to
replace it. Disabling the app's service account remains a separate app-wide
off switch. New enrollment into a disabled account returns HTTP 409; repeating
an existing enrollment reports accountStatus: "disabled" without enabling it.
The CLI refuses to publish a final artifact for an inactive key or disabled
account. Use a new output directory to replace a revoked or expired key.
Register a public key through the API
Core's endpoints require administrator authorization:
| Method and path | Request or response |
|---|---|
POST /api/admin/apps/:appId/keys |
{ publicKey, name?, expiresAt?, grants? } → { keyId, installationId, fingerprint, status, accountStatus } |
GET /api/admin/apps/:appId/keys |
{ installationId, items, lastRejectedStart, page, perPage, total, hasNext } with public-key metadata |
DELETE /api/admin/apps/:appId/keys/:keyId |
Revoke that app's exact key; repeated revocation succeeds |
GET /api/admin/apps/keys |
{ installationId, items, page, perPage, total, hasNext }; each item has appId, accountStatus, totalKeys, activeKeys |
lastRejectedStart is null when no verified refusal is recorded, or
{ at: "2030-01-02T03:04:05.000Z", reason: "revoked", keyId: "<uuid>" }.
at is an ISO 8601 timestamp; keyId is a UUID or null. The field belongs
to the app and is returned on every page of its key list.
Both GET endpoints accept page and perPage with the same limits as cld.
The overview includes apps with revoked keys, even when the app is not running.
activeKeys counts unexpired, unrevoked keys; account disablement is separate.
publicKey is exactly 32 raw Ed25519 bytes encoded as canonical, unpadded
base64url. Malformed, noncanonical, off-curve, and small-order points are
rejected. name contains 1–120 characters. expiresAt, when supplied on a new
enrollment, is a future ISO 8601 timestamp. A third active key returns HTTP
409 with an instruction to revoke an old key. Registration is idempotent per
(appId, publicKey); a retry preserves the original grants, name, and expiry.
Creation and revocation produce administrator audit events. Responses use
Cache-Control: no-store. No endpoint accepts or returns a private seed.
installationId is a UUID created once in a Core database transaction. It
survives a database restore. Preserve the database when restoring an
installation; a newly initialized database has a different identity.
Create a key with OpenSSL
Use OpenSSL with Ed25519 support, jq, and a shell. Store your administrator
request authorization in a mode 0600 curl config file named admin.curl,
using curl's header = "Authorization: Bearer <credential>" syntax. The file
keeps that credential out of command arguments.
Work in a private directory. Disable shell tracing before handling the seed:
set +x
set -eu
umask 077
openssl genpkey -algorithm ed25519 -out app.pem
openssl pkey -in app.pem -outform DER -out app.der
openssl pkey -in app.pem -pubout -outform DER -out app-public.der
test "$(wc -c < app.der)" -eq 48
test "$(wc -c < app-public.der)" -eq 44
seed="$(tail -c 32 app.der | openssl base64 -A | tr '+/' '-_' | tr -d '=')"
public_key="$(tail -c 32 app-public.der | openssl base64 -A | tr '+/' '-_' | tr -d '=')"
jq -n --arg publicKey "$public_key" '{publicKey: $publicKey, name: "Deployment"}' > public-key.json
curl --fail-with-body --config admin.curl \
--json @public-key.json \
https://cloud.example.com/api/admin/apps/inventory/keys > registration.jsonThe private DER is the 16-byte Ed25519 PKCS#8 prefix plus the 32-byte seed;
the public DER is the 12-byte SubjectPublicKeyInfo prefix plus the 32-byte
public key. The size checks above guard the extraction layout. Keep app.pem
and app.der private. If the request fails or its response is lost, repeat the
curl command with the same public-key file; do not generate another pair.
After a successful response, assemble the private key using shell built-ins:
jq -e '.status == "active" and .accountStatus == "active"' registration.json > /dev/null || exit 1
installation_id="$(jq -er '.installationId' registration.json)"
key_id="$(jq -er '.keyId' registration.json)"
printf 'cloud-app-key-v1.%s.%s.%s.%s\n' \
"$installation_id" inventory "$key_id" "$seed" > inventory.key
unset seedinventory.key is the final secret artifact. The public key and registration
response are not private key material. Protect the administrator curl config
and private PEM/DER files as well.
Rotate or recover a key
For rotation, create a second key in a new output directory, distribute it to
the application deployment, then revoke the old key. At most two keys can be
active during this overlap. An issuer replacement needs --grant oauth:issue
again; grants are never inherited. Keep the existing runtime configuration
until its key-consuming startup protocol is available.
For a lost key, create a new pair and revoke the lost key. Core cannot recover the private seed; losing it does not delete application data.
For a stolen key, revoke it and rotate any credentials already delivered to
the application: Postgres, Valkey, NATS, and APP_SECRET. Revoking an app key
does not close existing infrastructure connections or recover copied data.
Follow the affected service's credential-rotation procedure; changing
APP_SECRET also needs its data-encryption migration procedure.
Use the portable key-format helpers
@k2b/cloud/app-key has no server imports and works in Bun and browsers with
WebCrypto Ed25519. Browser generation requires a secure context.
generateAppKeyPair()returns{ seed, publicKey }through WebCrypto JWK export. When the engine refuses a generation with anOperationError, as WebKit on Linux does about once in a hundred, it draws a new pair, up to three attempts.deriveAppPublicKey(seed)derives the public key from a 32-byte seed.importAppKeySeed(seed)andimportAppPublicKey(publicKey)return WebCrypto keys for signing or verification.formatAppKey({ installationId, appId, keyId, seed })andparseAppKey(value)validate the private string; IDs are canonical lowercase UUIDs.validateAppKeyAppId(appId)enforces the ID syntax and reserved IDs.appKeyFingerprint(publicKey)returns the first 16 bytes of SHA-256 of the raw public key as 32 lowercase hexadecimal characters.
Private strings have the form
cloud-app-key-v1.<installationId>.<appId>.<keyId>.<base64url seed>.
The seed is exactly 32 bytes, encoded without padding. Parsing rejects
whitespace, noncanonical base64url, extra segments, and unknown versions.
import { formatAppKey, parseAppKey } from "@k2b/cloud/app-key";
export function finishEnrollment(
seed: string,
appId: string,
registration: { installationId: string; keyId: string; status: string; accountStatus: string },
) {
if (registration.status !== "active" || registration.accountStatus !== "active") throw new Error("Enrollment is inactive");
const value = formatAppKey({ ...registration, appId, seed });
return { value, identity: parseAppKey(value) };
}Keep the locally generated pair until registration succeeds. Retrying with the
same public key is what makes lost responses safe. Core-owned consumers read
getInstallationId, getActiveAppKey, and touchAppKey from
@k2b/cloud/services/app-keys; the active-key read goes directly to Postgres
and checks account status, revocation, and expiry without a cache.
Key metadata keeps its stored lifecycle status and reports usable separately.
A key is usable only while it and its account are active and the runtime accepts
its public key. Restored public development fixtures therefore report
usable: false outside development and are excluded from the app overview’s
activeKeys count.