TL;DR
- Secrets leak through
echo, debug tracing, logs, process args, and env-var inheritance — guard the flow, not just storage - Fetch from Bitwarden once, assign to a shell variable, reference the variable — never inline the raw value
- Verify with first-3/last-3:
${VAR:0:3}...${VAR: -3}— confirm you have the right token without exposing it - GitLab masked vars hide values in logs (8+ chars); protected vars only expose on protected branches — use both for production secrets
- Agent permission rules prefer command substitution inside the allowed command, not as an env var
- Rotate tokens immediately; verify before use; pin your
bwCLI version
The leak surface — where secrets escape
I used to think storing secrets in Bitwarden (or Vault, HashiCorp, whatever) meant they were safe. I was wrong.
Storage is half the problem. The other half is getting the secret to where it needs to run — and that’s where things fall apart.
A secret leaks when it:
- Prints to stdout/stderr —
echo "$TOKEN"and it’s in the logs forever - Debugged with
set -x— bash echoes every command before running it; yourcurl -H "Authorization: Bearer $TOKEN"becomes plaintext in the output - Sits in env vars — inherited by child processes, readable via
ps auxon some systems, copied into build artifacts - Persists in history — shell scrollback, CI logs, clipboard from a copy-paste
- Appears in process arguments — even if you don’t echo it,
pscan show args passed to long-running processes
The scary part: if a secret prints once, it’s compromised forever. Masking in CI hides it from future runs, but that one job log in the archive is still readable.
So the goal is simple: fetch the secret as late as possible, use it immediately, and never let it touch a log, a var dump, or your terminal.
The vault as source of truth
I run Bitwarden in a Docker container behind a reverse-proxy with SSO. It’s not a Fort Knox UI-only vault — it’s a REST API I can call from the CLI, and the CLI auto-unlocks from cached sessions or a keychain-stored master password.
The pattern: a small wrapper script (bw-secret) that:
- Tries to use a cached session token (pulled from the OS keychain)
- If that’s stale/missing, fetches the master password from the keychain and runs
bw unlock - Calls
bw get <item>and returns the value - Never prints the secret itself — only returns it to stdout for assignment to a var
It looks like:
bw-secret api-key
# Returns: the-actual-token-value (nothing else printed)
Gotcha: Bitwarden’s bw CLI had a session-handling regression in an older version. Pin a working version in your CI:
- script: curl -L -o bw https://vault.bitwarden.com/download/cli/linux/
- chmod +x bw
# Or build a container image with a pinned version
Shell-variable discipline — the core pattern
Here’s the most important rule: fetch once into a variable, then reference the variable.
DO THIS:
TOKEN=$(bw-secret api-key)
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/check
NEVER THIS:
# WRONG: secret is inlined in the command, will echo if you debug
curl -H "Authorization: Bearer $(bw-secret api-key)" https://api.example.com/check
# WRONG: plain env var left around
export API_TOKEN=$(bw-secret api-key)
# WRONG: echoing to debug
echo "Token is: $(bw-secret api-key)"
The difference: when TOKEN=$(bw-secret api-key) runs, bash captures the output into a variable before it reaches the shell history or logs. The assignment itself doesn’t print anything. Then curl -H "Authorization: Bearer $TOKEN" passes the value to curl’s argument, and if curl logs, the value is often abstracted.
There’s a giant caveat: set -x. The moment you turn on shell tracing to debug a misbehaving pipeline, this protection evaporates. set -x prints every command after expansion — so the assignment line shows + TOKEN=<the actual secret>, and any command that references $TOKEN shows the expanded value too. The shell-variable habit keeps secrets out of your normal logs and scrollback; set -x walks straight past it. Don’t enable it near secrets, and if you absolutely must, fence the sensitive section: { set +x; } 2>/dev/null before you touch the token, then re-enable tracing after.
Verification without exposure — the first-3/last-3 trick
You fetched a token. Is it the right one? You need to verify without printing the whole thing.
Use first-3 and last-3 characters:
TOKEN=$(bw-secret api-key)
echo "Verification: ${TOKEN:0:3}...${TOKEN: -3}"
# Output: Verification: abc...xyz
Now you can check: “Is this token one I recognize?” Compare against the first-3/last-3 from your Bitwarden vault UI or a note. If it matches, you have the right one. Log it. No one can reverse-engineer a token from 6 characters and some dots.
This is the verification pattern I use everywhere — scripts, CI logs, agent checks, manual debugging. It’s paranoid enough to be safe, sane enough to be practical.
Feeding CI — masked and protected variables
GitLab (and GitHub Actions) have two kinds of secret variables:
| Type | Behavior | Use |
|---|---|---|
| Masked | Value is replaced with [MASKED] in job logs | All secrets; hides from logs but not from job env |
| Protected | Only exposed on protected branches (main, tags) | Production secrets only; blocks exposure to fork pipelines |
Both are independent. A masked var that’s not protected will still expose itself to a forked merge request’s CI pipeline (a real concern if you use public GitLab or accept external contributions).
Real gotcha: if a forked MR runs a job with a masked var, the var still gets the full value — the [MASKED] replacement only happens in logs. A malicious fork can echo $SECRET > exfil.txt and upload it as an artifact. That’s why production secrets must be protected.
To use Bitwarden in CI:
before_script:
- bw unlock --passwordfile ~/.bw_master || true
- export AWS_ACCESS_KEY_ID="$(bw-secret aws-ci-key)"
- export AWS_SECRET_ACCESS_KEY="$(bw-secret aws-ci-secret)"
Define AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as protected, masked variables in GitLab (or empty placeholders — command substitution overrides them). The job fetches fresh secrets on every run, and the masking hides them from logs.
Wiring into agent permissions — Claude and beyond
I run Claude Code agents with permission rules that wrap secrets. The rule:
- command: "curl -H 'Authorization: Bearer $(bw-secret api-key)' https://api.example.com"
prompt_phrase: "check api status"
The command substitution happens inside the allowed command, not as an env var. This is important because:
- Env vars don’t reliably propagate into subagents or detached sessions
- Command substitution is self-contained — it runs, returns the value, and that’s it
- The wrapper (
bw-secret) handles unlock automatically
The agent sees the permission rule, matches it to the prompt, and runs the command. If the rule allows the pattern, the secret is fetched at command time. If it doesn’t match, the agent gets permission denied. No intermediate exposure.
Rotation and verification
If you generate a new token (API key, password, etc.), store it back in Bitwarden immediately. Don’t leave it in a notepad or your terminal.
Before you use a rotated secret in production, verify it works:
NEW_TOKEN=$(bw-secret api-key)
echo "First-3/last-3: ${NEW_TOKEN:0:3}...${NEW_TOKEN: -3}"
curl -H "Authorization: Bearer $NEW_TOKEN" https://api.example.com/health
# Should return 200 OK; if 401, the token is invalid or hasn't propagated
If a token is compromised, rotate it in the vault, update all dependent jobs/scripts, and verify a clean run. CI logs with the old token are still archived, but future runs won’t use it.
Gotchas and edge cases
set -x defeats masking entirely. If you enable bash debug mode, every command is echoed before var expansion. CI masking won’t catch it. Avoid set -x anywhere near secrets.
Forked MR pipelines expose masked vars. The value is in the environment; only the logs are masked. Don’t assume a masked var is safe for fork pipelines — use protected vars.
Child processes inherit env vars. If you export TOKEN=... and spawn a subprocess, the subprocess has the token in its environment. If that subprocess crashes and dumps core, the secret is in the dump.
Values under 8 characters or containing newlines won’t mask. GitLab’s masking regex has limits. Use longer tokens.
The bw CLI version matters. An old version has session bugs; a pinned version in CI is safer.
A hardware security key (like a YubiKey) backing your Bitwarden 2FA is good paranoia, but it’s an UX tax — you can’t unlock Bitwarden from a headless CI agent. Use a service account with a strong password, or a service-specific API key in the vault, instead.
Summary
Secrets in transit are riskier than secrets at rest. A well-locked vault is pointless if the secret leaks on the way to the job.
The pattern is boring but airtight:
- Fetch from Bitwarden into a shell variable
- Reference the variable, never the raw value
- Verify with first-3/last-3, never expose the whole thing
- Use masked and protected vars in CI
- Wire agent permissions to use command substitution, not env vars
- Rotate and verify immediately
I’ve built this into a few repos now, and the paranoia scales. It’s the difference between “I hope no one scrapes my logs” and “I have observability, even if someone does.”
Related: Vault behind Authentik covers storage and SSO; this post is about moving secrets from the vault into CI and agents without leaking them.