---
title: Local Keys & Gateway
description: How Local Keys are stored, encrypted, and served to the browser and CLI via the LenserFight Gateway loopback daemon — architecture, security model, and setup.
---

# Local Keys & Gateway

Local Keys are API keys that live **only on your machine**, encrypted at rest under a master passphrase that LenserFight never sees. Execution reads the key through the LenserFight Gateway daemon running on your machine — no key material ever touches the cloud.

> **This page covers the architecture and security model in depth.**
> For a quick-start comparison of all funding modes, see [Funding Sources](/en/explanation/lenses/funding-sources).

---

## Architecture

```
┌──────────────────┐  HTTP (loopback, bearer auth)  ┌──────────────────┐  fs (0600/0700)
│  apps/web        │ ────────────────────────────>  │  apps/gateway    │ ──────────────> ~/.lenserfight/keys/
│  (browser)       │   /keys CRUD + /keys/:id/resolve│  (Node daemon)   │                <id>.json (AES-256-GCM)
└──────────────────┘                                 └──────────────────┘
                                                              ▲
                                                              │ direct fs
                                                              │
                                                     ┌──────────────────┐
                                                     │  apps/cli        │
                                                     │  lf keys *       │
                                                     └──────────────────┘
```

The browser holds **no** ciphertext and **no** persistent state about your keys. Every read goes through the gateway loopback daemon. The same store serves:

- The **browser** via the gateway's `/keys` endpoints.
- The **CLI** (`lf keys *`) directly via the filesystem.
- Any future runner built on `libs/data/local-keys`.

There is no separate browser-only store — the previous IndexedDB design has been removed.

---

## Setup

Complete these steps once, in order. Every step is required — skipping any one causes a specific error (listed in the Troubleshooting section below).

### Step 0 — Prerequisites (one-time only)

Open a terminal and run the following in order:

```bash
# 1. Generate the device Ed25519 keypair (stored in your OS keychain).
#    Required for `lf gateway serve` (full mode) to pass its identity check.
#    Skip this if you only ever run with `--keys-only`.
lf gateway identity init

# 2. Generate the master passphrase that encrypts your local keys at rest.
#    Required before you can save any key — the gateway refuses with
#    "passphrase_missing" (503) until this has been run.
lf keys init
```

Both commands are **idempotent** — re-running them on an existing setup prints a
warning and exits without overwriting anything (use `--force` to rotate).

### Step 1 — Start the gateway

In a terminal — **leave this running**:

```bash
# Same machine — gateway and browser on the same box (most common).
lf gateway serve --keys-only

# Full mode (also syncs device state with the cloud).
lf gateway serve

# Tailscale or LAN — browser on another device, gateway on this one.
lf gateway serve --keys-only --bind 0.0.0.0
```

`--keys-only` skips the Ed25519 identity / Supabase session / lenser-active
preconditions so you can use Local Keys without a full gateway identity.
It also allows binding a non-loopback address without a Tailscale consent file.

When the daemon starts it prints a colored box:

```
┌──────────────────────────────────────────────────────────────┐
│                                                              │
│  Gateway ready — pair the web app in 3 steps                 │
│                                                              │
│  Prerequisite: run `lf keys init` once (adds master passphrase).│
│                                                              │
│  Step 1 — copy this token:                                   │
│                                                              │
│  <your-43-char-base64url-token>                              │
│  ...                                                         │
│                                                              │
│  Step 2 — open the web app → any Funding panel → Local Keys  │
│  Step 3 — paste the token into "Pair gateway"                │
│                                                              │
│  Gateway: http://127.0.0.1:38080                             │
└──────────────────────────────────────────────────────────────┘
```

Copy the token from this box. You can always retrieve it later with:

```bash
lf gateway pair --web       # print the current token
lf gateway pair --rotate    # rotate to a new token (invalidates the old one)
```

### Step 2 — Add a provider key

In another terminal (the gateway must still be running):

```bash
# Add a key — value is read from stdin so it never enters shell history.
lf keys add --provider openai --label "Prod"

# Verify it was stored:
lf keys list
```

### Step 3 — Pair the browser

1. Open the LenserFight web app on any lens, battle, or workflow page (anywhere with a Funding panel).
2. In the Funding panel, click the **Local Keys** tile.
3. A **Paste your pairing token below ↓** box appears — paste the token printed in Step 1 and click **Pair gateway**.

The keys added with `lf keys add` will now appear in the picker.

**Token lifetime:** The pairing token lives in `sessionStorage` only. Close the tab and you'll need to re-run `lf gateway pair --web` to get a fresh token. The gateway re-uses the same token across restarts unless you rotate it with `--rotate`.

---

## Troubleshooting

| Error | Cause | Fix |
|-------|-------|-----|
| `[precondition] FAIL identity_present` | No Ed25519 keypair on this machine | `lf gateway identity init` |
| `[precondition] FAIL keys_passphrase` | `lf keys init` has never been run | `lf keys init` |
| `Failed to save key … passphrase_missing (503)` | Master passphrase missing in keychain | `lf keys init` |
| `[keychain] OS keychain (keytar) is unavailable` | Native keytar binding not compiled for this Node version | Warning only — the file-backend fallback at `~/.lenserfight/gateway/keys/` is used automatically. Functionality is identical. |
| `lf-gateway-init: command not found` | The gateway binary wasn't built | `pnpm nx run gateway:build-init && node dist/apps/gateway/init.js` — **or** use `lf gateway identity init` which works without building the binary. |
| `Gateway not running` in web app | `lf gateway serve` is not running | Start it and leave it running in a dedicated terminal. |
| `Gateway not paired` in web app | Browser has no pairing token | Run `lf gateway pair --web` and paste the token into the Funding panel. |
| `Gateway refused this origin (403)` | App is running on a non-whitelisted URL | Use `https://lenserfight.com`, `localhost`, or `127.0.0.1`. Custom origins: set `LF_GATEWAY_EXTRA_ORIGINS`. |

