Security
Authorization: Bearer lattice_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxApplications 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.
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'srandomBytes), base64url-encoded. - Prefixed
lattice_sk_so a key is recognizable by sight and greppable in logs (with the rest redacted). prefixis 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:
{
"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:
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 | |
|---|---|---|
| Table | api_keys | management_api_keys |
| Scoped to | One application_id | Nothing — administers the whole gateway |
| Grants | /v1/**, subject to that application's policy | /v1/** is not granted; /management/v1/** only |
| Revocation | Per-key, or all-at-once via applications.disabled_at | Per-key |
| Typical holder | A company application's backend | The dashboard's server, or an admin script |