---
title: "Keys & security"
description: "CanaryLytics projects have two kinds of API key. A secret live key (cly_live_…) has full access; an embeddable read-only key (cly_read_…) can only read stats."
last_updated: "2026-07-30"
---

# Keys & security

A CanaryLytics project has exactly one **live key** (`cly_live_…`) with full access, and optionally one **read-only key** (`cly_read_…`) that can read stats but never write or change anything. The live key stays secret; the read-only key is the one that can appear in public HTML.

## Live key vs read-only key

| Capability | `cly_live_<32 hex>` | `cly_read_<32 hex>` |
|---|---|---|
| Per project | exactly one | zero or one |
| Dashboard login | yes | yes (read-only mode) |
| `GET /v1/*` reads | yes | yes |
| Widget | yes | yes |
| `POST /v1/events` | yes | **403 `read_only_key`** |
| Dashboard settings mutations | yes | **forbidden** |

<Aside type="danger" title="Never ship the live key">
  Anywhere a page's source can be viewed (widget embeds, admin panels, browser bundles) use the read-only key, never the live one. The live key can rotate the project's keys, add or remove trusted domains, and write events. The read key can only read.
</Aside>

## How keys are stored

Only the hex `HMAC-SHA256(key, KEY_HMAC_SECRET)` hash is persisted. The plaintext key is shown exactly once, at creation or rotation, and is never logged. Authenticating a request is a single indexed lookup: hash the Bearer token and match it against the live-key-hash and read-key-hash columns; whichever matched decides whether the request gets `"full"` or `"read"` access. A database dump alone is useless without the separate `KEY_HMAC_SECRET`.

Every query the API runs is hard-scoped `WHERE project_id = <the authenticated project>`. There is no cross-project read path, regardless of which key flavor authenticated the request.

## Rotation

From the dashboard's Settings page:

- **Rotate key** replaces the live key immediately. The old key stops resolving the moment the new hash is written, with no grace period. The new plaintext is shown once.
- **Create/replace read key** mints a `cly_read_…` key. Minting over an existing one revokes the old one, since a project holds at most one read key at a time.
- **Revoke read key** removes it entirely. Browser reads that were using it (the widget, SDK clients) stop working immediately; the project still operates on the live key alone.

## Key format

```
cly_live_5f3a9c2e1b7d4a80f6c3e9d2a1b4c7e0
cly_read_a1b2c3d4e5f60718293a4b5c6d7e8f90
```

Both are a fixed prefix (`cly_live_` or `cly_read_`) followed by 32 lowercase hex characters (16 random bytes). An `Authorization: Bearer …` header carrying anything else is rejected before it ever reaches a database lookup.

## Related

<CardGrid>
  <LinkCard title="Widget embed" description="Where the read-only key earns its keep." href="/docs/widget/" />
  <LinkCard title="REST API" description="How each key authenticates against /v1." href="/docs/api/rest/" />
</CardGrid>

## Sitemap

- [Docs sitemap](https://analytics.canarycoders.es/docs/sitemap.md)
- [Full documentation as a single file](https://analytics.canarycoders.es/llms-full.txt)
