Issue Scoped API Keys
Create API keys with immutable permission ceilings for agents, CI jobs, and integrations that should not hold your full authority.
Issue Scoped API Keys
By default, an API key inherits the live authority of the principal it authenticates as. Whatever you can do, the key can do, including things you are granted permission to do next month. That is convenient for your own laptop and a poor deal for an agent, a CI job, or a vendor integration that only needs to search one space.
A scoped key carries a ceiling: a list of rules fixed at issuance that the key can never exceed. Every request through a scoped key must pass two independent checks — the subject's live authority at request time, and the ceiling. If the key leaks, the damage is bounded by the ceiling regardless of what its subject is allowed to do. The concepts behind this are covered in API Keys and Ceilings.
This guide issues two scoped keys against a real space and shows what the server does when you ask for a ceiling you are not entitled to grant. Every step is shown for the CLI and for the REST API.
Before You Start
- A running GoodMem server and a human credential with authority over the space: the
goodmemCLI authenticated, or an API key for REST. - Export the values the REST examples use:
export GOODMEM_REST_URL="https://localhost:8080" # REST base URL; on GoodMem Cloud, your instance's https:// hostname
export GOODMEM_API_KEY="gm_your_key"The CLI speaks gRPC, which GoodMem Cloud instances do not expose. On Cloud, use the cURL or HTTPie tab — every step here is a plain REST call over HTTPS — or the console. If your own server uses a self-signed certificate, add -k to curl and --verify=no to HTTPie.
- A space with at least one memory in it. Capture its UUID:
export SPACE_ID="$(goodmem space list --format json | jq -r '.spaces[0].spaceID')"export SPACE_ID="$(curl -sS "$GOODMEM_REST_URL/v1/spaces?maxResults=1" \
--header "x-api-key: $GOODMEM_API_KEY" | jq -r '.spaces[0].spaceId')"export SPACE_ID="$(http GET "$GOODMEM_REST_URL/v1/spaces" maxResults==1 \
x-api-key:"$GOODMEM_API_KEY" | jq -r '.spaces[0].spaceId')"1. The Ceiling Rule Grammar
On the CLI, a ceiling rule names one operation and one selector, colon-delimited:
OPERATION:SELECTOR[:RESOURCE_KIND:RESOURCE_UUID]ANYandOWNselectors take no resource segments:LIST_SPACE:ANY.EXACTandDIRECT_MEMBERS_OFrequire a resource kind and UUID:READ_SPACE:EXACT:SPACE:1cf3.... The one exception isINSTANCE, which is a singleton and takes no UUID.- Tokens accept upper or lower case and hyphens or underscores;
direct-members-ofandDIRECT_MEMBERS_OFparse the same way.
Over REST, the same rule is a JSON object. ANY and OWN rules omit assignedResource; the INSTANCE singleton is { "kind": "INSTANCE" } with no resourceId:
{
"operation": "READ_SPACE",
"selector": "EXACT",
"assignedResource": { "kind": "SPACE", "resourceId": "1cf3..." }
}The full operation catalog is in the Operations and Selectors reference. --ceiling is repeatable, and --authority-mode scoped requires at least one rule; over REST, "authorityMode": "SCOPED" requires a nonempty ceiling array of at most 1,000 rules.
2. Issue a Retrieval-Only Key
Retrieval over a space checks two gates: LIST_MEMORY on the space itself, and READ_MEMORY on the memories the space directly contains. A ceiling that includes one without the other produces a key that fails every retrieval, so include both:
goodmem apikey create \
--authority-mode scoped \
--ceiling LIST_MEMORY:EXACT:SPACE:$SPACE_ID \
--ceiling READ_MEMORY:DIRECT_MEMBERS_OF:SPACE:$SPACE_ID \
--label purpose=support-botcurl -sS --json @- "$GOODMEM_REST_URL/v1/apikeys" \
--header "x-api-key: $GOODMEM_API_KEY" <<JSON
{
"authorityMode": "SCOPED",
"ceiling": [
{ "operation": "LIST_MEMORY", "selector": "EXACT",
"assignedResource": { "kind": "SPACE", "resourceId": "$SPACE_ID" } },
{ "operation": "READ_MEMORY", "selector": "DIRECT_MEMBERS_OF",
"assignedResource": { "kind": "SPACE", "resourceId": "$SPACE_ID" } }
],
"labels": { "purpose": "support-bot" }
}
JSONhttp POST "$GOODMEM_REST_URL/v1/apikeys" x-api-key:"$GOODMEM_API_KEY" <<JSON
{
"authorityMode": "SCOPED",
"ceiling": [
{ "operation": "LIST_MEMORY", "selector": "EXACT",
"assignedResource": { "kind": "SPACE", "resourceId": "$SPACE_ID" } },
{ "operation": "READ_MEMORY", "selector": "DIRECT_MEMBERS_OF",
"assignedResource": { "kind": "SPACE", "resourceId": "$SPACE_ID" } }
],
"labels": { "purpose": "support-bot" }
}
JSONThe REST response is { "apiKeyMetadata": { ... }, "rawApiKey": "gm_..." }; the CLI prints the same two things.
The raw key (gm_...) appears once, in this response. The server stores only a verifier and cannot show the key again.
The DIRECT_MEMBERS_OF rule covers every memory the space contains now or later, and nothing else — not the space's configuration, not a sibling space, not the embedders the space references.
No EXECUTE_EMBEDDER rule is needed. Embedder authority was checked once, when the space was created; retrieval through an existing space runs its configured embedders on the space's behalf.
3. Test the Boundary
Confirm the key retrieves from the intended space, authenticating as the new key rather than as yourself:
GOODMEM_API_KEY="gm_..." goodmem memory retrieve \
--space-id $SPACE_ID "escalation procedure"curl -sS --json @- "$GOODMEM_REST_URL/v1/memories:retrieve" \
--header "x-api-key: gm_..." <<JSON
{ "message": "escalation procedure", "spaceKeys": [ { "spaceId": "$SPACE_ID" } ] }
JSONhttp POST "$GOODMEM_REST_URL/v1/memories:retrieve" x-api-key:"gm_..." \
message="escalation procedure" spaceKeys:="[{\"spaceId\": \"$SPACE_ID\"}]"Over REST the retrieval response is a stream of newline-delimited JSON events; any retrievedItem event means the key got through both gates.
Then confirm it does nothing else. Listing spaces requires LIST_SPACE, which is not in the ceiling:
GOODMEM_API_KEY="gm_..." goodmem space listcurl -sS "$GOODMEM_REST_URL/v1/spaces" --header "x-api-key: gm_..."http GET "$GOODMEM_REST_URL/v1/spaces" x-api-key:"gm_..."This fails with PERMISSION_DENIED (HTTP 403) even though you — the subject — can list spaces fine. The ceiling is doing its job.
For a quick sweep of what the key can and cannot do, the authorization check endpoint evaluates the same questions without touching any data:
GOODMEM_API_KEY="gm_..." goodmem access-policy check \
--check LIST_MEMORY:SPACE:$SPACE_ID \
--check LIST_SPACE:INSTANCE \
--format quietcurl -sS --json @- "$GOODMEM_REST_URL/v1/access-policy:check" \
--header "x-api-key: gm_..." <<JSON
{
"checks": [
{ "operation": "LIST_MEMORY", "target": { "kind": "SPACE", "resourceId": "$SPACE_ID" } },
{ "operation": "LIST_SPACE", "target": { "kind": "INSTANCE" } }
]
}
JSONhttp POST "$GOODMEM_REST_URL/v1/access-policy:check" x-api-key:"gm_..." <<JSON
{
"checks": [
{ "operation": "LIST_MEMORY", "target": { "kind": "SPACE", "resourceId": "$SPACE_ID" } },
{ "operation": "LIST_SPACE", "target": { "kind": "INSTANCE" } }
]
}
JSONThe CLI prints true then false; REST returns { "results": [ { "allowed": true }, { "allowed": false } ] } in request order. A check names an operation and a concrete target, never a selector — see Operations and Selectors — and checks are advisory; the retrieval above is the proof.
4. Issue an Ingestion Key
An ingestion pipeline needs to add memories to a space and nothing more. CREATE_MEMORY targets the space:
goodmem apikey create \
--authority-mode scoped \
--ceiling CREATE_MEMORY:EXACT:SPACE:$SPACE_ID \
--label purpose=nightly-ingestcurl -sS --json @- "$GOODMEM_REST_URL/v1/apikeys" \
--header "x-api-key: $GOODMEM_API_KEY" <<JSON
{
"authorityMode": "SCOPED",
"ceiling": [
{ "operation": "CREATE_MEMORY", "selector": "EXACT",
"assignedResource": { "kind": "SPACE", "resourceId": "$SPACE_ID" } }
],
"labels": { "purpose": "nightly-ingest" }
}
JSONhttp POST "$GOODMEM_REST_URL/v1/apikeys" x-api-key:"$GOODMEM_API_KEY" <<JSON
{
"authorityMode": "SCOPED",
"ceiling": [
{ "operation": "CREATE_MEMORY", "selector": "EXACT",
"assignedResource": { "kind": "SPACE", "resourceId": "$SPACE_ID" } }
],
"labels": { "purpose": "nightly-ingest" }
}
JSONThis key can write to the space but cannot read, search, or delete anything in it. A pipeline compromise leaks the ability to insert documents, which is recoverable, rather than the ability to read them, which is not.
5. Add a Validity Window
Both bounds are immutable after issuance. The key works from valid-from (inclusive, defaults to the moment of issuance) until expires-at (exclusive, defaults to never). The CLI takes RFC3339 timestamps; REST takes epoch milliseconds — date -u -d 2026-12-01T00:00:00Z +%s000 prints 1796083200000:
goodmem apikey create \
--authority-mode scoped \
--ceiling CREATE_MEMORY:EXACT:SPACE:$SPACE_ID \
--expires-at 2026-12-01T00:00:00Z \
--label purpose=q4-migrationcurl -sS --json @- "$GOODMEM_REST_URL/v1/apikeys" \
--header "x-api-key: $GOODMEM_API_KEY" <<JSON
{
"authorityMode": "SCOPED",
"ceiling": [
{ "operation": "CREATE_MEMORY", "selector": "EXACT",
"assignedResource": { "kind": "SPACE", "resourceId": "$SPACE_ID" } }
],
"expiresAt": 1796083200000,
"labels": { "purpose": "q4-migration" }
}
JSONhttp POST "$GOODMEM_REST_URL/v1/apikeys" x-api-key:"$GOODMEM_API_KEY" <<JSON
{
"authorityMode": "SCOPED",
"ceiling": [
{ "operation": "CREATE_MEMORY", "selector": "EXACT",
"assignedResource": { "kind": "SPACE", "resourceId": "$SPACE_ID" } }
],
"expiresAt": 1796083200000,
"labels": { "purpose": "q4-migration" }
}
JSONAn expiring key turns rotation from a policy into a deadline. Since neither bound can be edited, extending access means issuing a replacement key. validFrom works the same way for a key that should start working later.
6. When Issuance Is Refused
At issuance, every ceiling rule must fit within two things: what the subject is currently allowed to do, and what your issuing request is currently allowed to do. A ceiling can attenuate authority; it cannot manufacture it.
- If your issuing credential does not cover a rule — say you hold a scoped key yourself and ask for a ceiling beyond it — the server returns
PERMISSION_DENIED(HTTP 403):Issuing credential does not cover every requested ceiling rule. - If the subject's live authority does not cover a rule, the server returns
FAILED_PRECONDITION(HTTP 412):Subject authority does not cover every requested ceiling rule.
There is no way to widen an existing key. If the ceiling turns out too narrow, issue a new key with the right rules and revoke the old one.
7. Keys for Other Subjects
--subject on the CLI, or subjectPrincipalId in the REST body, sets which principal the key authenticates as. Human keys are self-issued, so getting another person their first key goes through enrollment. The subject field exists for service identities: workload keys must be scoped, and issuing one requires MANAGE_ACCESS on that service identity. The full production pattern — durable identity, scoped keys, rotation that survives staff turnover — is in Set Up a Service Identity, and the per-agent version of it is in Isolate Agents on a Shared Instance.
8. Revoke When Done
goodmem apikey delete 123e4567-e89b-12d3-a456-426614174000curl -sS -X DELETE "$GOODMEM_REST_URL/v1/apikeys/123e4567-e89b-12d3-a456-426614174000" \
--header "x-api-key: $GOODMEM_API_KEY"http DELETE "$GOODMEM_REST_URL/v1/apikeys/123e4567-e89b-12d3-a456-426614174000" \
x-api-key:"$GOODMEM_API_KEY"Revocation is permanent and takes effect immediately. The row is retained for audit, so the key still appears in goodmem apikey list and GET /v1/apikeys as revoked; it will never authenticate again. If a workload still needs access, issue its replacement key before revoking the old one.
See Also
- API Keys and Ceilings — how the two-sided check works and why service keys must be scoped.
- Roles, Grants, and Selectors — where the subject's live authority comes from.
- Share a Space — granting people access, rather than credentials.
- Isolate Agents on a Shared Instance — one ceiling-locked key per agent.
- Console: Access Control — issuing scoped keys from the browser.
- REST reference: create API key, check authorizations, delete API key.