Skip to content

Secrets

Rafael Gumieri edited this page Jun 15, 2026 · 4 revisions

Secrets

Nenya loads secrets from JSON files. Multiple sources are supported with priority ordering. All JSON files support the same structure. Fields can be split across multiple files.

Secret Paths (Priority Order)

Priority Path Description
1 $CREDENTIALS_DIRECTORY/secrets systemd single file (highest priority)
2 $CREDENTIALS_DIRECTORY/*.json systemd directory
3 $NENYA_SECRETS_DIR/*.json configurable via env var
4 /run/secrets/nenya/*.json K8s/Docker standard path

The first path that exists is used. If multiple JSON files exist in a directory, they are merged alphabetically (last-wins for duplicate keys).

JSON Format

{
  "client_token": "nk-...",
  "provider_keys": {
    "gemini": "AIza...",
    "deepseek": "sk-...",
    "openai": "sk-proj-..."
  },
  "api_keys": {
    "dev-user": {
      "name": "dev-user",
      "token": "nk-...",
      "roles": ["user"],
      "allowed_agents": ["build", "plan"],
      "enabled": true
    }
  }
}
Field Type Required Description
client_token string Yes Admin bearer token for /v1/* endpoints
provider_keys object No Map of provider name → API key
api_keys object No Map of key ID → ApiKey config (RBAC)

Generate Tokens

Client token

openssl rand -hex 32
# Output: nk-abc123def456... (prefix + 48 hex chars)

API keys (RBAC)

# Generate key with roles (alias: a2a)
go run ./cmd/nenya keygen --name "dev-user" --roles user,read-only --agents build,plan

Manual configuration:

{
  "api_keys": {
    "dev-user": {
      "name": "dev-user",
      "token": "nk-...",
      "roles": ["user"],
      "allowed_agents": ["build", "plan"],
      "allowed_endpoints": ["GET /v1/models", "POST /v1/chat/completions"],
      "enabled": true
    }
  }
}

ApiKey Field Reference:

Field Type Required Description
name string Yes Human-readable key identifier (for logging/metrics)
token string Yes API key token (minimum 16 characters, prefix nk- recommended)
roles []string Yes One or more: "admin", "user", "read-only"
allowed_agents []string No Agent names this key can access (empty = all agents)
allowed_endpoints []string No HTTP method + path allowlist (empty = role-based defaults)
created_at string No ISO 8601 timestamp for audit trail
expires_at string No ISO 8601 timestamp for key expiration
enabled bool No Enable/disable key without deletion

Roles:

Role Permissions Agent Access Endpoint Access
admin Full access (bypasses RBAC) All agents All endpoints
user Chat, models, embed Scoped by allowed_agents (or all if empty) All non-admin endpoints
read-only Models, metrics only Scoped by allowed_agents (or all if empty) GET requests only

Examples:

Read-only key for model catalog:

{
  "api_keys": {
    "model-reader": {
      "name": "Model Catalog Reader",
      "token": "nk-abc123...",
      "roles": ["read-only"],
      "allowed_agents": [],
      "enabled": true
    }
  }
}

User key scoped to specific agents with endpoint allowlist:

{
  "api_keys": {
    "build-agent-user": {
      "name": "Build Agent User",
      "token": "nk-def456...",
      "roles": ["user"],
      "allowed_agents": ["build", "test"],
      "allowed_endpoints": ["GET /v1/models", "POST /v1/chat/completions", "POST /v1/embeddings"],
      "enabled": true
    }
  }
}

Admin key with expiration:

{
  "api_keys": {
    "ops-admin": {
      "name": "Operations Admin",
      "token": "nk-ghi789...",
      "roles": ["admin"],
      "expires_at": "2026-12-31T23:59:59Z",
      "enabled": true
    }
  }
}

Provider Keys

The provider_keys object maps provider names to their API keys. See Providers for the full list of built-in providers.

Legacy single-key format:

{
  "provider_keys": {
    "openai": "sk-proj-..."
  }
}

Multi-account format (recommended for high-volume providers):

For providers with multiple API keys or credentials, use the accounts field in the provider config instead:

{
  "providers": {
    "openai": {
      "url": "https://api.openai.com/v1/chat/completions",
      "accounts": [
        { "id": "account-1", "type": "apikey", "credential": "sk-proj-xxxxx" },
        { "id": "account-2", "type": "apikey", "credential": "sk-proj-yyyyy" }
      ],
      "ratelimit_max_rpm": 500
    },
    "google": {
      "url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
      "accounts": [
        { "id": "oauth-dev", "type": "oauth", "credential": "ya29.a0..." }
      ]
    }
  }
}

Multi-Account Behavior

  • LRU Selection: Least-recently-used strategy with mutex-guarded access
  • Error Classification: 6 error classes (auth, rate_limit, quota, capacity, server, unknown) with semantic cooldowns
  • Exponential Backoff: Failed accounts receive exponentially increasing backoff (±10% jitter) up to level 15
  • Model Locks: Individual model+provider combinations locked during cooldown
  • Account State Persistence: State persisted in <provider>.accounts.json files in config directory

At least one provider key must be present for the corresponding provider to work. If accounts is configured, it takes precedence over provider_keys for that provider.

Supported Credential Types

Type Description
apikey Standard API key (most providers)
oauth OAuth bearer token
cookie Cookie-based authentication

Account Status

Status Behavior
active Normal operation, eligible for selection
error Last request failed (short cooldown)
disabled Permanently excluded from rotation

systemd

Single file

# Create secrets file
cat > /etc/nenya/secrets.json << 'EOF'
{
  "client_token": "nk-$(openssl rand -hex 32)",
  "provider_keys": {
    "deepseek": "sk-...",
    "gemini": "AIza..."
  }
}
EOF

# Configure systemd
cat > /etc/systemd/system/nenya.service << 'EOF'
[Service]
LoadCredential=secrets:/etc/nenya/secrets.json
EOF

The gateway reads from $CREDENTIALS_DIRECTORY/secrets.

Directory (merged)

mkdir -p /etc/nenya/secrets.d

cat > /etc/nenya/secrets.d/01-client.json << 'EOF'
{"client_token": "nk-..."}
EOF

cat > /etc/nenya/secrets.d/02-providers.json << 'EOF'
{"provider_keys": {"deepseek": "sk-...", "gemini": "AIza..."}}
EOF

cat > /etc/nenya/secrets.d/03-api-keys.json << 'EOF'
{"api_keys": {...}}
EOF

No systemd change needed — gateway auto-detects secrets.d/*.json.

Container / Kubernetes

Docker Compose

Mount secrets as files. Use NENYA_SECRETS_DIR to point to the mount point:

services:
  nenya:
    image: ghcr.io/gumieri/nenya:latest
    ports:
      - "8080:8080"
    volumes:
      - ./config:/etc/nenya:ro
      - ./secrets:/run/secrets/nenya:ro
    environment:
      NENYA_SECRETS_DIR: /run/secrets/nenya
    restart: unless-stopped

Example secrets directory:

secrets/
├── 01-client.json     -> {"client_token": "nk-..."}
├── 02-providers.json  -> {"provider_keys": {"deepseek": "sk-..."}}
└── 03-keys.json       -> {"api_keys": {...}}

Kubernetes

Mount a Secret as files. Use NENYA_SECRETS_DIR env var:

apiVersion: v1
kind: Secret
metadata:
  name: nenya-secrets
type: Opaque
stringData:
  01-client.json: |
    {"client_token": "nk-..."}
  02-providers.json: |
    {"provider_keys": {"deepseek": "sk-...", "gemini": "AIza..."}}
  03-keys.json: |
    {"api_keys": {"dev-user": {...}}}
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nenya
spec:
  replicas: 1
  template:
    spec:
      containers:
        - name: nenya
          image: ghcr.io/gumieri/nenya:latest
          env:
            - name: NENYA_SECRETS_DIR
              value: "/run/secrets/nenya"
          volumeMounts:
            - name: config
              mountPath: /etc/nenya
              readOnly: true
            - name: secrets
              mountPath: /run/secrets/nenya
              readOnly: true
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
      volumes:
        - name: config
          configMap:
            name: nenya-config
        - name: secrets
          secret:
            secretName: nenya-secrets

Note: NENYA_SECRETS_DIR is optional. If unset, the gateway falls back to /run/secrets/nenya.

Security Notes

  • Never commit secrets to version control
  • Set chmod 600 on secrets files
  • Use systemd credential mechanism for secure in-memory storage
  • Rotate tokens periodically
  • Path traversal (..) is rejected for all secret paths

Secure Memory (mlock)

Nenya uses mlock to keep tokens in RAM, preventing them from swapping to disk. Requires:

[Service]
LimitMEMLOCK=infinity
LimitCORE=0

If this setting is missing, Nenya will fail to start with error:

secure memory allocation failed

See Security for implementation details.

Migration from Environment Variables

If you previously used NENYA_CLIENT_TOKEN and NENYA_PROVIDER_KEY_* environment variables, migrate to JSON files:

Before (deprecated):

export NENYA_CLIENT_TOKEN="nk-..."
export NENYA_PROVIDER_KEY_DEEPSEEK="sk-..."
export NENYA_PROVIDER_KEY_GEMINI="AIza..."

After:

// 01-client.json
{"client_token": "nk-..."}

// 02-providers.json
{"provider_keys": {"deepseek": "sk-...", "gemini": "AIza..."}}

Environment variables are no longer supported. Use JSON files only.

Platform Secret Hardening

Linux (systemd Deployment)

sudo mkdir -p /etc/nenya
sudo chown root:nenya /etc/nenya
sudo chmod 750 /etc/nenya

The LoadCredential mechanism passes files via memfd (anonymous memory), avoiding disk reads after boot.

macOS

macOS does not support systemd credentials. Recommended approaches:

Option A: Named file with strict permissions

mkdir -p ~/.config/nenya/secrets.d
chmod 700 ~/.config/nenya/secrets.d
CREDENTIALS_DIRECTORY=~/.config/nenya/secrets.d ./nenya -config config.json

Option B: macOS Keychain (preferred)

security add-generic-password -s "nenya-secrets" -a "$USER" \
  -w "$(cat creds/secrets.json)" -U

See Also

Getting Started

Core Concepts

Reference

Operations

  • Demo — Test all pipeline tiers
  • Troubleshooting — Common issues and solutions
  • FAQ — Frequently asked questions
  • Security — Security policy and vulnerability reporting

Project

Clone this wiki locally