GitLoom

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.

### What a key can do

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:

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.