---

## Key management commands

| Command                                  | Purpose                                                                                 |
| ---------------------------------------- | --------------------------------------------------------------------------------------- |
| `lf keys init`                           | One-time setup: creates `~/.lenserfight/keys/`, stores master passphrase in OS keychain |
| `lf keys add --provider <p> --label <l>` | Add a new key (value read from stdin)                                                   |
| `lf keys list`                           | List all stored key IDs and metadata                                                    |
| `lf keys rotate <id>`                    | Re-encrypt a key under a new IV                                                         |
| `lf keys remove <id>`                    | Delete a key envelope                                                                   |
| `lf keys doctor`                         | Verify all envelopes can be decrypted                                                   |

See the [CLI keys reference](/en/reference/cli/keys) for the full surface.

---

## Encryption at rest

Each key lives in its own envelope at `~/.lenserfight/keys/<id>.json`:

```json
{
  "v": 1,
  "alg": "aes-256-gcm",
  "kdf": "scrypt",
  "salt": "<16 bytes>",
  "iv": "<12 bytes>",
  "ciphertext": "...",
  "tag": "<16 bytes>",
  "meta": { "id": "...", "provider": "openai", "label": "Prod", "createdAt": "..." }
}
```

- **Per-key salt + scrypt KDF** (N=2¹⁵, r=8, p=1, dkLen=32). Brute force must grind scrypt for every individual key.
- **AES-256-GCM** with a fresh 12-byte IV per encryption. The auth tag detects tampering and refuses to decrypt.
- **File mode 0600**, parent directory mode 0700. The store refuses to follow symlinks and rejects any ID that does not match `^[A-Za-z0-9_-]{20,40}$`.
- **Master passphrase** lives in the OS keychain under service `lenserfight-keys`. Never written to any file. CI may use the env var `LENSERFIGHT_KEYS_PASSPHRASE` instead.

---

## Browser ↔ gateway authentication

| Check                                       | Defense                                                                                                                                                  |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Cross-origin browser JS calling the gateway | Origin allow-list (`lenserfight.com`, subdomains, `localhost`/`127.0.0.1`). All others → 403.                                                            |
| Disallowed-origin preflight                 | Returns 403 — browser never sees the response body.                                                                                                      |
| Bearer token                                | 32 random bytes generated by `lf gateway pair`, held in the OS keychain by the gateway and in `sessionStorage` by the browser. Constant-time comparison. |
| Brute force on `/keys/:id/resolve`          | 60/min per token, burst of 5 — 429 + audit log.                                                                                                          |
| Body abuse                                  | Hard 64 KiB cap; larger payloads → 413.                                                                                                                  |

The gateway only binds to loopback by default. The `--bind` flag accepts a specific IP — it will not bind to `0.0.0.0` unless you explicitly pass it.

For the full threat-model breakdown, see [Local Keys security model](/en/explanation/security/local-keys).

---

## Ollama (local models)

Ollama runs AI models entirely on your machine. For local Ollama models, **no API key is required** — Ollama connects to `localhost:11434`. An optional key field is available for cloud-routed Ollama models only.

---

## Migrating from the old IndexedDB store

If you used Local Keys before this version, the old IndexedDB database (`lenserfight-local-keys`) is auto-deleted on first load after upgrade. Re-add your keys with `lf keys add`. There is no export path from the legacy store.

---

## When to use Local Keys

- You are self-hosting LenserFight and prefer to keep all secrets on your machine.
- You move between the CLI and the browser and want a single source of truth for provider keys.
- You want to test AI providers without a LenserFight account.

---

## Cloud BYOK key decryption

Cloud BYOK keys are stored in Supabase Vault and decrypted server-side via `fn_get_my_key_secret`, a Postgres RPC that returns the plaintext to the authenticated browser client. The function works in both local and cloud Supabase environments.

### Security model

Access is controlled by two server-side guards enforced inside the function:

- **Ownership check:** `lenser_id` must match the authenticated caller — you can only decrypt your own keys.
- **Active-only:** revoked or inactive keys (`is_active = false`) are rejected before any vault lookup.

The function is granted only to the `authenticated` role — anonymous callers cannot reach it.

### Error reference

| Error                                            | Code    | Cause                           | Fix                                                               |
| ------------------------------------------------ | ------- | ------------------------------- | ----------------------------------------------------------------- |
| `Key not found, revoked, or not owned by caller` | `P0001` | Wrong `key_id` or key revoked   | Check `ai.keys` table for an active key owned by the current user |
| `Failed to decrypt key from vault`               | `P0001` | Vault entry missing             | Re-add the key via Settings → BYOK                                |
| `Unauthenticated: no lenser profile found`       | `P0001` | No authenticated lenser session | Sign in before calling the function                               |

---

## See also

- [Funding Sources](/en/explanation/lenses/funding-sources) — comparison of all three funding modes
- [Local Keys security model](/en/explanation/security/local-keys)
- [`lf keys` CLI reference](/en/reference/cli/keys)
- [`lf gateway` CLI reference](/en/reference/cli/gateway)
- [BYOK execution guide](/en/how-to/battles/byok-execution)
