Gateway · BYOK credential routing
Per-tenant BYOK credential storage and routing — TenantCredentialStore contract, Postgres / Redis / external-secret-manager adapters, and CredentialRoutingMiddleware.
A tenant supplies their own provider key for one or more model families, and the gateway routes that family's calls through the tenant's key (BYOK transport) rather than a platform-owned key. This page covers the storage substrate — where the key lives between calls — and the middleware that resolves it at route time.
The single-file byok.ts utility at @pleach/gateway/byok ships the
header-only fingerprint helper.
The directory at @pleach/gateway/byok/* (this page) ships the storage
- routing substrate. The two coexist on distinct subpaths.
TenantCredentialStore
The storage contract. Adapters implement it against a concrete backing
store; the routing middleware reads from get() on every route()
call.
import type { ProviderFamily } from "@pleach/gateway"
interface TenantCredential {
readonly family: ProviderFamily
readonly apiKey: string
readonly baseURL?: string
readonly metadata?: Record<string, unknown>
}
interface TenantCredentialStore {
get(
tenantId: string,
family: ProviderFamily,
): Promise<TenantCredential | null>
put(
tenantId: string,
family: ProviderFamily,
credential: TenantCredential,
): Promise<void>
list(tenantId: string): Promise<TenantCredential[]>
}One tenant may have multiple entries — one per provider family. The
primary key is (tenantId, family). apiKey is opaque to the
store; transport-level validation happens at the routing layer.
get() returns null (not throws) when a credential is missing. The
middleware treats null as "use platform fallback" by default;
operators who want strict-BYOK-required semantics configure
onMissing: "throw" (see below).
Reference adapters
Three reference adapters ship under separate subpaths so consumers
only pay the optional-peer-dependency cost when they actually wire one
in. Each is structurally agnostic — no pg / ioredis / vendor SDK
import at module-eval time.
Postgres
import { Pool } from "pg"
import { createPostgresCredentialStore } from "@pleach/gateway/byok/adapters/postgres"
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const store = createPostgresCredentialStore({ pool })Schema (canonical — see MIGRATIONS.md in the source tree for the
authoritative SQL):
CREATE TABLE pleach_byok_credentials (
tenant_id TEXT NOT NULL,
family TEXT NOT NULL,
api_key BYTEA NOT NULL,
base_url TEXT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, family)
);
CREATE INDEX pleach_byok_credentials_tenant_idx
ON pleach_byok_credentials (tenant_id);Honest scope. Encryption at rest is the host's responsibility.
The adapter writes and reads api_key as plain bytes via
parameterized queries; pair with a column-encryption extension
(pgcrypto's pgp_sym_encrypt / Supabase Vault / RDS TDE) for
compliance deployments. The factory does NOT apply encryption itself,
which keeps the adapter dependency-free and lets hosts pick their own
KMS integration.
tableName is validated at construction. The adapter rejects
anything that doesn't match /^[A-Za-z_][A-Za-z0-9_]*$/, throwing a
TypeError from the factory before the first query runs. Construction
fails fast so SQL injection through tableName is not reachable:
createPostgresCredentialStore({
pool,
tableName: "drop; --",
})
// → TypeError: invalid tableName "drop; --"
// (must match /^[A-Za-z_][A-Za-z0-9_]*$/)Source: packages/gateway/src/byok/adapters/postgres.ts,
assertSafeIdentifier.
The adapter is uncached by default. Hosts SHOULD wrap with a
short-TTL in-memory cache (lru-cache, 60s TTL) at the middleware
boundary if route-time latency matters.
Redis
import Redis from "ioredis"
import { createRedisCredentialStore } from "@pleach/gateway/byok/adapters/redis"
const client = new Redis(process.env.REDIS_URL)
const store = createRedisCredentialStore({
client,
keyPrefix: "pleach:byok:prod",
})Auto-detects between ioredis and node-redis v4+ — the duck-typed
client surface (get / set / del / sadd / srem / smembers)
is identical at the lowest-common-denominator level.
Key shape:
<prefix>:<tenantId>:<family> → JSON-encoded TenantCredential
<prefix>:<tenantId>:__families → SET of families registeredThe __families set is the load-bearing detail. Redis KEYS pattern
is O(N) on the whole keyspace and refused outright on managed
Redis (Upstash, ElastiCache cluster mode). Maintaining a per-tenant
set means list() runs O(F) where F = number of families registered
for the tenant — capped at 6 today (the six locked provider
families). One SMEMBERS + N GETs, not a keyspace scan.
list() heals the set lazily: if a credential key is missing while its
family is still in __families (a delete that didn't srem), the
adapter silently srems the orphan and continues.
Encryption at rest is the host's responsibility — pair with Redis TLS
- ACL + an at-rest encrypted volume. The adapter writes plain JSON; do NOT operate this against an unencrypted Redis on a shared network.
External secret manager
Vendor-neutral facade for AWS Secrets Manager / HashiCorp Vault / GCP Secret Manager / Azure Key Vault / Doppler / 1Password Connect. The adapter delegates all storage operations to host-supplied callbacks; no SDK import here. Hosts pick their cloud SDK, wrap it, and pass the resolver.
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager"
import { createExternalSecretManagerStore } from "@pleach/gateway/byok/adapters/external-secret-manager"
const sm = new SecretsManagerClient({ region: "us-east-1" })
const store = createExternalSecretManagerStore({
resolver: async (tenantId, family) => {
const SecretId = `pleach/byok/${tenantId}/${family}`
try {
const { SecretString } = await sm.send(new GetSecretValueCommand({ SecretId }))
return SecretString ? JSON.parse(SecretString) : null
} catch (err: any) {
if (err.name === "ResourceNotFoundException") return null
throw err
}
},
})put / list are optional. Most external secret managers are
managed out-of-band (Terraform, console UI, IaC pipelines). When the
corresponding callback is omitted, calling put() / list() throws
ExternalSecretManagerNotImplementedError rather than silently
no-op'ing — operators see the failure at the admin-surface boundary.
Caching is the resolver's responsibility. The adapter calls
resolver() on every get(). Wrap with lru-cache if the vendor
charges per-API-call (AWS Secrets Manager: $0.05 per 10k calls).
CredentialRoutingMiddleware
The wire-up. The middleware sits between the gateway's route() call
and the underlying transport invocation. For each call it:
- Looks up the tenant's credential for the resolved family via
store.get(tenantId, family). - If found, returns
{ apiKey, baseURL, metadata, byokActive: true }for the routing layer to attach as headers. - If not found, falls back to the platform key and returns
byokActive: false.
import { createCredentialRoutingMiddleware } from "@pleach/gateway"
import { createPostgresCredentialStore } from "@pleach/gateway/byok/adapters/postgres"
const store = createPostgresCredentialStore({ pool })
const resolve = createCredentialRoutingMiddleware({
store,
fallbackKey: process.env.PLATFORM_ANTHROPIC_KEY,
onMissing: "fallback",
})
const { apiKey, baseURL, byokActive } = await resolve("tenant-acme", "anthropic")onMissing policy
type OnMissingPolicy = "fallback" | "throw""fallback"(default) — use the platformfallbackKey. Permissive; BYOK is opportunistic."throw"— throwCredentialMissingError. Strict; tenants without a registered credential cannot route. Use this for compliance-attestation workflows where every call MUST be attributable to a tenant-owned key.
When fallbackKey is omitted from the config, the effective policy is
always "throw" regardless of the onMissing setting — the
middleware fails closed by construction:
const resolve = createCredentialRoutingMiddleware({ store })
// No fallback. Tenants without a registered credential will hit
// CredentialMissingError every time.CredentialMissingError
class CredentialMissingError extends Error {
readonly tenantId: string
readonly family: ProviderFamily
}Caught at the GatewayClient layer and surfaced to the host as a
structured rejection. Operators MUST handle this in the
tenant-onboarding flow — typical UX is "you have not yet registered a
key for this provider".
Where governance lives
The middleware is intentionally policy-free. Governance gates
(allowedFamilies allowlist, plan-tier caps, per-tenant rate limits)
live at the GatewayClient layer and run BEFORE this middleware. By
the time resolveCredential() is called the family is already
authorized; this layer's only job is "where does the key come from for
this (tenant, family) pair".
Security posture
- Stored credentials MUST be encrypted at rest. Postgres column encryption / Redis TLS+ACL+at-rest encryption / external secret manager. Adapter factory docs surface the expectation; the gateway does not enforce it.
- Adapters MUST NOT log raw
apiKeyvalues. Logging the fingerprint (16-char sha256 prefix fromfingerprintByokKey) is permitted; logging the plain key is a security incident. - The store interface returns the raw key for routing-time consumption only. Callers (the routing middleware) are responsible for header-only handling per the BYOK header-only contract.
Cited source
packages/gateway/src/byok/TenantCredentialStore.ts— contract.packages/gateway/src/byok/CredentialRoutingMiddleware.ts— middleware factory.packages/gateway/src/byok/adapters/postgres.ts— Postgres adapter.packages/gateway/src/byok/adapters/redis.ts— Redis adapter.packages/gateway/src/byok/adapters/external-secret-manager.ts— external secret manager facade.packages/gateway/test/byokSurface.smoke.test.mjs— surface regression-lock (round-trip get/put/list + factory rejection cases).
Where to go next
Cost events
Per-call cost emission with BYOK attribution — byokActive + byokKeyHash flow through the CostEvent.
Migration from `@pleach/core`
When to introduce the gateway and how the BYOK middleware wires into an existing createPleachRuntime setup.
`@pleach/gateway` overview
The Phase A GatewayClient — route(), cost events, family-strict cascade, transport seam.
@pleach/replay
The `@pleach/replay` package — ReplayClient + ReplayHandle walk the canonical event log via runtime.events.iterate/fold, with tenantId required and a typed cache-miss policy.
Gateway · Rate limiting
Rate-limit contract and the in-process reference adapter — RateLimiter interface, fixed-window InMemoryLimiter, and the roadmap for distributed adapters.