Authentication
API keys and dashboard sessions — what each one can do, and the one thing only a session can.
Two credentials reach the same API. An API key is what your server uses. A
dashboard session is a Cognito ID token, held by the browser app at
https://app.gitloom.cloud. Both are presented the same way:
Authorization: Bearer <credential>
Authorization happens at the gateway, in a Lambda authorizer, before any handler runs.
It accepts either credential and resolves both to exactly one account. A missing
credential is 401. One it refuses — malformed, expired, revoked, another scheme
such as Basic, or an account that is suspended — is 403, with no detail about
which: telling you which half of a guess was right is a gift to whoever is guessing.
The gateway answers both itself, so the body is {"message":"Unauthorized"} or
{"message":"Forbidden"}, not the error envelope; the SDKs report both as code
unauthorized.
GET /health is the only unauthenticated route.
Which credential am I holding?
curl -s "$GITLOOM_API/v1/whoami" \
-H "Authorization: Bearer $GITLOOM_API_KEY"
{"account":"acme","auth":"api_key","env":"live"}
| field | values |
|---|---|
account |
your account id — the owner of every namespace you can reach |
auth |
api_key or jwt |
env |
live or test for a key; empty for a dashboard session |
API keys
A key looks like this:
gl_live_3f9a2b7c1d4e6f80_kQ8vN2xR7mZpLd3Wc0Ty5Ub9Aj1Hs4Ge
└┬─┘└─┬┘ └──────┬───────┘ └──────────────┬──────────────┘
│ │ │ └─ secret: 24 random bytes, base64url
│ │ └─ key id: 16 hex chars, appears in logs, used to revoke
│ └─ environment: live | test
└─ prefix, so secret scanners can match it
Only the SHA-256 of the secret is stored. The full key exists exactly once, in the
201 response that created it. There is no endpoint that returns it again, and no
operator can recover it. Lost key: revoke it, mint another.
Creating a key
A dashboard session can create any key. An API key can create only a key confined to one namespace, which is how a server issues a key for one of its users without anyone opening the dashboard:
| caller | asks for | result |
|---|---|---|
| dashboard session | an account-wide key, or one with namespace |
201 |
| account-wide API key | a key with namespace |
201 |
| account-wide API key | an account-wide key | 403 dashboard_only |
| namespace-scoped API key | any key | 403 scoped_key |
So a leaked account-wide key can mint scoped keys: any number, for any namespace.
A key does not record which key created it, so those scoped keys keep working after
the leaked key is revoked. After revoking a leaked key, list the account's keys in
the dashboard and revoke every scoped key created since the leak; created_at and
namespace identify them.
With a dashboard token:
curl -s -X POST "$GITLOOM_API/v1/keys" \
-H "Authorization: Bearer $ID_TOKEN" \
-H "content-type: application/json" \
-d '{"name":"production worker","env":"live"}'
{
"id": "3f9a2b7c1d4e6f80",
"env": "live",
"name": "production worker",
"key": "gl_live_3f9a2b7c1d4e6f80_kQ8vN2xR7mZpLd3Wc0Ty5Ub9Aj1Hs4Ge",
"namespace": "",
"note": "store this now; it cannot be retrieved again"
}
With an account-wide API key, for one user's namespace:
curl -s -X POST "$GITLOOM_API/v1/keys" \
-H "Authorization: Bearer $GITLOOM_API_KEY" \
-H "content-type: application/json" \
-d '{"name":"user-8213 desktop app","namespace":"user-8213"}'
The response is the same shape, with "namespace": "user-8213". env defaults to
live when omitted. Anything other than live or test is 400 invalid_env, and
a namespace that is not a valid namespace name is 400 invalid_namespace.
Listing and revoking
Both take a dashboard session; an API key gets 403 dashboard_only.
curl -s "$GITLOOM_API/v1/keys" -H "Authorization: Bearer $ID_TOKEN"
{"keys":[{"id":"3f9a2b7c1d4e6f80","env":"live","name":"production worker","revoked":false,"created_at":"2026-07-31T09:14:02Z"},
{"id":"9c4e1a07b2d35f68","env":"live","name":"user-8213 desktop app","namespace":"user-8213","revoked":false,"created_at":"2026-08-02T11:40:19Z"}]}
Revoke by key id — the middle segment, never the secret:
curl -s -X DELETE "$GITLOOM_API/v1/keys/3f9a2b7c1d4e6f80" \
-H "Authorization: Bearer $ID_TOKEN"
{"id":"3f9a2b7c1d4e6f80","revoked":true}
An unknown id is 404 not_found.
Everything except listing and revoking keys and creating account-wide ones: create and list namespaces, write memories, retrieve, read usage. What it reaches depends on how it was created:
- An account-wide key reads and writes every namespace in the account. It belongs on a server: in a browser bundle, a mobile app or a public repo, it is every one of your users' memories.
- A namespace-scoped key reads and writes only its namespace. A request that
leaves
namespaceout means that one; one that names another is refused, with403 forbidden_namespaceon a read and400 invalid_namespaceon a write.
With account-wide keys, per-user isolation is a property of which namespace your server asks for; a scoped key makes it a property of the credential as well. See Namespaces and multi-user patterns.
live and test are not a data boundary
The environment is in the prefix and reported by whoami, and that is all it does
today. A gl_test_ key reads and writes exactly the same namespaces as a gl_live_
key on the same account. It is useful for spotting a production credential pasted into
a test config, and for secret scanners. It is not a sandbox. If you need one, use a
separate namespace — that is a hard boundary — or a separate account.
Dashboard sessions
The dashboard signs in through the Cognito hosted UI and calls the same API with the
resulting ID token (not the access token). The account comes from the
custom:tenant_id claim, which a Pre-Token-Generation trigger injects from DynamoDB
at sign-in — the user cannot set it, and the app client's writable attributes are
restricted so a signup cannot claim someone else's tenant.
Tokens expire, and the gateway refuses an expired one with a 403. In the dashboard
that means refresh the session, not that anything is wrong with the account.
Browsers and CORS
A browser can call the API only from the dashboard's own origins. The API's CORS
policy allows those origins GET, POST, PATCH, DELETE and OPTIONS, with the
authorization and content-type headers. From any other web origin the browser
refuses the call at the preflight, whatever the method, so call GitLoom from your
server.
The advice would be the same without that limit: an account-wide key belongs on a server, and your backend should decide what a browser may ask for.
Handling failures
| status | code | what to do |
|---|---|---|
401 |
none; the SDKs say unauthorized |
no credential arrived: set the Authorization header |
403 |
none; the SDKs say unauthorized |
the credential was refused: check the key is right, unrevoked, and not a stale token |
403 |
dashboard_only |
an API key listed or revoked keys, or asked for an account-wide one; sign in instead |
403 |
scoped_key |
a namespace-scoped key tried to create a key; use an account-wide key or the dashboard |
404 |
namespace_not_found |
create the namespace before reading or writing |
429 |
quota_exceeded or rate_limited |
see Plans and pricing and Limits. A monthly limit will not clear in a second — a rate limit will, within a minute |
5xx |
internal |
retry with exponential backoff and jitter |
Every error a handler returns uses the same envelope. The gateway's 401 and 403
above are the exception, with only {"message": ...}, because no handler has run:
{"error":{"code":"namespace_not_found","message":"create namespace \"user-8213\" before writing to it"}}
Branch on code. message is prose and may be reworded.