# Gateway · BYOK credential routing (/docs/gateway/byok)



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](/docs/gateway#byok-is-header-only).
The directory at `@pleach/gateway/byok/*` (this page) ships the storage

* routing substrate. The two coexist on distinct subpaths.

## `TenantCredentialStore` [#tenantcredentialstore]

The storage contract. Adapters implement it against a concrete backing
store; the routing middleware reads from `get()` on every `route()`
call.

```typescript
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 [#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 [#postgres]

```typescript
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):

```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:

```typescript
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 [#redis]

```typescript
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 registered
```

The `__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 `GET`s, 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 `srem`s 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 [#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.

```typescript
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` [#credentialroutingmiddleware]

The wire-up. The middleware sits between the gateway's `route()` call
and the underlying transport invocation. For each call it:

1. Looks up the tenant's credential for the resolved family via
   `store.get(tenantId, family)`.
2. If found, returns `{ apiKey, baseURL, metadata, byokActive: true }`
   for the routing layer to attach as headers.
3. If not found, falls back to the platform key and returns
   `byokActive: false`.

```typescript
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 [#onmissing-policy]

```typescript
type OnMissingPolicy = "fallback" | "throw"
```

* `"fallback"` (default) — use the platform `fallbackKey`. Permissive;
  BYOK is opportunistic.
* `"throw"` — throw `CredentialMissingError`. 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:

```typescript
const resolve = createCredentialRoutingMiddleware({ store })
// No fallback. Tenants without a registered credential will hit
// CredentialMissingError every time.
```

### `CredentialMissingError` [#credentialmissingerror]

```typescript
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 [#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 [#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 `apiKey` values. Logging the fingerprint
  (16-char sha256 prefix from `fingerprintByokKey`) 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](/docs/gateway#byok-is-header-only).

## Cited source [#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 [#where-to-go-next]

<Cards>
  <Card title="Cost events" href="/docs/gateway/cost-events" description="Per-call cost emission with BYOK attribution — byokActive + byokKeyHash flow through the CostEvent." />

  <Card title="Migration from `@pleach/core`" href="/docs/gateway/migration-from-core" description="When to introduce the gateway and how the BYOK middleware wires into an existing createPleachRuntime setup." />

  <Card title="`@pleach/gateway` overview" href="/docs/gateway" description="The Phase A GatewayClient — route(), cost events, family-strict cascade, transport seam." />
</Cards>
