> ## Documentation Index
> Fetch the complete documentation index at: https://pro-bot.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# BYO-key flow

> How your LLM API key is encrypted, where it moves, and where it never goes.

ProBot is **bring-your-own-key**. You supply an Anthropic / OpenAI / Azure / Google key; ProBot keeps it envelope-encrypted at rest, decrypts it in memory for one chat request, and discards it. The key is **never logged**, **never echoed in an error message**, and **never returned in a response**. A `canary-key` test enforces this at both the route layer and the provider adapter layer - if a regression ever leaks the key, the build fails.

## Encryption at rest

On the hosted deployment, each key is protected by two layers:

* A per-bot **DEK** (Data Encryption Key, AES-256-GCM) encrypts the key itself.
* A **KEK** (Key Encryption Key) loaded from the `PROBOT_KEY_ENCRYPTION_KEY` env var wraps the DEK. The KEK never touches the database.

The wrapped DEK + ciphertext live in the `encrypted_llm_keys` table (one row per bot). A database-only leak is useless without the KEK. Rotating the KEK re-wraps every DEK without touching the ciphertext keys themselves - see [KEY-STORAGE.md](https://github.com/vishalpatil18/probot/blob/main/KEY-STORAGE.md).

## How a chat request resolves the key

The chat route accepts an optional `x-llm-api-key` header (used by the owner's own dashboard test chat) and falls back to the managed encrypted key otherwise:

1. **Header present** (owner testing from their own browser) - used directly.
2. **Managed encrypted key** - unwrapped with the KEK, decrypted in memory, used once, discarded.
3. **Neither** - request fails with `missing_llm_key`.

Recruiters chatting on the public URL or through the embed widget never have a header key - path 2 handles them. Every managed decrypt also writes a **decrypt audit log** row (timestamp + non-reversible hash) so anomalies are observable.

```mermaid theme={null}
sequenceDiagram
    participant U as Recruiter (browser)
    participant Route as /api/chat/[botId]
    participant DB as encrypted_llm_keys
    participant Prov as Anthropic / OpenAI / Azure

    U->>Route: POST { message, sessionId }  (no key header)
    Route->>DB: lookup encrypted row for botId
    DB-->>Route: { wrappedDek, ciphertext }
    Route->>Route: unwrap DEK with KEK; decrypt key in memory
    Route->>Prov: provider.complete({ apiKey, system, userMessage })
    Prov-->>Route: { reply }
    Route->>Route: sanitize output; write decrypt audit row
    Route->>Route: discard plaintext key (stack frame GC'd)
    Route-->>U: { reply }
```

## Where the key is

| Surface                          | State                                 |
| -------------------------------- | ------------------------------------- |
| `encrypted_llm_keys` (Postgres)  | ✅ AES-256-GCM ciphertext, wrapped DEK |
| HTTPS connection to provider     | ✅ TLS in transit                      |
| Server memory during one request | Plaintext, then discarded             |
| **JSON request body**            | ❌ never                               |
| **Server logs**                  | ❌ never                               |
| **Error responses**              | ❌ never echoed                        |
| **KEK in the database**          | ❌ held only in the deployment env     |

> The JSON body is `{ message, sessionId }` - the key is never a body field on any surface.

## Why this shape

A traditional "AI chatbot service" stores your API key server-side in plaintext, calls the provider on your behalf, and gives you no visibility. That leaves a persistent, subpoenable secret sitting in a shared database.

ProBot's design inverts that:

* **Ciphertext at rest, plaintext for microseconds.** A DB dump alone reveals nothing. Only a running server with the KEK can decrypt.
* **No key column in `bots` or `users`.** The `encrypted_llm_keys` table is the *only* place a key ever appears, and only as ciphertext.
* **You pay the provider directly.** Envelope encryption doesn't change your billing relationship - ProBot never proxies charges.

## Provider adapter - per-request client

Every adapter (Anthropic, OpenAI, Azure, Google, Grok) constructs a **new client per request** with the decrypted key. There is no `let cachedClient`, no `Map<userId, Client>`, no global. The `canary-key` test plants a known canary string as the plaintext key and asserts it appears **only** in the outbound HTTPS payload - never in a response, never in a log mock, never in an error.

```ts theme={null}
// src/lib/ai/providers/anthropic.ts (simplified)
async complete({ apiKey, system, userMessage, model }) {
  const client = new Anthropic({ apiKey });  // new per call
  const msg = await client.messages.create({ /* … */ });
  return { reply: extractText(msg) };
}
```

## Implementation footnotes

* **Azure keys are header-only.** Azure's multi-secret credential (key + endpoint + apiVersion) isn't supported by managed storage in this release, so Azure bots require the owner to be online with the credentials in their browser store, or a self-hosted bot via the `probot-self-hosted` npm package. Other providers all go through managed encryption.
* **Self-hosting the bot.** If you'd rather not trust any operator, install the [`probot-self-hosted`](/docs/self-hosted-bot/index) npm package in your own web app - the plaintext key lives entirely inside your backend and never touches pro-bot.dev.
* **Owner test chat.** When the owner tests their bot from the dashboard, the browser passes the key as `x-llm-api-key`. The dashboard mirrors the key into a browser-side encrypted store (IndexedDB + non-extractable Web Crypto key) so the owner doesn't re-paste it on every reload.

## Failure modes (and how the key is protected in each)

| Failure                   | What happens                                                                                                                         | Key safe? |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | --------- |
| User pastes wrong key     | Provider returns 401 → adapter throws `ProviderError("invalid_key")` → route returns `{ error: "invalid_llm_key" }`. Key not echoed. | ✅         |
| Provider rate-limits      | `ProviderError("rate_limit")` → `429 provider_rate_limit`                                                                            | ✅         |
| Provider is down          | `ProviderError("provider_unavailable")` → `502`                                                                                      | ✅         |
| KEK unavailable at boot   | The route returns `503 managed_storage_unavailable`. No partial key exposure.                                                        | ✅         |
| Network drops mid-request | Handler aborts; plaintext key is GC'd with the stack frame.                                                                          | ✅         |

## Threat model

BYO-key with envelope encryption protects you against:

* **Server-side breach of ProBot's database.** Ciphertext + wrapped DEK are useless without the KEK.
* **Cross-tenant leakage.** Every request constructs a fresh provider client with the just-decrypted key.
* **Vendor lock-in / surprise billing.** You hold the contract with Anthropic / OpenAI / Azure directly.

BYO-key does **not** protect you against:

* **Full infrastructure compromise.** Anyone with deploy access can read the KEK. If that's your threat model, self-host the bot with the [`probot-self-hosted`](/docs/self-hosted-bot/index) npm package - your LLM key never touches pro-bot.dev.
* **XSS on the ProBot frontend.** The frontend mitigates this with `react-markdown` + no `rehype-raw`; if you find a gap, please report via [SECURITY.md](https://github.com/vishalpatil18/probot/blob/main/SECURITY.md).
* **Phishing.** A lookalike domain could capture pasted keys.

For the input/output sanitization layer, see [Security](/docs/concepts/security).
