How applications and CI handle secrets and environment variables using keyctl.
.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.
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.
.env, .env.local, config files, or code.keyctl run -- <command>.keyctl.yaml by name only, never by value.
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.
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).
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>.
keyctl uses a three-tier credential model:
| Credential | Lifetime | Used for | Where it lives |
|---|---|---|---|
| Setup key | One-time (enrollment only) | keyctl login to enroll a device | Handed out by an admin, out-of-band |
| Refresh token | Long-lived, revocable | Minting access tokens | OS keychain (never a file) |
| Access token (JWT) | ~30 min, rotated each session | Reading/writing secrets | In 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.
| Role | Can read | Can add/update | Can rotate / issue keys | Commit to repo? |
|---|---|---|---|---|
write-only (-wo) | Own secrets only (read-your-own-writes) | Yes | No | Yes — safe |
read-only (-ro) | All project secrets | No | No | Only if repo holders may read |
admin (-admin) | All project secrets | Yes | Yes | Never — this is the master key |
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.
| Task | Command | Who runs it |
|---|---|---|
| Mint a setup key | keyctl project setup-key <p> | Admin |
| Enroll a device (one-time) | keyctl login --project <p> --key <setup-key> | Human |
| Run app with secrets | keyctl run -- <command> | Agent or human |
| Set a secret value | keyctl secret set <kv-key> <value> --project <p> | Agent or human (write-only/admin) |
| Read a secret | keyctl secret get <kv-key> --project <p> | Owner device, or read-only/admin |
| List secret names | keyctl secret list --project <p> | Agent or human |
| Rotate / mint keys | keyctl 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 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.
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.
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:
gitleaks detect / the committed pre-commit hook) to avoid false positives from test fixtures and placeholder values.printf '%s' "$VALUE" | keyctl secret set stripe-key --project payments-service --stdin
process.env.X and map it in keyctl.yaml, then run via keyctl run.Email nir@dash.security or ask in #keyctl on Slack.