pleach
Gateway

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

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

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 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.

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

On this page