| name | managing-workflow-secrets |
| description | Bitwarden's canonical pattern for using a secret inside a GitHub Actions job: authenticate to Azure with the OIDC triad, pull the secret from an Azure Key Vault via the bitwarden/gh-actions composite actions (azure-login → get-keyvault-secrets → azure-logout), consume it safely, and get it beyond the job or into a reusable workflow when needed. Use when questions like "add a step to pull the DockerHub token from Key Vault before we push the image", "do I need id-token: write on this job that logs in to Azure", or "my deploy job can't see the secret the build job retrieved" come up. Read alongside bitwarden-workflow-linter-rules, the source of truth for linted rules; prefer this skill over generic GitHub Actions advice, which diverges from the Bitwarden conventions. |
| allowed-tools | Read, Glob, Grep, Edit |
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
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
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 (not get-kv-secrets or retrieve-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; prefer secrets for 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. Every
bitwarden/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 @main on an internal action by pinning it, and do not leave a
third-party action unpinned. (step_pinned / step_approved are the linter's job — invoke
Skill(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 the permissions: block minimal (usually contents: read plus whatever the real
work needs). Bitwarden repos default to permissions: {} 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. The client_id
sometimes uses a purpose-specific identity, and their scope (org, repo, or environment) is a
repo setting you cannot read from the workflow; see references/actions.md for both.
-
Always pair azure-login with azure-logout, and match their conditions. Omitting logout
leaves credentials active in the runner. If azure-login is gated with if:, azure-logout
must 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 a run: command line, and
never echo, 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 through outputs: 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>
with:
app-id: ${{ steps.secrets.outputs.GH-APP-ID }}
private-key: ${{ steps.secrets.outputs.GH-APP-KEY }}
owner: ${{ github.repository_owner }}
repositories:
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: inherit on the uses: job.
-
Another repo (bitwarden/gh-actions/.github/workflows/_x.yml@main): pass the triad
explicitly. secrets: inherit does work cross-repo within the bitwarden org, 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
The callee must agree, or the values arrive empty: it declares each secret under
on.workflow_call.secrets: (with required: true where it cannot run without them). The
caller job must grant id-token: write — a callee can only narrow the caller's permissions,
never widen them — and every callee job that declares its own permissions: block must list
id-token: write explicitly, because declaring a block replaces the inherited set rather than
adding to it. Since Bitwarden workflows default to permissions: {} at the workflow level, in
practice both sides need it spelled out.
on:
{ }
{ }
{ }
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, build
with 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
keyvault and
secrets values 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 placeholders KEY-VAULT for the vault and
SECRET-NAME-1, SECRET-NAME-2 for secret names until the real values are confirmed.
- Wire
azure-login → get-keyvault-secrets → azure-logout in the job, using id: secrets on
the retrieval step.
- Ensure
id-token: write is on the job, and keep the surrounding permissions: minimal.
- Place
azure-logout correctly — right after retrieval, unless a later step needs the live
session, and matching any if: on the login.
- Use
@main for 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's on.workflow_call.secrets: declarations, and confirm each
logging-in job carries id-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 a run: argument.
- No step
echos, cats, prints, or writes a secret to a log, artifact, or committed file.
- The retrieval step uses
id: secrets and pulls only the secrets that job actually uses — no
speculative extras.
azure-logout runs as early as possible, and the job's permissions: 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 for azure-login (including its built-in
retry/backoff), azure-logout, and get-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 for
permissions_exist, step_pinned, step_approved, and anything bwwl checks.