Secrets vs Parameter Store: Choosing the Right Primitive

OSC provides three mechanisms for injecting sensitive configuration into your workloads. They look similar at first glance, but they have different scopes and different security properties. Choosing the wrong one can expose credentials to services that should never see them.

Quick reference: If a credential belongs to one service instance, use a Service Secret. If a runner app needs a credential as an environment variable, use an encrypted parameter store entry. If a value is non-sensitive and shared across multiple services, use a plaintext parameter store entry.

The Three Primitives

Primitive Use for Scope Visibility
Parameter store (plaintext) Non-secret config: feature flags, base URLs, environment names, display names Workspace-wide Anyone with access to the store; shown in the dashboard
Parameter store (encrypted, secret: true) Credentials needed by runner-hosted apps (web/python/wasm/dotnet/golang runners) — encrypted at rest, masked in UI Workspace-wide (any caller with the CONFIG_API_KEY can read) Masked in UI; readable via API with the key
Service Secrets ({{secrets.name}}) Credentials needed by a single catalog service instance — CouchDB admin password, S3 access keys, MariaDB root password Per-service-instance (one instance only) Never readable after creation; stored as a Kubernetes Secret

The Key Distinction: Workspace vs Instance Scope

The parameter store UI looks like a .env file, but it is not. Values in the parameter store are workspace-scoped: every service running in your workspace can read every key. Even when a value is marked secret: true and stored encrypted, it is still readable by any caller that has the workspace's CONFIG_API_KEY.

Service Secrets are different. A secret named mysecret on the CouchDB service page is bound to that service instance. It cannot be read by your video transcoder, your web runner, or any other service in the workspace. It is never returned via API after creation.

If you need per-service credential isolation, Service Secrets are the only option.

When to Use Each

Use plaintext parameter store entries when: - The value is not sensitive (base URLs, feature flags, environment names, port numbers) - Multiple services in the same workspace should share the same value - You want the value visible in the dashboard for easy inspection

Use encrypted parameter store entries (secret: true) when: - A runner-hosted app (Web Runner, Python Runner, WASM Runner, .NET Runner, Golang Runner) needs a credential as process.env.STRIPE_KEY or similar - You accept that any caller with the workspace CONFIG_API_KEY can read the decrypted value - The value must be masked in the dashboard UI

Use Service Secrets when: - A catalog service instance (CouchDB, MariaDB, MinIO, etc.) needs a credential in its configuration - You need the credential to be isolated to one service instance - You want the value to be unreadable after it is set (write-once, never-read-back)

Runners cannot reference Service Secrets directly. Service Secrets are injected at catalog service startup as Kubernetes Secrets. They are not available as environment variables to your runner workloads. For runner credentials, encrypted parameter store entries are the correct mechanism.

Before/After Examples

Example 1: Per-Service CDN API Key

A customer building a media stack had three services: an ingest service, a packaging service, and a CDN integration service. They put the CDN API key into the plaintext parameter store because it was the first place they saw for configuration.

The problem: every service in the workspace could read CDN_API_KEY from the parameter store. If any of those services were ever compromised, the attacker would also have the CDN key.

Before (wrong approach):

# This stores CDN_API_KEY in the parameter store — readable by ALL services in the workspace
npx @osaas/cli create eyevinn-app-config-svc myconfig -o RedisUrl="redis://..."

# Then via API or MCP:
# Set CDN_API_KEY = "cdn-live-xxxxx" (plaintext, visible in dashboard)

Or with an AI agent connected to OSC MCP:

"Set CDN_API_KEY to cdn-live-xxxxx in myconfig"

After (correct approach): Store the CDN key as a Service Secret bound to only the ingest service instance.

# Via CLI: create the ingest service and pass the CDN key as a Service Secret reference
# First create the secret in the web console under the ingest service's "Service Secret" tab,
# name it "cdnkey", then reference it when creating the instance:
npx @osaas/cli create eyevinn-ingest-service myingest \
  -o CdnApiKey="{{secrets.cdnkey}}"

Or with an AI agent connected to OSC MCP:

"Create a service secret called cdnkey on the ingest service, then create
an ingest instance referencing {{secrets.cdnkey}} for CdnApiKey"

The CDN key is now a Kubernetes Secret mounted only inside the ingest pod. The packaging service and CDN integration service have no path to read it.

Example 2: Runner App Stripe Key

A customer running a Node.js billing app in Web Runner added their Stripe live key to the parameter store as a plaintext value.

The problem: STRIPE_KEY=sk_live_xxx appeared in the dashboard under the Store tab, visible to any team member with workspace access. It was also returned in plaintext from the config API without any authentication header.

Before (wrong approach):

# Plaintext Stripe key — visible in dashboard, readable without auth
curl -X POST https://myconfig.eyevinn-app-config-svc.auto.prod.osaas.io/api/v1/config \
  -H "Content-Type: application/json" \
  -d '{"key":"STRIPE_KEY","value":"sk_live_xxx"}'

After (correct approach): Recreate the store with encrypted secrets enabled, then mark the key as a secret.

Step 1: If your existing store does not have encryption enabled, you need to recreate it. Encryption can only be enabled at creation time.

"Set up a new parameter store called myconfig with encrypted secrets enabled"

The agent calls setup-parameter-store which generates a CONFIG_API_KEY. Copy and store this key as an OSC Service Secret on your Web Runner instance before the conversation continues.

Step 2: Set the Stripe key with secret: true:

"Set STRIPE_KEY to sk_live_xxx as a secret in myconfig"

The agent calls set-parameter with secret: true. The value is stored encrypted at rest and shown as *** in the dashboard.

Step 3: Your Web Runner app receives it unchanged as process.env.STRIPE_KEY. No code changes needed. The encryption and masking happen in the platform layer.

# Verify: plaintext response is only returned with the CONFIG_API_KEY header
curl https://myconfig.eyevinn-app-config-svc.auto.prod.osaas.io/api/v1/config/STRIPE_KEY
# Returns: {"key":"STRIPE_KEY","value":"***"}

curl https://myconfig.eyevinn-app-config-svc.auto.prod.osaas.io/api/v1/config/STRIPE_KEY \
  -H "Authorization: Bearer <CONFIG_API_KEY>"
# Returns: {"key":"STRIPE_KEY","value":"sk_live_xxx"}

Decision Checklist

Before storing a credential, ask these questions in order:

  1. Is this value sensitive at all? If no, use a plaintext parameter store entry.
  2. Is this credential for a catalog service instance (CouchDB, MariaDB, MinIO, etc.)? If yes, use a Service Secret and reference it as {{secrets.name}} in the instance parameters.
  3. Is this credential for a runner workload (Web Runner, Python Runner, WASM Runner)? If yes, use an encrypted parameter store entry with secret: true.
  4. Do you need per-service credential isolation? If yes, use Service Secrets. Encrypted parameter store values are still readable workspace-wide.