Skip to content

Credentials

Cornus can hand a running workload a secret — cloud credentials, an LLM API key, anything — without the secret ever entering the image, the deploy spec, or the pod spec. The credential is minted on your machine (from your own local credentials), relayed through the server over the live deploy-attach connection, and delivered into the container by the per-pod caretaker sidecar — fetched on demand, TTL-cached, and refreshed as it nears expiry. Its companion feature, routing a workload's outbound traffic through the caller, is client-side egress.

How it works

It is declared as a credentials: block in the deploy spec — or as x-cornus-credentials: on a Compose service, which takes the same fields (see Compose below). It is realized on the kubernetes backend over a session the client holds for the workload's lifetime to answer fetches, so cornus deploy --detach and the host backends reject it. Each entry under sources: names a client-side backend that produces the secret and one or more deliveries that surface it to the container.

Only the backend name and non-secret config ever travel to the server; the secret is produced by the backend at fetch time.

Source backends

Each backend mints from the caller's own environment.

backendMints fromNotes
staticliteral config values (or a file)
execstdout of config.commandJSON, or a single raw value under config.key
enva client env var (config.var)e.g. ANTHROPIC_API_KEY
aws-stsshort-lived AWS creds via STS, using your AWS credential chainneeds the credaws-tagged binary; modes auto / assume-role / session-token / passthrough
anthropic / claude-code / codexyour local LLM loginshort-lived tokens re-read near expiry
github-cliyour local gh auth loginruns gh auth token; hostname for GitHub Enterprise, user to pick an account

Delivery kinds

deliveries[].kind defaults to endpoint.

  • endpoint — the caretaker serves the credential from a loopback HTTP endpoint. provider: generic (default) serves the native contract (GET /credentials/<name> yielding {"values":{...},"expiration":"..."}), advertised to the app via CORNUS_CREDENTIALS_URL / CORNUS_CREDENTIAL_<NAME>_URL. provider: aws-imds renders the credential in the shapes an unmodified AWS SDK expects — see Source a credential from AWS STS below. The auth-injecting providers (anthropic-proxy, openai-proxy, github-proxy) go further still and hold the credential themselves, so the container never receives it at all.
  • file — materialize to path: in a shared volume, format: one of json (default), env (KEY=VALUE lines), raw (a single value), or aws-credentials (an ini profile). Written 0600.
  • env — inject envVar: into the app container. The value is fetched once at deploy time and stored in a Kubernetes Secret referenced via secretKeyRef (so it is not a pod-spec literal), but it is static (no refresh) and lives in etcd — prefer endpoint / file for short-lived or never-materialized secrets.

Trust

The secret is answered per fetch over the live session and is never in the spec or the wire control frames. A workload may fetch only the credential names its own deploy session declared — the session id is an unguessable capability, checked at the server relay and again in the caretaker. The auth proxies strip client-supplied auth before injecting the real credential, so a workload can neither read the raw secret nor spoof it.

See also: deploy spec

Broker a credential into a workload without baking it into the image

Declare a credentials: block; the secret is minted on your machine and delivered by the caretaker, never entering the image, spec, or pod spec.

