Skip to content
Back to skills

Manage Secrets

BSecurity

Use when a repo handles credentials — decide where each secret lives per host (Vercel, GitHub environments, sops+age for Compose, Supabase), keep them out of images, logs, bundles and state, set up .env.example, rotation and secret scanning.

  • 5 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 10, 2026
developmentshellbashvuedockergitapidatabasefrontendbackendsecurity

Works with

  • cli
  • api

Security analysis

B84/100
  • criticalSends environment variables or credentials to an external URL
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 2 files and shows the line behind each finding

Scanned October 10, 2026

npx -y skills add VelimirMueller/lab-claude-skills --skill manage-secrets --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Manage Secrets?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Manage Secrets
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/velimirmueller-manage-secrets/badge)](https://www.skillsdirectory.com/skills/velimirmueller-manage-secrets)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: manage-secrets
description: Use when a repo handles credentials — decide where each secret lives per host (Vercel, GitHub environments, sops+age for Compose, Supabase), keep them out of images, logs, bundles and state, set up .env.example, rotation and secret scanning.
---

# Manage Secrets

One rule: a secret has exactly one home per environment, and that home is not the repo, the image, or a log.
Everything else in this skill follows from it.

## 1. Audit current state

```bash
cat .claude/stack-profile.md 2>/dev/null || cat ~/.claude/stack-profile.md 2>/dev/null   # hosting, database.host, iac, ci
git ls-files | grep -E '(^|/)\.env($|\.)' | grep -v '\.example$'                          # tracked env files: must be empty or *.sops.*
grep -nE '^\.env|\.tfstate|\.tfvars' .gitignore                                          # ignored?
ls .env.example .sops.yaml deploy/envs/*/secrets.sops.env 2>/dev/null
grep -rnE '^\s*(ARG|ENV)\s+\w*(KEY|TOKEN|SECRET|PASSWORD|PASS)\w*' Dockerfile* 2>/dev/null   # secrets baked into layers
grep -rnE '(VITE|NEXT_PUBLIC|NUXT_PUBLIC|PUBLIC)_\w*(SECRET|TOKEN|PASSWORD|SERVICE_ROLE|PRIVATE)' --include='*.ts' --include='*.vue' --include='*.tsx' --include='.env*' . 2>/dev/null | head
git ls-files | grep -E '\.(tfstate|pem|key|p12)$'                                         # state and key material in git
gh secret list 2>/dev/null; gh secret list --env prd 2>/dev/null
gh api repos/{owner}/{repo} --jq '.security_and_analysis | {secret_scanning, secret_scanning_push_protection}' 2>/dev/null
command -v sops age gitleaks
```

Also scan history once: `gitleaks git --redact --no-banner` (reports file, rule and line, never the value). A hit means
the secret is burned: rotate it first, then remove it from the repo.

## 2. Decide what to do

- No finding in step 1 and the home table (step 3) is satisfied → "already in place", report the one-line status.
- Findings → fix in this order: **rotate** leaked secrets, **move** secrets to their home, **block** recurrence (scanning).
- Never print or paste secret values into the chat, logs, or the report. Use names and the first 4 characters at most.

## 3. Detect the track: where each secret lives

| Where it runs | Runtime secrets live in | Deploy credentials live in | Never |
|---|---|---|---|
| Vercel (`hosting: vercel`) | Vercel env vars, type **Secret**, per environment | GitHub `prd` environment secrets (`VERCEL_TOKEN`, …) only if Actions promotes | `NEXT_PUBLIC_*` / `VITE_*` for anything private |
| Hetzner / IONOS with Compose | **sops+age** file in the repo, decrypted on the host into tmpfs | GitHub environment secret (SSH key) | `.env` committed; secrets as image `ENV` |
| Supabase | Edge Function secrets (`supabase secrets set`), DB creds in Supabase | GitHub environment secrets (`SUPABASE_ACCESS_TOKEN`, `SUPABASE_DB_PASSWORD`) | `sb_secret_*` / `service_role` in a client |
| GitHub Actions | GitHub **environment** secrets, one set per env | OIDC where the target supports it ([pipeline-patterns](../set-up-delivery-pipeline/pipeline-patterns.md#rule-oidc-where-the-target-supports-it-a-scoped-short-lived-secret-where-it-does-not)) | Repository-level secrets for prd credentials |
| Local dev | `.env` (gitignored), copied from `.env.example` | n/a | Production values on a laptop |
| IaC ([set-up-opentofu](../set-up-opentofu/SKILL.md)) | Provider tokens in the CI environment; state in an encrypted remote backend | Environment secrets | Secrets in `*.tfvars` or in committed state |

Why each home is chosen: [secrets-patterns.md](./secrets-patterns.md). Host mechanics:
[deploy-to-hetzner](../deploy-to-hetzner/SKILL.md), [deploy-to-ionos](../deploy-to-ionos/SKILL.md),
[deploy-to-vercel](../deploy-to-vercel/SKILL.md).

## 4. Install only what is missing

```bash
mise use sops@3.13.3 age@1.3.2 gitleaks@8.30.1     # versions verified 2026-10-09; see ../_shared/stack-versions.md
```

Without mise: `brew install sops age gitleaks`. On a host that only decrypts: `sops` and `age` from the release page,
checksum verified.

## 5. Generate the seams

**`.env.example`** (committed, every variable the app reads, no real values, one comment per non-obvious var):

```dotenv
# Public: shipped to the browser. Never put a secret here.
VITE_API_URL=http://localhost:8787
# Private: server only.
DATABASE_URL=postgres://app:app@localhost:5432/app
SESSION_SECRET=            # openssl rand -base64 32
```

**`.gitignore`** gets `.env`, `.env.*`, `!.env.example`, `!*.sops.env`, `*.tfstate*`, `*.tfvars`, `*.pem`, `*.key`.

**The env schema module** (one file validates all env at startup and exports a typed object; nothing else reads the
environment): see [validate-env](../../frontend/validate-env/SKILL.md) for the pattern. Same rule on the server: parse once, fail fast.

**sops + age for self-hosted Compose.** One age key per host, plus one per human who must edit that environment:

```bash
age-keygen -o host-prd.key        # writes the private key file
age-keygen -y host-prd.key        # prints the public key for .sops.yaml; move host-prd.key to the host, mode 0400
```

```yaml
# .sops.yaml  (public keys only; this file is committed)
creation_rules:
  - path_regex: ^deploy/envs/dev/secrets\.sops\.env$
    age: >-
      age1<dev-host-public-key>,
      age1<alice-public-key>
  - path_regex: ^deploy/envs/stg/secrets\.sops\.env$
    age: >-
      age1<stg-host-public-key>,
      age1<alice-public-key>
  - path_regex: ^deploy/envs/prd/secrets\.sops\.env$
    age: >-
      age1<prd-host-public-key>,
      age1<lead-public-key>
```

```bash
export SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt         # explicit; the default differs on macOS
sops edit deploy/envs/prd/secrets.sops.env                        # creates or edits; plaintext only in the editor buffer
sops decrypt deploy/envs/prd/secrets.sops.env >/dev/null          # proves you hold a recipient key
```

Decrypt on the host, at deploy. The host's pre-up hook does this, not `deploy/deploy.sh`: CI never holds the private key or the plaintext. `run.sh` copies the release's `secrets.sops.env` into `$COMPOSE_DIR` and calls the hook before `compose up`. Install the hook at `/opt/deploy/hooks/pre-up.sh` (root-owned, 0755) and enable it with one line in `/etc/deploy/deploy.conf`, which `run.sh` sources, so the host (not the caller) decides that it runs:

```bash
HOOK_PRE_UP=/opt/deploy/hooks/pre-up.sh
```

```bash
#!/usr/bin/env bash
# /opt/deploy/hooks/pre-up.sh <compose_dir> — turn this release's sops file into what Compose reads
set -Eeuo pipefail
umask 077
dir="$1"
[[ -f "$dir/secrets.sops.env" ]] || exit 0          # environment without secrets
install -d -m 0700 /run/app "$dir/secrets"
SOPS_AGE_KEY_FILE=/etc/sops/age/keys.txt sops decrypt --input-type dotenv --output-type dotenv \
  "$dir/secrets.sops.env" > /run/app/secrets.env.tmp
mv /run/app/secrets.env.tmp /run/app/secrets.env    # app env_file; /run is tmpfs
# Self-hosted Postgres (Hetzner track) reads POSTGRES_PASSWORD_FILE from a Compose file secret,
# which must survive a reboot. Managed Postgres (IONOS DBaaS, Supabase) declares no such secret: skip.
if grep -q 'postgres_password' "$dir/compose.yaml"; then
  sed -n 's/^POSTGRES_PASSWORD=//p' /run/app/secrets.env > "$dir/secrets/postgres_password"
  [[ -s "$dir/secrets/postgres_password" ]] || { echo "pre-up: POSTGRES_PASSWORD missing in secrets.sops.env" >&2; exit 1; }
  chmod 0400 "$dir/secrets/postgres_password"
fi
```

A decrypt failure or a missing key exits non-zero and aborts `run.sh` before `compose up` (`set -e`; the hook is the last command of its `&&` list, so its status counts). Two honest limits: values passed through `env_file` are stored in the container config, so root on the host can read them with `docker inspect` — acceptable on a single-tenant VM where root already holds the age key; and the hook ran locally with sops 3.13.3 + age 1.3.2 on 2026-10-09 (decrypt, managed-database skip, missing `POSTGRES_PASSWORD` → exit 1, tampered file → exit 1), not yet on a live host.

**Docker builds.** A build secret is mounted, never `ARG`/`ENV`:

```dockerfile
RUN --mount=type=secret,id=npm_token NPM_TOKEN="$(cat /run/secrets/npm_token)" pnpm install --frozen-lockfile
```

with `secret-envs: npm_token=NPM_TOKEN` on `docker/build-push-action`. Runtime secrets are not build inputs at all.

## 6. Wire

1. **Scanning, three layers.** Push protection (GitHub; free on public repos, GitHub Secret Protection on private ones, paid): enable in Settings → Code security. Pre-commit: `gitleaks git --pre-commit --staged --redact --no-banner` in the repo's hook runner. CI: add `gitleaks git --redact --no-banner` to the `ci` task so `_checks.yml` runs it. If push protection is unavailable, pre-commit and CI become the only gates: say so.
2. **GitHub environments:** `printf %s "$VALUE" | gh secret set NAME --env prd` (or run it bare and paste at the prompt); never `--body` with a literal (shell history). Set repository secrets only for things every environment shares and that are harmless alone.
3. **Vercel:** `vercel env add NAME production --sensitive` (prompts for the value). Local: `vercel env run -e development -- pnpm dev` runs with remote values and writes no file.
4. **Supabase:** `supabase secrets set --env-file supabase/functions/.env.prd` from a decrypted temp file, then delete it; `supabase secrets list` shows names only. Names must not start with `SUPABASE_`.
5. **Logs:** redact at the logger, not at the call site ([logging-contract](../../core/_shared/logging-contract.md)); redact again in the collector ([deploy-otel-collector](../deploy-otel-collector/SKILL.md)).
6. **Rotation:** record owner, source of truth and interval for each secret in `deploy/envs/README.md` (names only). Rotate on schedule, on offboarding, and on any suspicion. Procedure per type in [secrets-patterns.md](./secrets-patterns.md#rule-rotate-at-the-source-then-the-store-then-the-consumers).
7. Cross-cutting rules: [security-baseline](../../core/_shared/security-baseline.md).

## 7. Verify

```bash
git ls-files | grep -E '(^|/)\.env($|\.)' | grep -vE '\.example$|\.sops\.env$'   # expect: no output
gitleaks git --redact --no-banner                                                 # expect: "no leaks found"
sops decrypt deploy/envs/dev/secrets.sops.env | head -c0 && echo decrypt-ok           # expect: decrypt-ok
docker history --no-trunc <image> | grep -iE 'secret|token|password|key=' ; echo $?   # expect: 1 (no match)
grep -rE 'sb_secret_|service_role|BEGIN [A-Z ]*PRIVATE KEY' dist .next .output build 2>/dev/null   # expect: no output (client bundle)
```

Then prove the pipeline end to end: a deliberately fake token (`ghp_` + 36 `x`) in a scratch commit must be rejected by the
pre-commit hook. Remove the commit afterwards.

## References
- [secrets-patterns.md](./secrets-patterns.md): why each home, rotation order, the client-bundle boundary, when to deviate.
- [../set-up-delivery-pipeline/SKILL.md](../set-up-delivery-pipeline/SKILL.md): environments and OIDC.
- [../_shared/environments.md](../_shared/environments.md): what dev, stg and prd mean.
- [../_shared/stack-versions.md](../_shared/stack-versions.md): sops, age, gitleaks lines.

Files in this skill

  • SKILL.md11 KB
  • secrets-patterns.md7.5 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.