# Auth model

> Secret keys, publishable keys, widget session tokens, and embed tokens — which one to use, when, and why.

Source: https://docs.hatched.live/docs/concepts/auth-model

Hatched exposes four token types. They exist because different parts of
your product have different trust boundaries, and mixing them up is the
single most common reason integrations get shipped with secret-key leaks.

## The four tokens

| Token                    | Prefix                         | Where it lives   | Can do                                                        |
| ------------------------ | ------------------------------ | ---------------- | ------------------------------------------------------------- |
| **Secret API key**       | `hatch_live_*`, `hatch_test_*` | Server (env var) | Everything. Full account access.                              |
| **Publishable key**      | `hatch_pk_*`                   | Browser (safe)   | Read buddies/operations, mint read-only embed tokens.         |
| **Widget session token** | JWT                            | Browser          | Scoped interactive actions (track, buy, equip) for one buddy. |
| **Embed token**          | JWT                            | Browser          | Read-only widget display for one buddy.                       |

## Session token vs embed token

These two are the easiest to mix up — they're both browser JWTs scoped to one
buddy, but they come from different endpoints and do different things:

| | Widget session token | Embed token |
| --- | --- | --- |
| Minted by | `POST /api/v1/widget-sessions` — `hatched.widgetSessions.create(...)` | `POST /api/v1/embed-tokens` — `hatched.embedTokens.create(...)` |
| Requires `scopes`? | **Yes** (`['read', 'events:track', ...]`) — sending none is rejected | **No** — the endpoint rejects a `scopes` field |
| Loader attribute | `data-session-token` | `data-embed-token` |
| Widget mode | `interactive` — can track events, purchase, equip | `read-only` — display only |
| Server-side state | Tracked (revocable via `widgetSessions.revoke`) | Stateless (validity = JWT signature + `exp`) |
| Default TTL | 1h (max 1h) | 24h (max 24h) |

Rule of thumb: if any widget on the page needs to *do* something (track an
event, buy or equip an item), mint a **session token**. If every mount is purely
display, an **embed token** is cheaper. Both require an existing `buddy_id` —
see [First user bootstrap](/docs/guides/first-user-bootstrap).

## Decision tree

```
Is this code running on the browser?
├── No (Node, edge, server component, route handler)
│     → Secret API key (HATCHED_API_KEY env var)
│
└── Yes
      ├── Do you need mutation (send event, earn coin)?
      │     → Call your own backend route with the secret key.
      │        Never put a secret key in the browser bundle.
      │
      ├── Do you need the user to interact with widgets?
      │     → Server mints a widget session token,
      │        browser loads widget.js with data-session-token.
      │
      ├── Do you only need to display widgets/read-only state?
      │     → Server mints an embed token (cheaper, stateless).
      │
      └── Do you need raw API reads from a SPA/static site?
            → Publishable key in the browser + @hatched/sdk-js with { publishableKey }.
```

## Secret API key

```ts
import { HatchedClient } from '@hatched/sdk-js';

const hatched = new HatchedClient({
  apiKey: process.env.HATCHED_API_KEY!,
});
```

**Rules.**

- Load from an env variable. **Never** hard-code.
- Rotate on any suspected leak. Rotation is instant — old key returns 401
  immediately.
- The SDK throws at construction if it detects a DOM environment. The
  only way to suppress is `allowBrowser: true`, intended exclusively for
  unit tests.

## Publishable key

```ts
const hatched = new HatchedClient({
  publishableKey: 'hatch_pk_xxxxxxxx',
});

const buddy = await hatched.buddies.get(buddyId);     // ✅ ok
await hatched.events.send({ ... });                    // ❌ PublishableKeyScopeError
```

**Rules.**

- Publishable keys are **scoped**: read-only endpoints (buddies,
  operations) plus `embedTokens.create`. Every mutation endpoint
  returns `403 publishable_key_scope`.
- Safe to commit to a browser bundle, include in `<meta>` tags, or expose
  as `NEXT_PUBLIC_*`.
- Per-key scope is configurable in Dashboard → Developers → API keys →
  **Create publishable key** → check the endpoints you want to allow.

## Widget session token

The flow for an interactive widget (buddy, marketplace, celebrate):

1. Browser asks your backend for a session.
2. Backend calls `hatched.widgetSessions.create(...)` with a secret key.
3. Backend returns `{ token, expiresAt }` to the browser.
4. Browser loads `widget.js` with `data-session-token` and mounts `<div data-hatched-mount="buddy">`.
5. The widget talks to Hatched directly, signed with the session token.

