See it in action on the hev-shop demo store.

API

API keys

Layer mints its own API keys. What a key opens is declared per resource: each entitlement names a VectorStore, a Warehouse, or Layer itself, and carries the scopes and claims for that target. The ApiKey CRD page covers the resource model; this page is the REST surface.

You don’t have to mint anything. The default VectorStore credential (the store key you already own) is accepted as an admin bearer. Minting starts from it: admin scope is what calls these routes.

Key model

  • Tokens look like hvl_iqGFsDD2PNkyhCqr59jjvKuKL47vqXMz. The raw token is returned once, in the mint response. Layer stores only one-way hashes; a lost token is revoked and re-minted, never recovered.
  • Every key is an ApiKey resource in the cluster — kubectl get apikeys is the audit trail, and kubectl apply is an equal authoring surface (the operator mints and delivers the token via Secret).
  • Revoking keeps the record; deleting removes it. Propagation takes seconds, not milliseconds.

Routes

RouteMethodAuthBehavior
/v2/keysPOSTadminMint a key. The only response that contains the raw token.
/v2/keysGETadminList keys — metadata, never material. ?includeRevoked adds revoked and expired keys.
/v2/keys/{keyId}GETadminOne key’s metadata.
/v2/keys/{keyId}/revokePOSTadminRevoke. Idempotent; the record stays.
/v2/keys/{keyId}DELETEadminHard delete.
/v2/keys/authenticatePOSTnoneExchange a raw token for identity and entitlements.

Admin here means a key with the layer entitlement at admin scope, or the bootstrap gateway key. authenticate is unauthenticated by construction — the token is the credential.

Mint

key = await client.mint_key({
    "name": "cohort-reader",
    "owner": "acme",
    "entitlements": {
        "vectorstore.prod-turbopuffer": {
            "scopes": ["read"],
            "namespaces": ["cohort-*"],
        },
        "warehouse.prod-snowflake": {
            "claims": ["notes:cohort:*:read"],
        },
    },
    "expiresAfter": "365d",
})
print(key.token)  # shown once, never again
key, err := client.MintKey(ctx, &hevlayer.MintKeyRequest{
	Name:  "cohort-reader",
	Owner: "acme",
	Entitlements: hevlayer.ApiKeyEntitlements{
		"vectorstore.prod-turbopuffer": {
			Scopes:     []string{"read"},
			Namespaces: []string{"cohort-*"},
		},
		"warehouse.prod-snowflake": {
			Claims: []string{"notes:cohort:*:read"},
		},
	},
	ExpiresAfter: "365d",
})
fmt.Println(key.Token) // shown once, never again
const key = await client.mintKey({
  name: "cohort-reader",
  owner: "acme",
  entitlements: {
    "vectorstore.prod-turbopuffer": {
      scopes: ["read"],
      namespaces: ["cohort-*"],
    },
    "warehouse.prod-snowflake": {
      claims: ["notes:cohort:*:read"],
    },
  },
  expiresAfter: "365d",
});
console.log(key.token); // shown once, never again
curl -X POST "$LAYER_GATEWAY_URL/v2/keys" \
  -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "cohort-reader",
    "owner": "acme",
    "entitlements": {
      "vectorstore.prod-turbopuffer": {"scopes": ["read"], "namespaces": ["cohort-*"]},
      "warehouse.prod-snowflake": {"claims": ["notes:cohort:*:read"]}
    },
    "expiresAfter": "365d"
  }'

The response is 201 Created:

{
  "keyId": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
  "name": "cohort-reader",
  "owner": "acme",
  "entitlements": {
    "vectorstore.prod-turbopuffer": {"scopes": ["read"], "namespaces": ["cohort-*"]},
    "warehouse.prod-snowflake": {"claims": ["notes:cohort:*:read"]}
  },
  "phase": "Active",
  "createdAt": "2026-06-10T00:00:00Z",
  "expiresAt": "2027-06-10T00:00:00Z",
  "token": "hvl_iqGFsDD2PNkyhCqr59jjvKuKL47vqXMz"
}
FieldRequiredBehavior
nameyesUnique per install. Becomes the ApiKey resource name.
ownernoFree-form owner label, echoed in list and authenticate responses.
descriptionnoFree text, shown in listings.
entitlementsnoMap keyed by target — vectorstore.<name>, warehouse.<name>, or layer. Unknown prefixes are 400s. See the entitlement model.
expiresAfternoDuration or never. Defaults to 365d; expiresAt is computed at mint.

Validation failures are 400s with field-level messages; nothing is created on a failed mint. An entitlement whose target does not exist is accepted — it grants nothing and surfaces as an EntitlementTargetMissing condition on the resource, so keys and targets can be applied in either order.

Mint an admin key the same way — "entitlements": {"layer": {"scopes": ["admin"]}}. An admin key can mint further keys; key custody is the only hierarchy.

