Designing API key auth for a multi-product developer platform
API key authentication looks finished the day you ship it: generate a random string, store it, check it on every request. Then the platform grows a second product, then environments, then two different kinds of caller — and the finished feature starts asking design questions.
smplkit’s API serves two audiences: management operations (create a service, invite a user, update a config value) and runtime operations (fetch a config, evaluate a feature flag). They have different scoping needs and live in different places in a customer’s codebase. One key type can’t serve both well.
Two key types
API keys (sk_live_...) are for management and control plane operations. They’re account-scoped — not tied to any environment. A CI/CD pipeline uses one to create environments, manage services, or update config values. They’re managed from the top-level API Keys page in the console.
SDK keys (sdk_live_...) are for runtime data plane operations. They’re environment-scoped — each key belongs to exactly one environment (production, staging, development). A customer’s application uses one to fetch config values or evaluate feature flags at runtime. They’re managed from inside the environment’s detail page.
The prefixes aren’t decoration. Paste an SDK key into a CI/CD pipeline configuration (or an API key into an application) and the mistake is visible in the string itself, before anything runs.
Why two
Management operations are account-level by nature: creating a service or listing environments doesn’t belong to any particular environment, so an account-scoped key fits.
Runtime operations are the opposite. A config fetch in production must return production values, never staging’s. Environments exist to isolate, and the SDK key enforces that isolation at the authentication layer.
Force one key type to do both jobs and you get to pick your awkwardness: an account-scoped key used at runtime bypasses environment isolation, and an environment-scoped key used for management makes you nominate an arbitrary environment for operations that have none.
The console teaches the model
API keys live on a top-level page because they span the account. SDK keys live inside each environment’s detail page because that’s what they’re scoped to. This is UI organization, but it’s also documentation nobody has to read: a developer who finds SDK keys inside the Production environment page has learned the scoping model without being told.
None of this is original, which we consider a feature. LaunchDarkly separates per-environment SDK keys from account-level API access tokens. Stripe separates publishable keys from secret keys. Datadog separates API keys from application keys. The implementations differ; the instinct — separate key types for separate concerns — shows up everywhere the problem does.
Opaque keys, not JWTs
Both key types are opaque random strings. When a product service like Smpl Config receives a request with an SDK key, it doesn’t decode the key locally. It calls the app service’s introspection endpoint, which validates the key and returns what it’s entitled to access.
We considered JWTs — self-validating tokens with embedded claims — and rejected them three times over.
Revocation. A revoked key should stop working now, not at expiry. A revoked JWT keeps working until it expires; you can shorten the expiry, but then you’re running a token-reissue treadmill. An opaque key validated server-side can be revoked instantly — well, almost instantly; see the cache below.
Claim updates. When a customer changes their subscription tier, the key’s entitlements change with it. With JWTs you’d reissue tokens; with opaque keys, the introspection endpoint returns current entitlements on every call (or cache hit).
Information leakage. An SDK key ships inside customer applications. If it were a JWT, anyone who base64-decodes it gets tenant IDs, scopes, and subscription details for free. An opaque key decodes to nothing, because there’s nothing in it.
The 60-second cache
Calling introspection on every request would add latency to every request. So product services cache the result in-process, per node, with a 60-second TTL. The cache is a TTL dictionary keyed by the SDK key value; entries are a few hundred bytes of JSON, so even thousands of active keys cost next to nothing in memory.
We considered Redis as a shared cache across nodes and rejected it. Redis adds a network hop (~0.5ms per lookup) to every request, plus a piece of infrastructure to operate, to solve a problem a dictionary was already solving in under a microsecond.
The price is consistency, and it’s worth stating plainly: a revoked key can keep working for up to 60 seconds on nodes that have it cached. For our use cases that’s acceptable. If instant revocation ever becomes a requirement, we can layer a push notification (SNS) that evicts cache entries immediately — still no Redis.
The same sixty seconds cuts the other way, too. Introspection makes the app service a runtime dependency of every product service. A node with a warm cache rides out a brief app-service blip; a key the node hasn’t seen recently can’t be validated until introspection answers again. Sixty seconds of grace in both directions — a revoked key that works slightly too long, and a warm cache that keeps vouching while the validator is away.
One table
Both key types live in a single api_key table. A type column (API_KEY or SDK_KEY) selects the behavior; environment_id is nullable — null for API keys, required for SDK keys — with application-level validation enforcing the pairing. One table, one introspection query, regardless of type.