yaml
name: app
image: localhost:5000/app:v1
credentials:
  sources:
    - name: db
      backend: static                              # produce the secret on the client
      config: { username: app, password: s3cret }  # non-secret config for other backends
      deliveries:
        - { kind: endpoint, provider: generic }        # GET $CORNUS_CREDENTIALS_URL -> JSON
        - { kind: file, path: /creds/db.json, format: json }
  • Realized on the kubernetes backend over a foreground cornus deploy --server session (the client answers fetches for the workload's lifetime, so --detach rejects it).
  • deliveries[].kind is endpoint (default), file, or env; a workload may fetch only the credential names its own session declared.

See also: deploy spec

Proxy an LLM API or inject an API key into a workload

The anthropic-proxy and openai-proxy endpoint providers go one step further than serving the credential: the caretaker runs a loopback reverse proxy to the vendor API and injects the auth header itself, so the workload calls the LLM with no key of its own. It sets ANTHROPIC_BASE_URL / OPENAI_BASE_URL on the app — plus a placeholder ANTHROPIC_API_KEY / OPENAI_API_KEY, because SDKs and CLIs refuse to start when their key variable is missing even though the proxy is what actually holds the credential — strips any client-sent auth, and adds the real credential per request. So a coding-agent workload can ride your own Claude Code / Codex login without the secret ever entering the container.

yaml
credentials:
  sources:
    - name: claude
      backend: claude-code                  # or: anthropic / env (config.var: ANTHROPIC_API_KEY)
      deliveries:
        - kind: endpoint
          provider: anthropic-proxy         # sets ANTHROPIC_BASE_URL; injects the header
          # upstream: https://my-gateway    # optional: Azure OpenAI, an on-prem gateway, a mock
  • upstream points the proxy at any Anthropic- or OpenAI-compatible gateway instead of the vendor default (https://api.anthropic.com / https://api.openai.com).
  • To inject a plain env var instead, use backend: env with config.var and an env-kind delivery (static, stored in a Kubernetes Secret; prefer endpoint / file for short-lived secrets).

API keys and OAuth tokens

The proxy handles both credential shapes transparently, so it works with a plain API key or an OAuth login token with no change to the workload:

  • An API key is sent in the vendor's normal key header (x-api-key for Anthropic).
  • An OAuth token — for example an sk-ant-oat... token from a claude / ant auth login sign-in — is sent as Authorization: Bearer <token> with the anthropic-beta: oauth-2025-04-20 header the Anthropic API requires for OAuth bearer tokens. The proxy picks the credential value in order: oauth_token (forces OAuth), api_key (forces API-key), else value / token.

The anthropic / claude-code / codex source backends read your local login store and refresh the short-lived OAuth access token as it nears expiry (codex reads the ChatGPT sign-in's tokens.access_token, falling back to an API key), so a long-running agent keeps working without you re-authenticating — and the token still never enters the container.

See also: deploy spec

Source a credential from AWS STS

Mint short-lived AWS credentials from your own AWS credential chain and surface them in the SDK's expected shape.

yaml
credentials:
  sources:
    - name: aws
      backend: aws-sts
      config: { role_arn: arn:aws:iam::123456789012:role/app, region: us-east-1 }
      deliveries:
        - { kind: endpoint, provider: aws-imds, wellKnown: true }
        - { kind: file, path: /root/.aws/credentials, format: aws-credentials }
  • aws-sts uses your AWS credential chain via STS; it needs the credaws-tagged binary and supports modes auto / assume-role / session-token / passthrough.

The aws-imds endpoint provider renders a brokered credential in the shapes AWS SDKs already look for, so an unmodified SDK picks it up with no code and no app change. The adapter is pure HTTP with no AWS SDK dependency of its own, and it answers two shapes over one endpoint:

  • ECS container credentialsGET /creds returns {AccessKeyId, SecretAccessKey, Token, Expiration}.
  • EC2 IMDSv2PUT /latest/api/token, then GET /latest/meta-data/iam/security-credentials/<role> (the listing advertises a single synthetic role, cornus). IMDSv1 clients simply skip the token step.

How the SDK reaches it depends on wellKnown:

wellKnownBindingHow the SDK finds itNeeds
false (default)loopbackCornus injects AWS_CONTAINER_CREDENTIALS_FULL_URI=http://<loopback>/creds, the standard ECS-credentials env var AWS SDKs honor.nothing extra
truelink-local 169.254.169.254:80 in the pod netnsthe SDK's built-in IMDSv2 path — no env var at all, exactly as on a real EC2 instanceNET_ADMIN on the caretaker

This is a delivery adapter, not a general-purpose metadata service you run: it serves exactly the one brokered credential for the workload's session. The same mechanism is how GCP / Azure metadata adapters would slot in.

See also: deploy spec

Give a workload GitHub API access from your own gh login

The github-cli source runs gh auth token on your machine, and the github-proxy endpoint provider injects that token into the workload's GitHub REST API calls — so the container makes authenticated calls while holding no token at all.

yaml
credentials:
  sources:
    - name: gh
      backend: github-cli
      ttl: 1h                                # gh reports no expiry; see below
      deliveries:
        - kind: endpoint
          provider: github-proxy             # sets GITHUB_API_URL; injects the header
          # upstream: https://ghe.corp/api/v3   # GitHub Enterprise Server

The token is read from wherever gh keeps it — the OS keyring on most machines, so this works where reading ~/.config/gh/hosts.yml would not. gh also honors GH_TOKEN / GITHUB_TOKEN (and GH_ENTERPRISE_TOKEN for other hosts), so the same spec works unchanged in CI. Config keys: hostname (GitHub Enterprise), user (pick between logged-in accounts), command, timeout, key.

If gh is not on your PATH, or is installed under another name, set CORNUS_GH_BIN on the machine holding the deploy session — that is where the token is minted, so that is where the variable is read. It adapts a shared spec to one machine without editing the spec; an explicit config.command is a deliberate choice by whoever wrote the spec (a wrapper that must not be bypassed, say) and still wins over it. Precedence: config.command, then CORNUS_GH_BIN, then gh.

sh
CORNUS_GH_BIN=/opt/homebrew/bin/gh cornus deploy --server -f app.yaml

Set an explicit ttl:. gh auth token reports no expiry, so the 5-minute default re-runs gh — and possibly touches your keyring — every five minutes per replica. An hour is plenty for a token with no expiry.

This is the REST API, not git

git clone and git push do not ride this proxy: git-over-HTTPS goes straight to github.com:443 and is unaffected. Nor does the gh CLI itself work against it — gh takes a hostname rather than a base URL and always speaks HTTPS, so it cannot be pointed at the plaintext loopback sidecar. For either case, deliver the token itself instead and accept that it enters the container:

yaml
deliveries:
  - { kind: endpoint }                                   # GET $CORNUS_CREDENTIALS_URL -> JSON
  - { kind: file, path: /run/secrets/gh-token, format: raw }

Pointing your client at the proxy

Only @actions/github reads GITHUB_API_URL on its own. Every other client needs one line:

Client
Octokit (JS)new Octokit({ baseUrl: process.env.GITHUB_API_URL })
PyGithubGithub(base_url=os.environ["GITHUB_API_URL"])
go-githubc := github.NewClient(nil); c.BaseURL, _ = url.Parse(os.Getenv("GITHUB_API_URL") + "/")

For go-github use BaseURL directly, with a trailing slashnotWithEnterpriseURLs, which appends api/v3/ to any host that does not look like api.github.com, so a loopback proxy address would become https://api.github.com/api/v3/... and 404.

GITHUB_GRAPHQL_URL is set alongside GITHUB_API_URL for the default upstream. It is deliberately omitted for a GitHub Enterprise upstream: GHES serves REST under /api/v3 and GraphQL under the sibling /api/graphql, which a single proxy cannot reach, and advertising a wrong URL would send the client to GHES with no credential.

No GITHUB_TOKEN is set in the container, on purpose — a placeholder there would be picked up by gh, by git credential helpers, and by any script calling api.github.com directly, turning "no credential" into a confusing 401 far from its cause. If a client insists the variable exist, set a dummy yourself in the spec's env:; the proxy strips whatever the client sends.

Two things to get right

Keep hostname and upstream in step. The source's hostname is resolved on your machine and the delivery's upstream on the deploy path; nothing checks that they agree. A github-cli source with hostname: ghe.corp and a default upstream sends a valid GitHub Enterprise credential to api.github.com. Always configure the pair together.

Mind the scope. A gh auth login token typically carries repo (read/write to every private repository you can reach), read:org, and often workflow, and anything running in the pod can use the loopback proxy as you, with no way to narrow it — a much wider blast radius than an LLM key. A runaway loop also burns your own rate limit. For anything but a workload you fully trust, mint a fine-grained PAT and deliver it with static / env / exec instead.

Not covered: uploads.github.com (release assets) is a separate host; absolute URLs inside response bodies are not rewritten (Link and Location headers are, so pagination and redirects stay on the proxy); and a GitHub Enterprise instance behind a private CA needs that CA in the caretaker image or via SSL_CERT_FILE.

See also: deploy spec

From a Compose file

A Compose service declares the same block as x-cornus-credentials:, so a whole stack — the agent, its database, its cache — comes up with one cornus compose up and the agent still rides your own login.

yaml
services:
  agent:
    image: localhost:5000/agent:v1
    x-cornus-credentials:
      - name: claude
        backend: claude-code
        deliveries:
          - { kind: endpoint, provider: anthropic-proxy }
  db:
    image: postgres:16
  • The block is the bare source list above, or the spec's object form (sources: holding the same list) — a spec's block pastes in unchanged.
  • Delivery fields take Compose's snake_case spelling (well_known, env_var, value_key); the spec's camelCase spellings work too. A key that is neither is an error, not a silently ignored field.
  • The declaring service holds a deploy-attach session for its lifetime, and the up line names the reason (brokering credentials). Under cornus compose up -d the project's background agent holds it — so unlike cornus deploy --detach, a detached compose up supports credentials.
  • The host backends do not implement credential delivery: they either refuse the deploy or warn and ignore the block.
  • A project-level x-cornus-credentials: block is the default for every service that declares none; a service block overrides it wholesale. Each inheriting service holds its own session.

See also: cornus compose

Released under the Apache-2.0 License.