List and get

keys = await client.list_keys()
key = await client.get_key("0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9")
keys, err := client.ListKeys(ctx, nil)
key, err := client.GetKey(ctx, "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9")
const keys = await client.listKeys();
const key = await client.getKey("0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9");
curl "$LAYER_GATEWAY_URL/v2/keys" \
  -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY"
{
  "keys": [
    {
      "keyId": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
      "name": "cohort-reader",
      "owner": "acme",
      "entitlements": {
        "vectorstore.prod-turbopuffer": {"scopes": ["read"], "namespaces": ["cohort-*"]},
        "warehouse.prod-snowflake": {"claims": ["notes:cohort:*:read"]}
      },
      "phase": "Active",
      "createdAt": "2026-06-10T00:00:00Z",
      "expiresAt": "2027-06-10T00:00:00Z",
      "lastSeenAt": "2026-06-10T12:00:00Z"
    }
  ]
}

Listings carry metadata only — never tokens or recoverable hashes. lastSeenAt advances at most once per five minutes per key.

Authenticate

External systems present a raw token and get back keyId — a stable actor id — plus the full entitlements map, then make their own authorization decisions from the claims. This is the verb that makes Layer a key store for applications that keep authorization to themselves: Layer never interprets a claim string.

identity = await client.authenticate_key({"token": presented})
claims = identity.entitlements["warehouse.prod-snowflake"].claims
identity, err := client.AuthenticateKey(ctx, &hevlayer.AuthenticateKeyRequest{
	Token: presented,
})
claims := identity.Entitlements["warehouse.prod-snowflake"].Claims
const identity = await client.authenticateKey({ token: presented });
const claims = identity.entitlements["warehouse.prod-snowflake"].claims;
curl -X POST "$LAYER_GATEWAY_URL/v2/keys/authenticate" \
  -H "Content-Type: application/json" \
  -d '{"token": "hvl_iqGFsDD2PNkyhCqr59jjvKuKL47vqXMz"}'

200 returns {keyId, name, owner, entitlements, expiresAt}. Invalid, revoked, and expired tokens all answer 401 indistinguishably, in constant time on a miss. The route is rate-limited.

Revoke and delete

await client.revoke_key("0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9")
await client.delete_key("0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9")
_, err := client.RevokeKey(ctx, "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9")
_, err = client.DeleteKey(ctx, "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9")
await client.revokeKey("0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9");
await client.deleteKey("0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9");
curl -X POST "$LAYER_GATEWAY_URL/v2/keys/$KEY_ID/revoke" \
  -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY"

The gateway stops accepting a revoked key within seconds. Rotation is mint-new, deploy, revoke-old — there is no in-place rotation. Revoked keys stay in ?includeRevoked listings for audit; delete when the record itself should go.

Using a minted key

A minted key works anywhere its entitlements reach. A vectorstore.<name> entitlement opens data-plane routes against that store, inside its namespace globs:

curl "$LAYER_GATEWAY_URL/v2/namespaces/cohort-7/query" \
  -X POST \
  -H "Authorization: Bearer hvl_iqGFsDD2PNkyhCqr59jjvKuKL47vqXMz" \
  -H "Content-Type: application/json" \
  -d '{"rank_by": ["text", "BM25", "acme"], "top_k": 10}'

Outside the grant, the gateway answers 403:

{"error": "namespace not in key grant", "namespace": "orders"}

A request that needs a higher scope answers 403 with the scope named:

{"error": "insufficient API key scope", "required_scope": "admin"}

A key whose entitlements carry only claims — no scopes — is a pure external-store key: it authenticates, but opens no Layer route.

Unlike the store key, a minted key is gateway-only — it cannot be used against the upstream store directly, so the SDK clients’ direct fall-through path is unavailable to it and fails fast.

kubectl

The CRD is the other authoring surface — apply an ApiKey with no credential and the operator mints the token into a Secret named by status.secretRef:

kubectl apply -f key.yaml
kubectl get apikeys -n layer
kubectl get secret apikey-cohort-reader -n layer -o jsonpath='{.data.token}' | base64 -d

Both surfaces round-trip through one schema — kubectl get apikey -o yaml and GET /v2/keys/{keyId} are two spellings of the same object. The ApiKey CRD page has the full resource model and the Kubernetes RBAC the chart ships for it.

CLI

The same operations from the CLI:

layer keys mint cohort-reader --owner acme \
  --entitle vectorstore.prod-turbopuffer=read \
  --namespaces "cohort-*" \
  --claim warehouse.prod-snowflake="notes:cohort:*:read"
layer keys ls
layer keys revoke cohort-reader

layer keys mint prints the token once, alone on stdout for piping; the metadata table goes to stderr.

esc