BYOK — Bring Your Own Keys — lets each workspace pay their own Cloudflare / OpenAI / OpenRouter / Qdrant bill instead of running on the platform operator's shared credentials. Strategic for operators reselling Pitchbar as a SaaS to many end customers: every customer's chat, embeddings, and vector storage land on their upstream account, not yours.
BYOK has two switches that compose:
| Global flag | Per-user override | BYOK unlocked? |
|---|---|---|
| OFF | not set | No — platform keys are used. |
| OFF | force-on | Yes — this user gets BYOK access even though the platform default is off. |
| OFF | force-deny | No. |
| ON | not set | Yes — every workspace must supply own keys. |
| ON | force-on | Yes (same as not-set when global is on). |
| ON | force-deny | No — explicit deny always wins. |
The matrix is enforced by
App\Support\ByokResolver::isUnlockedFor($user, $workspace).
The LLM and Vector container bindings call into the resolver on
every resolution, so flipping a switch takes effect on the next
request without an Octane reload.
/settings/system.From this point every workspace must paste their own keys before their widget can serve visitors. Workspaces with no keys hit a friendly "this workspace is not configured" bubble in chat instead of a stack trace.
/admin/users.Inherit global (default) — follow the global flag.Force enable — grant BYOK access to this user only.Force disable — block BYOK for this user. Always wins.
When BYOK is unlocked for a workspace, an
AI keys entry appears in the customer's Settings
sidebar (between API tokens and the platform pages).
Click it to open /settings/byok-keys:
Each section has a separate Save button so a customer can configure providers one at a time, and a per-provider Clear button to wipe just that provider's credentials. The form never echoes secrets back into the UI — customers re-paste on every rotation. Public fields (account_id, model names, index names, Qdrant URL) are pre-filled.
Super-admins do not see the "AI keys" entry. They
manage platform-wide credentials at
/settings/system →
AI providers. Workspace-level BYOK is customer-only.
| Field | Database column | Encryption |
|---|---|---|
| All BYOK credentials | workspaces.byok_keys (JSON map) | encrypted:array via APP_KEY |
| Global flag | app_settings.byok_enabled_globally (boolean) | Plain (it's a public flag) |
| Per-user override | users.byok_enabled (nullable boolean) | Plain |
The encrypted column stores a Laravel Crypt envelope. The raw DB
column never contains plaintext credentials — anyone reading the
column directly only gets a base64 blob. Decryption requires
APP_KEY; rotating APP_KEY renders the
column unreadable until customers re-paste.
BYOK isolates each workspace from every other. The boundary is enforced in five layers:
workspaces.byok_keys outside the application
returns a Crypt envelope, not the plaintext token.
ByokResolver::keysFor(...) takes a Workspace
model that comes from CurrentWorkspace::get().
That helper resolves the workspace from the authenticated
admin's default_workspace_id OR the widget JWT's
agent.workspace_id — never from request
body input. There's no global lookup that could leak the wrong
tenant.
OpenAiClient and QdrantClient are
bound scoped() in
AppServiceProvider, not singleton().
Each HTTP request rebuilds the client. Workspace A's keys
never persist into worker memory for workspace B's next
request, even under Octane.
ByokKeysController::clear and ::update
resolve the workspace from CurrentWorkspace, never
from a ?workspace_id= param. Workspace A can't
wipe or read workspace B's keys via any documented API call.
tests/Feature/Byok/ByokTenantIsolationTest.php
pins each guarantee — encryption at rest, A-doesn't-see-B,
mutating A doesn't touch B, clearing A doesn't touch B.
Removing any of the protections fails the test suite.
When a visitor sends a message, the LLM container binding runs:
CurrentWorkspace from the widget JWT.ByokResolver::isUnlockedFor($user, $workspace) (visitor flow has no user, so the resolver checks only the global flag plus the workspace's keys).workspaces.byok_keys has the matching provider's keys, build the client with those credentials.MissingByokKeyException — the visitor sees a friendly "workspace not configured" line, not a stack trace.The same chain drives Vectorize / Qdrant binding selection, the crawler's Cloudflare Browser Rendering credentials, and the reranker.
They still pay you for the application itself (subscription, lifetime deal). BYOK only shifts the variable AI / vector / browser-rendering cost. Your Pitchbar billing is unrelated to the upstream LLM bill.
Their next request misses keys and they see the "workspace not configured" bubble. The fix is one of:
/settings/byok-keys as the workspace owner./admin/users) so they fall back to platform credentials.APP_KEY?
Yes, but the existing encrypted byok_keys column entries
are sealed with the old key — rotating renders them unreadable until
customers re-paste. Run a one-shot artisan command (or migrate
in-place) to decrypt with the old key and re-encrypt with the new.
Same caveat applies to every other encrypted cast in the
app.
No. The SSRF guard (private-IP blocklist + DNS rebind protection) runs regardless of which Cloudflare account is fetching the URL. BYOK only swaps the credentials; the safety net stays in place.
They check it on their own provider's dashboard (Cloudflare, OpenAI, Qdrant Cloud) — Pitchbar doesn't proxy usage metering back. Workspace-level analytics in your Pitchbar dashboard still show conversation + message counts, which is enough for them to correlate.