DOCS / ARCHITECTURE / SECURITY

Security

text
Authorization: Bearer lattice_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Applications and the dashboard both authenticate with a bearer key; the gateway never re-exposes a provider credential to either. This page covers exactly how those bearer keys are generated, stored, and verified — for the API surface that issues and consumes them, see Authentication and API Keys.

Key generation

generateApiKey() (apps/gateway/server/auth/api-keys.ts) is used for both application-scoped keys and management keys — they're structurally identical, just stored in different tables and checked by different middleware.

ts
type ProviderAdapter = {
  id: ProviderId;
  createChatCompletion(
    request: ChatCompletionRequest,
    ctx: ProviderRequestContext,
  ): Promise<ChatCompletionResponse>;
  streamChatCompletion(
    request: ChatCompletionRequest,
    ctx: ProviderRequestContext,
  ): AsyncGenerator<ChatCompletionChunk, void, void>;
};

type ProviderRequestContext = {
  apiKey?: string;
  providerModel: string;
  signal: AbortSignal;
};
  • 32 bytes of cryptographically random data (node:crypto's randomBytes), base64url-encoded.
  • Prefixed lattice_sk_ so a key is recognizable by sight and greppable in logs (with the rest redacted).
  • prefix is the first 23 characters (lattice_sk_ + 12 characters of the secret) — enough to let an admin recognize which key a UI row refers to, without ever storing or displaying the rest again.

Storage: hash, not the key

The raw key is never stored. What lands in api_keys.key_hash / management_api_keys.key_hash is:

ts
{
  "id": "chatcmpl-...",
  "object": "chat.completion",
  "created": 1755878400,
  "model": "gpt-4o",
  "choices": [
    {
      "index": 0,
      "message": { "role": "assistant", "content": "..." },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 42,
    "completion_tokens": 118,
    "total_tokens": 160
  }
}

HMAC-SHA256, keyed by a server-side secret (the pepper, API_KEY_PEPPER) that lives only in the gateway's environment — not in the database. This is the deliberate security property: a stolen database dump alone is not enough to produce a usable key, because computing the right hash requires the pepper too. Losing the pepper is equally deliberate in its blast radius — it invalidates every key ever issued at once, since no stored hash can be reproduced from a new pepper. Generate one with bun run generate-pepper (apps/gateway/scripts/generate-pepper.ts, also 32 random bytes, base64url); it must be at least 32 characters (enforced by the gateway's env schema).

Verification

Both lookups are a single indexed read against a key_hash column — verifying a bearer key never requires scanning every issued key.

Request-context storage

The application resolved by verifyApiKey needs to be readable by later middleware (rate limiting) and the route handler (policy, quota, logging) without re-verifying the key. Rather than relying on H3Event.context — which the codebase found didn't reliably survive Nitro's typing/module augmentation in this setup — the gateway keys a WeakMap<Request, { application?, requestId? }> off the underlying Request object directly (server/utils/request-context.ts). It's a small, deliberate deviation from the "just use event.context" default, scoped to exactly this problem.

Error responses never leak internals

Every error the gateway can throw — auth, validation, quota, provider failures, unexpected exceptions — passes through one central error handler (server/utils/error-handler.ts) that emits a single envelope shape:

json
cd apps/dashboard
cp .env.example .env

An error not explicitly thrown as a LatticeError (i.e. any unhandled exception) has its message replaced with the generic "Internal server error" before it reaches the client — stack traces and internal error text never escape the process. See Errors for the full code table.

Summary of the two trust tiers

Application key (lattice_sk_…)Management key
Tableapi_keysmanagement_api_keys
Scoped toOne application_idNothing — administers the whole gateway
Grants/v1/**, subject to that application's policy/v1/** is not granted; /management/v1/** only
RevocationPer-key, or all-at-once via applications.disabled_atPer-key
Typical holderA company application's backendThe dashboard's server, or an admin script
LATTICE — INTERNAL AI INFRASTRUCTURE
GITHUB © 2026