Company standard · Owner: nir

Secret Handling Policy

How applications and CI handle secrets and environment variables using keyctl.

Source of truth. The canonical, versioned copy of this policy lives in the internal wiki: keyctl — Secret Handling Policy (Confluence). This page is the public rendering; the in-repo agent guidance mirrors it as a Cursor skill at .cursor/skills/keyctl-secrets/SKILL.md. It contains no secret values and no instruction beyond describing how the keyctl workflow is meant to be used.

Overview

Company standard: applications do not use local secrets. Secret values live remotely in Azure Key Vault and are fetched at runtime by the keyctl CLI. Plaintext secret values are not created, committed, or populated in the repo or on disk.

The committed setup key (write-only by default)

For internal, trusted repos, a write-only setup key (the -wo key) MAY be committed in keyctl.yaml as setupKey:. This lets the CLI auto-enroll a device on first use, so nobody runs keyctl login manually. A write-only key is safe to commit because:

Admin keys and the global admin token are out of scope for committing — they stay out-of-band. A read-only (-ro) key may also be committed when repo holders are intended to read secrets at runtime, and only when everyone with repo access is already trusted to read that project's secrets.

Running an app with secrets

Instead of npm start, python app.py, go run ./cmd/server, etc., wrap it:

keyctl run -- npm start
keyctl run -- python app.py

keyctl run reads keyctl.yaml, fetches the declared secrets over HTTPS, injects them as environment variables into the child process only, then discards them. Nothing is written to disk. Reading secrets requires a read-capable credential (admin or read-only, or the write-only device that wrote them).

The config file (commit this)

keyctl.yaml at the repo root maps environment variable names to Key Vault secret keys. Values are never stored here, so it is safe to commit.

project: payments-service
apiUrl: https://api.getkeyctl.com
setupKey: <write-only setup key>  # OPTIONAL, -wo key — enables auto-enroll for writes
secrets:
  DATABASE_URL: database-url      # ENV_VAR_NAME: keyvault-secret-key
  STRIPE_KEY:   stripe-secret-key

Each project's secrets are namespaced by the project name in the vault (stored as proj--<project>--sec--<key>). Scaffold config with keyctl init --project <name>.

Authentication model

keyctl uses a three-tier credential model:

CredentialLifetimeUsed forWhere it lives
Setup keyOne-time (enrollment only)keyctl login to enroll a deviceHanded out by an admin, out-of-band
Refresh tokenLong-lived, revocableMinting access tokensOS keychain (never a file)
Access token (JWT)~30 min, rotated each sessionReading/writing secretsIn memory only

Enrollment sends a setup key to POST /v1/enroll, receives a device-bound refresh token stored in the OS keychain, and discards the setup key. Every command then silently exchanges the refresh token for a fresh short-lived access token.

Setup key roles

RoleCan readCan add/updateCan rotate / issue keysCommit to repo?
write-only (-wo)Own secrets only (read-your-own-writes)YesNoYes — safe
read-only (-ro)All project secretsNoNoOnly if repo holders may read
admin (-admin)All project secretsYesYesNever — this is the master key

Read-your-own-writes (write-only devices)

A write-only device is automatically recorded as the owner of every secret it creates and can read back its own secrets. Other devices — even those holding the same committed write-only key — cannot read those secrets (different device ID → 403). Ownership is also enforced on overwrites and deletes, preventing repo-clone poisoning.

Common commands

TaskCommandWho runs it
Mint a setup keykeyctl project setup-key <p>Admin
Enroll a device (one-time)keyctl login --project <p> --key <setup-key>Human
Run app with secretskeyctl run -- <command>Agent or human
Set a secret valuekeyctl secret set <kv-key> <value> --project <p>Agent or human (write-only/admin)
Read a secretkeyctl secret get <kv-key> --project <p>Owner device, or read-only/admin
List secret nameskeyctl secret list --project <p>Agent or human
Rotate / mint keyskeyctl project rotate-key <p>Admin (human)

In CI, only the one-time setup key is stored (as KEYCTL_KEY); keyctl enrolls in-memory and mints an access token per run. Individual secrets are never added to CI variables.

env:
  KEYCTL_KEY: ${{ secrets.KEYCTL_SETUP_KEY }}
  KEYCTL_API_URL: https://api.getkeyctl.com
run: keyctl run -- <command>

Admin operations are human-only

Admin setup keys and the global admin token are human-only credentials. They are out of scope for automated tooling and are not expected to appear in generated output, logs, or commits. Rotating and minting keys, running keyctl login with an admin key, and demo-project resets are admin actions handled by a person.

Interim measure (before keyctl is set up)

Until a team fully adopts keyctl, an existing .env is tolerated only when it is encrypted at rest with the project setup key rather than left in plaintext: keep the plaintext file out of git, store only the encrypted form, and treat it as temporary — migrate the values into keyctl and delete the local file when possible. Prefer keyctl run over any local file whenever it is available.

Migrating a hardcoded secret into keyctl

When a hardcoded secret is found in code, the goal is to move the value into keyctl and out of the source. This is human-gated and must be done without leaking the value:

Questions?

Email nir@dash.security or ask in #keyctl on Slack.