```ts
const session = await hatched.widgetSessions.create({
  buddyId: 'bdy_abc',
  userId: 'user_42',
  scopes: ['read', 'events:track', 'marketplace:browse', 'marketplace:purchase', 'items:equip'],
  ttlSeconds: 60 * 15,
});
```

**Rules.**

- Short-lived (minutes, not hours). Re-mint on focus or route change.
- Scoped to one `buddyId`. If you switch buddies, re-mint.
- Scoped to the exact list of widget scopes you pass. A session minted
  without `marketplace:purchase` cannot buy items even if the widget tries.

## Embed token

Read-only sibling of widget session tokens. Stateless and cheap to mint —
pass one per buddy/widget render on a page.

**This is the token that confuses people most often.** It is *not* something
you create once in the dashboard like an API key. It is a short-lived JWT
that your backend mints on demand — typically inside a route handler — and
hands to the browser for the page render.

### Why it exists

The widget runs in the user's browser. It needs *some* token to identify
which buddy to display, but you cannot put a secret API key in the browser
(any visitor could read it from devtools and call mutating endpoints with
your account's full authority). The embed token solves this: it is signed
by Hatched, scoped to one `(userId, buddyId)` pair, expires automatically,
and can only do read-only widget display: buddy, badges, streaks,
leaderboards, and marketplace catalog/state.

### How to mint one

```ts
// app/api/hatched/embed-token/route.ts  — Next.js
import { HatchedClient } from '@hatched/sdk-js';

const hatched = new HatchedClient({ apiKey: process.env.HATCHED_API_KEY! });

export async function POST(req: Request) {
  const { userId, buddyId } = await req.json();

  const embed = await hatched.embedTokens.create({
    userId,
    buddyId,
    ttlSeconds: 60 * 60, // 1h is a reasonable default
  });

  return Response.json({ token: embed.token, expiresAt: embed.expiresAt });
}
```

Or with raw HTTP if you do not use the SDK:

```http
POST /embed-tokens
Authorization: Bearer hatch_live_xxxxxxxxxxxx
Content-Type: application/json

{ "user_id": "user_42", "buddy_id": "bdy_abc", "ttl_seconds": 3600 }
```

Response:

```json
{ "token": "eyJhbGciOi…", "expires_at": "2026-05-05T16:00:00Z", "mode": "read-only" }
```

### How it reaches the browser

```html
<!-- The page fetches the token from your own backend route, then -->
<!-- inlines it into the widget loader before the script runs.    -->
<script
  src="https://cdn.hatched.live/widget.js"
  data-embed-token="eyJhbGciOi…"
  defer
></script>
<div data-hatched-mount="buddy"></div>
```

### Lifecycle

- **Stateless**: Hatched does not store embed tokens. Validity comes from
  the JWT signature and the `exp` claim — there is no revocation list.
- **TTL**: minimum 5 minutes, maximum 24 hours, default 24 hours. Pick the
  shortest TTL that fits your render cadence.
- **Re-mint, do not cache for long**: mint on each page request (or each
  SPA route change). The mint call is cheap.
- **Difference from a widget session token**: an embed token can only
  *display* — it cannot send events, equip items, or buy from the
  marketplace. For interactivity, use a widget session token instead.

## What lives where

| Layer                                                      | Token                               |
| ---------------------------------------------------------- | ----------------------------------- |
| `.env` / Vercel secrets / GitHub secrets                   | Secret API key                      |
| `NEXT_PUBLIC_HATCHED_PK` / HTML `<meta>`                   | Publishable key                     |
| Request to your `/api/hatched/session` endpoint            | — returns widget session token      |
| `data-session-token` / `data-embed-token` script attribute | Widget session token or embed token |

## What not to do

- ❌ Put a secret key in a `.env.production` file that gets shipped to the
  browser via Vite/webpack `DefinePlugin`. Check your bundler output.
- ❌ Use a session token to call the raw API from `fetch` — session tokens
  are only accepted by the widget runtime.
- ❌ Reuse a single session token across many users — tokens are
  user-bound.
- ❌ Assume a publishable key is "read-only enough" to skip scope review —
  check the scope set before publishing a new one.

## Related

- [Widget integration](/docs/guides/widget-integration)
- [Browser usage with publishable key](/docs/guides/browser-usage)
- [Error: publishable_key_scope](/docs/reference/error-codes#publishable-key-scope)
