Imported from bitwarden/ai-plugins (
plugins/bitwarden-devops-engineer/skills/managing-workflow-secrets/SKILL.md). Install upstream withnpx skills add bitwarden/ai-plugins --skill managing-workflow-secrets. Copyright stays with the author.
Boundaries
The three-action pattern is the standard across Bitwarden's CI, CD, and operational workflows — treat any other retrieval mechanism in a Bitwarden workflow as a finding.
Defer to the linter skill. For anything the workflow linter enforces (e.g.
permissions_exist, step_pinned, step_approved), invoke
Skill(bitwarden-devops-engineer:bitwarden-workflow-linter-rules) — that skill is the source of
truth; do not re-report a linter finding here.
Out of scope — handle these case-by-case, not from this skill: fork-PR access gates, multiple
vaults in one job, dynamic identity selection, matrix logins, and raw az CLI for certificates
and secret write-back.
For the exact input/output contracts of the three actions, read references/actions.md.
Secret exposure is the overriding concern
Keeping a retrieved secret from being exposed outranks every other consideration in this skill. An exposed token is a CRITICAL incident. Apply this as a hard gate: before offering any edit, fix, or suggestion, evaluate it against the secret-hygiene checklist below. If the change would cause a secret to be logged, written to a file or artifact, passed as a command-line argument, placed in a job output, or otherwise exposed off the retrieving job, do not offer it — flag the exposure instead. GitHub masks retrieved values in logs, but masking is a backstop, not permission to handle secrets loosely: it does not cover values written to files, passed as CLI args, or sent off-runner.
This gate is independently evaluable — each item in the secret-hygiene checklist is a concrete pass/fail check against the job you touched. Run it every time.
The AKV + OIDC lifecycle
Every job that needs a Key Vault secret follows the same four-beat sequence:
azure-login → get-keyvault-secrets → azure-logout → consume the step outputs
jobs:
my-job:
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write # OIDC federated login needs this — see golden rule 2
steps:
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: KEY-VAULT
secrets: "SECRET-NAME-1,SECRET-NAME-2"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Do work
env:
MY_TOKEN: ${{ steps.secrets.outputs.SECRET-NAME-1 }} # step outputs survive logout
run: ./do-work.sh
KEY-VAULT and SECRET-NAME-1 / SECRET-NAME-2 are placeholders. Substitute the vault and secret names
supplied for the task. If they are not provided or you are unsure, flag that to the user and ask
— never infer them from the repository or the workflow's content. See the authoring procedure.
The Azure session is only needed to fetch the secrets. Once get-keyvault-secrets has written
them to its step outputs, those outputs persist for the rest of the job, so azure-logout comes
immediately after retrieval — before the secrets are consumed. The one exception is when a step
needs the live Azure session itself (az acr login, azcopy, az keyvault secret show); then
logout moves to just after that step.
Two conventions worth applying every time:
-
Give the retrieval step
id: secrets(notget-kv-secretsorretrieve-secrets). It reads clearly at the point of use —steps.secrets.outputs.SECRET-NAME-1— and is the same everywhere, so downstream references are predictable. Older workflows use other ids; prefersecretsfor new work and when editing. -
Wrap three or more secrets in a folded block scalar, one per line, so the list stays readable and diffs cleanly. Two or fewer can stay inline as a quoted string:
secrets: >- SECRET-NAME-1, SECRET-NAME-2, SECRET-NAME-3
Output names are case-insensitive in GitHub expressions, so both
steps.secrets.outputs.SECRET-NAME-1 and ...outputs.secret-name-1 resolve. Match the secret name
as written for readability.
Golden rules (invariants)
Treat a deviation as a finding.
-
Internal actions float on
@main; third-party actions are SHA-pinned. Everybitwarden/gh-actions/*reference uses@main— never a SHA. Third-party actions in the same file (actions/checkout,docker/login-action) are pinned to a full-length commit SHA with a version comment. Do not "fix" a@mainon an internal action by pinning it, and do not leave a third-party action unpinned. (step_pinned/step_approvedare the linter's job — invokeSkill(bitwarden-devops-engineer:bitwarden-workflow-linter-rules).) -
Any job that logs in declares
id-token: write. OIDC federated login fails without it. Keep the rest of thepermissions:block minimal (usuallycontents: readplus whatever the real work needs). Bitwarden repos default topermissions: {}at the workflow level and grant narrowly per job. -
Only three GitHub secrets exist for auth — the OIDC triad.
AZURE_SUBSCRIPTION_ID,AZURE_TENANT_ID,AZURE_CLIENT_ID. Everything else lives in Key Vault. Theclient_idsometimes uses a purpose-specific identity, and their scope (org, repo, or environment) is a repo setting you cannot read from the workflow; seereferences/actions.mdfor both. -
Always pair
azure-loginwithazure-logout, and match their conditions. Omitting logout leaves credentials active in the runner. Ifazure-loginis gated withif:,azure-logoutmust carry the same condition, or it runs against a session that was never created. -
Treat secret exposure as the thing that matters most — see "Secret exposure is the overriding concern" above and run the secret-hygiene checklist on every job you touch. Consume every secret through a step-scoped
env:, never interpolate one directly into arun:command line, and neverecho,cat, or log it.
Getting a secret to a downstream job or reusable workflow
A secret's value belongs to the job that retrieved it. How you reach further depends on the distance the secret has to travel.
Same job, later step — reference the retrieval step's output through a step-scoped env: (shown
in the lifecycle above). This is the only case where a raw value is passed around, and it never
leaves the job.
A subsequent job — do not pass the value across the boundary. GitHub redacts masked values
out of job outputs: — the runner logs Skip output <key> since it may contain secret — and
get-keyvault-secrets registers every value it retrieves as masked. So a secret placed in an
output arrives empty downstream; a value that was never masked would cross in the clear.
Either way, never put a secret in a job output:. Only two things legitimately cross a job
boundary:
-
The ability to mint a short-lived GitHub App token, when the real need is GitHub access (cross-repo checkout, dispatch,
gh api). The minted token is itself masked, so it cannot travel throughoutputs:either — mint it in the job that consumes it. What crosses the boundary is the capability, not a token: each job retrieves the App id/key from AKV and mints its own.In the job that needs GitHub access:
- name: Get Azure Key Vault secrets id: secrets uses: bitwarden/gh-actions/get-keyvault-secrets@main with: keyvault: KEY-VAULT secrets: "GH-APP-ID,GH-APP-KEY" - uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token id: app-token uses: actions/create-github-app-token@<full-40-char-sha> # vX.Y.Z — replace both with the real values with: app-id: ${{ steps.secrets.outputs.GH-APP-ID }} private-key: ${{ steps.secrets.outputs.GH-APP-KEY }} owner: ${{ github.repository_owner }} repositories: self-host # narrow the token's scope when possible - uses: actions/checkout@<full-40-char-sha> # vX.Y.Z — replace both with the real values with: token: ${{ steps.app-token.outputs.token }}KEY-VAULT,GH-APP-ID, andGH-APP-KEYare placeholders — use the vault and App-credential secret names given for the task. Whether the App credentials live in an org-wide vault or a repo-scoped one is a per-task detail; if you do not have it, ask rather than assuming.<full-40-char-sha>is a placeholder too: never emit it literally and never guess a SHA. Look up the real commit SHA for the version you want and replace both the ref and the# vX.Y.Zcomment, per golden rule 1.A second job that also needs GitHub access repeats this whole block. Do not try to shorten it by routing
steps.app-token.outputs.tokenthrough a joboutput:— it is masked, so the downstream job receives an empty string and the failure looks like a permissions error. -
Non-secret derived values via job
outputs:— a version string, a boolean, or even the name of a secret key for the next job to look up (never the value). If the downstream job just needs the same secret, the simplest answer is to re-run login → retrieve → logout in that job. Each job authenticates independently.
A reusable workflow — the caller forwards the OIDC triad; the reusable workflow does its own login/retrieve inside each job. This is a two-sided change — never edit only the caller.
-
Same repo (
./.github/workflows/_x.yml):secrets: inheriton theuses:job. -
Another repo (
bitwarden/gh-actions/.github/workflows/_x.yml@main): pass the triad explicitly.secrets: inheritdoes work cross-repo within thebitwardenorg, but the convention is explicit passing — it keeps least privilege (only the three secrets travel, not every secret the caller can see) and documents the contract at the call site.jobs: review: uses: bitwarden/gh-actions/.github/workflows/_review-code.yml@main secrets: AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} permissions: contents: read id-token: write # OIDC token is minted against the caller job's permissionsThe callee must agree, or the values arrive empty: it declares each secret under
on.workflow_call.secrets:(withrequired: truewhere it cannot run without them). The caller job must grantid-token: write— a callee can only narrow the caller's permissions, never widen them — and every callee job that declares its ownpermissions:block must listid-token: writeexplicitly, because declaring a block replaces the inherited set rather than adding to it. Since Bitwarden workflows default topermissions: {}at the workflow level, in practice both sides need it spelled out.on: workflow_call: secrets: AZURE_SUBSCRIPTION_ID: { required: true } AZURE_TENANT_ID: { required: true } AZURE_CLIENT_ID: { required: true }If you own only the caller and the callee lives in
bitwarden/gh-actions, read itson.workflow_callblock and match the names exactly rather than guessing.
Authoring procedure
When asked to add or correct secret retrieval in a job:
- Confirm the secret genuinely needs AKV. Pure CI steps (
format,lint,test,buildwith no external service) usually need no secrets. See "When AKV is needed" below. - Use the vault and secret names you were given — never infer them. The
keyvaultandsecretsvalues are supplied per task. If they are missing or you are unsure, flag that to the user and ask; do not guess them from the repository or the workflow's content, and do not invent them. In drafts and examples, use the placeholdersKEY-VAULTfor the vault andSECRET-NAME-1,SECRET-NAME-2for secret names until the real values are confirmed. - Wire
azure-login → get-keyvault-secrets → azure-logoutin the job, usingid: secretson the retrieval step. - Ensure
id-token: writeis on the job, and keep the surroundingpermissions:minimal. - Place
azure-logoutcorrectly — right after retrieval, unless a later step needs the live session, and matching anyif:on the login. - Use
@mainfor the internal actions; SHA-pin any third-party action you add. Resolve the real full-length SHA and its version comment — never guess one, and never leave a<full-40-char-sha>placeholder in a workflow you hand back. - If the secret must reach another job or a reusable workflow, use the mechanism above —
re-retrieve per job, mint an App token for GitHub access, or forward the OIDC triad to the
reusable workflow. If a reusable workflow is involved, edit both sides: match the caller's
secrets:keys to the callee'son.workflow_call.secrets:declarations, and confirm each logging-in job carriesid-token: write. - Run the secret-hygiene checklist before finishing.
Secret hygiene checklist
Because an exposed token is a critical failure, verify each of these on any job you touch:
- Every secret is consumed through a step-scoped
env:, not inlined into arun:argument. - No step
echos,cats, prints, or writes a secret to a log, artifact, or committed file. - The retrieval step uses
id: secretsand pulls only the secrets that job actually uses — no speculative extras. azure-logoutruns as early as possible, and the job'spermissions:are the minimum required.
When AKV is needed
| Capability | Why AKV is involved |
|---|---|
| Container registry push | az acr login (needs live session) or a registry token from AKV |
| External service integration | API keys, connection strings, third-party tokens |
| Failure / status notifications | Notification webhook URLs (e.g. Slack) retrieved from AKV |
For pure CI capabilities with no external interaction, AKV steps are typically unnecessary — and
azure-login cannot succeed on a pull_request run from a fork. pull_request_target and
workflow_run do receive secrets, but choosing a trigger is a fork-PR access gate — out of scope
here; ask.
References
references/actions.md— input/output contracts forazure-login(including its built-in retry/backoff),azure-logout, andget-keyvault-secrets; plus how vault and secret names are supplied and the OIDC client-identity conventions.Skill(bitwarden-devops-engineer:bitwarden-workflow-linter-rules)— source of truth for all linted rules; invoke it forpermissions_exist,step_pinned,step_approved, and anythingbwwlchecks.