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 (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> # 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, and GH-APP-KEY are 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.Z comment,
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.token through a job output: — 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: 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 # OIDC token is minted against the caller job's permissions
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:
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 its
on.workflow_call block 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, 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.
1---2name: managing-workflow-secrets3description: 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.4---56## Boundaries78The three-action pattern is the standard across Bitwarden's CI, CD, and operational workflows —9treat any other retrieval mechanism in a Bitwarden workflow as a finding.1011**Defer to the linter skill.** For anything the workflow linter enforces (e.g.12`permissions_exist`, `step_pinned`, `step_approved`), invoke13`Skill(bitwarden-devops-engineer:bitwarden-workflow-linter-rules)` — that skill is the source of14truth; do not re-report a linter finding here.1516**Out of scope** — handle these case-by-case, not from this skill: fork-PR access gates, multiple17vaults in one job, dynamic identity selection, matrix logins, and raw `az` CLI for certificates18and secret write-back.1920For the exact input/output contracts of the three actions, read `references/actions.md`.2122## Secret exposure is the overriding concern2324Keeping a retrieved secret from being exposed outranks every other consideration in this skill. An25exposed token is a CRITICAL incident. Apply this as a hard gate: **before offering any edit, fix, or suggestion,26evaluate it against the secret-hygiene checklist below. If the change would cause a secret to be27logged, written to a file or artifact, passed as a command-line argument, placed in a job output,28or otherwise exposed off the retrieving job, do not offer it** — flag the exposure instead. GitHub29masks retrieved values in logs, but masking is a backstop, not permission to handle secrets30loosely: it does not cover values written to files, passed as CLI args, or sent off-runner.3132This gate is independently evaluable — each item in the [secret-hygiene checklist](#secret-hygiene-checklist)33is a concrete pass/fail check against the job you touched. Run it every time.3435## The AKV + OIDC lifecycle3637Every job that needs a Key Vault secret follows the same four-beat sequence:3839```40azure-login → get-keyvault-secrets → azure-logout → consume the step outputs41```4243```yaml44jobs:45 my-job:46 runs-on: ubuntu-24.0447 permissions:48 contents: read49 id-token: write # OIDC federated login needs this — see golden rule 250 steps:51 - name: Log in to Azure52 uses: bitwarden/gh-actions/azure-login@main53 with:54 subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}55 tenant_id: ${{ secrets.AZURE_TENANT_ID }}56 client_id: ${{ secrets.AZURE_CLIENT_ID }}5758 - name: Get Azure Key Vault secrets59 id: secrets60 uses: bitwarden/gh-actions/get-keyvault-secrets@main61 with:62 keyvault: KEY-VAULT63 secrets: "SECRET-NAME-1,SECRET-NAME-2"6465 - name: Log out from Azure66 uses: bitwarden/gh-actions/azure-logout@main6768 - name: Do work69 env:70 MY_TOKEN: ${{ steps.secrets.outputs.SECRET-NAME-1 }} # step outputs survive logout71 run: ./do-work.sh72```7374`KEY-VAULT` and `SECRET-NAME-1` / `SECRET-NAME-2` are **placeholders**. Substitute the vault and secret names75supplied for the task. If they are not provided or you are unsure, **flag that to the user and ask**76— never infer them from the repository or the workflow's content. See the authoring procedure.7778The Azure session is only needed to _fetch_ the secrets. Once `get-keyvault-secrets` has written79them to its step outputs, those outputs persist for the rest of the job, so `azure-logout` comes80**immediately after retrieval** — before the secrets are consumed. The one exception is when a step81needs the live Azure session itself (`az acr login`, `azcopy`, `az keyvault secret show`); then82logout moves to just after that step.8384Two conventions worth applying every time:8586- **Give the retrieval step `id: secrets`** (not `get-kv-secrets` or `retrieve-secrets`). It reads87 clearly at the point of use — `steps.secrets.outputs.SECRET-NAME-1` — and is the same everywhere, so88 downstream references are predictable. Older workflows use other ids; prefer `secrets` for new89 work and when editing.90- **Wrap three or more secrets in a folded block scalar**, one per line, so the list stays readable91 and diffs cleanly. Two or fewer can stay inline as a quoted string:9293 ```yaml94 secrets: >-95 SECRET-NAME-1,96 SECRET-NAME-2,97 SECRET-NAME-398 ```99100Output names are **case-insensitive** in GitHub expressions, so both101`steps.secrets.outputs.SECRET-NAME-1` and `...outputs.secret-name-1` resolve. Match the secret name102as written for readability.103104## Golden rules (invariants)105106Treat a deviation as a finding.1071081. **Internal actions float on `@main`; third-party actions are SHA-pinned.** Every109 `bitwarden/gh-actions/*` reference uses `@main` — never a SHA. Third-party actions in the same110 file (`actions/checkout`, `docker/login-action`) are pinned to a full-length commit SHA with a111 version comment. Do not "fix" a `@main` on an internal action by pinning it, and do not leave a112 third-party action unpinned. (`step_pinned` / `step_approved` are the linter's job — invoke113 `Skill(bitwarden-devops-engineer:bitwarden-workflow-linter-rules)`.)1141152. **Any job that logs in declares `id-token: write`.** OIDC federated login fails without it. Keep116 the rest of the `permissions:` block minimal (usually `contents: read` plus whatever the real117 work needs). Bitwarden repos default to `permissions: {}` at the workflow level and grant118 narrowly per job.1191203. **Only three GitHub secrets exist for auth — the OIDC triad.** `AZURE_SUBSCRIPTION_ID`,121 `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`. Everything else lives in Key Vault. The `client_id`122 sometimes uses a purpose-specific identity, and their scope (org, repo, or environment) is a123 repo setting you cannot read from the workflow; see `references/actions.md` for both.1241254. **Always pair `azure-login` with `azure-logout`, and match their conditions.** Omitting logout126 leaves credentials active in the runner. If `azure-login` is gated with `if:`, `azure-logout`127 must carry the _same_ condition, or it runs against a session that was never created.1281295. **Treat secret exposure as the thing that matters most** — see "Secret exposure is the overriding130 concern" above and run the secret-hygiene checklist on every job you touch. Consume every secret131 through a step-scoped `env:`, never interpolate one directly into a `run:` command line, and132 never `echo`, `cat`, or log it.133134## Getting a secret to a downstream job or reusable workflow135136A secret's value belongs to the job that retrieved it. How you reach further depends on the137distance the secret has to travel.138139**Same job, later step** — reference the retrieval step's output through a step-scoped `env:` (shown140in the lifecycle above). This is the only case where a raw value is passed around, and it never141leaves the job.142143**A subsequent job** — do **not** pass the value across the boundary. GitHub redacts masked values144out of job `outputs:` — the runner logs `Skip output <key> since it may contain secret` — and145`get-keyvault-secrets` registers every value it retrieves as masked. So a secret placed in an146output arrives **empty** downstream; a value that was never masked would cross in the clear.147Either way, never put a secret in a job `output:`. Only two things legitimately cross a job148boundary:149150- **The ability to mint a short-lived GitHub App token**, when the real need is GitHub access151 (cross-repo checkout, dispatch, `gh api`). The minted token is itself masked, so it cannot152 travel through `outputs:` either — **mint it in the job that consumes it**. What crosses the153 boundary is the capability, not a token: each job retrieves the App id/key from AKV and mints154 its own.155156 In the job that needs GitHub access:157158 ```yaml159 - name: Get Azure Key Vault secrets160 id: secrets161 uses: bitwarden/gh-actions/get-keyvault-secrets@main162 with:163 keyvault: KEY-VAULT164 secrets: "GH-APP-ID,GH-APP-KEY"165 - uses: bitwarden/gh-actions/azure-logout@main166 - name: Generate GH App token167 id: app-token168 uses: actions/create-github-app-token@<full-40-char-sha> # vX.Y.Z — replace both with the real values169 with:170 app-id: ${{ steps.secrets.outputs.GH-APP-ID }}171 private-key: ${{ steps.secrets.outputs.GH-APP-KEY }}172 owner: ${{ github.repository_owner }}173 repositories: self-host # narrow the token's scope when possible174 - uses: actions/checkout@<full-40-char-sha> # vX.Y.Z — replace both with the real values175 with:176 token: ${{ steps.app-token.outputs.token }}177 ```178179 `KEY-VAULT`, `GH-APP-ID`, and `GH-APP-KEY` are placeholders — use the vault and App-credential180 secret names given for the task. Whether the App credentials live in an org-wide vault or a181 repo-scoped one is a per-task detail; if you do not have it, ask rather than assuming.182 `<full-40-char-sha>` is a placeholder too: never emit it literally and never guess a SHA. Look up183 the real commit SHA for the version you want and replace both the ref and the `# vX.Y.Z` comment,184 per golden rule 1.185186 A second job that also needs GitHub access repeats this whole block. Do not try to shorten it by187 routing `steps.app-token.outputs.token` through a job `output:` — it is masked, so the downstream188 job receives an empty string and the failure looks like a permissions error.189190- **Non-secret derived values** via job `outputs:` — a version string, a boolean, or even the191 _name_ of a secret key for the next job to look up (never the value). If the downstream job just192 needs the same secret, the simplest answer is to **re-run login → retrieve → logout** in that job.193 Each job authenticates independently.194195**A reusable workflow** — the caller forwards the OIDC triad; the reusable workflow does its own196login/retrieve inside each job. This is a **two-sided change — never edit only the caller.**197198- **Same repo** (`./.github/workflows/_x.yml`): `secrets: inherit` on the `uses:` job.199- **Another repo** (`bitwarden/gh-actions/.github/workflows/_x.yml@main`): pass the triad200 explicitly. `secrets: inherit` does work cross-repo within the `bitwarden` org, but the convention201 is explicit passing — it keeps least privilege (only the three secrets travel, not every secret202 the caller can see) and documents the contract at the call site.203204 ```yaml205 jobs:206 review:207 uses: bitwarden/gh-actions/.github/workflows/_review-code.yml@main208 secrets:209 AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}210 AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}211 AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}212 permissions:213 contents: read214 id-token: write # OIDC token is minted against the caller job's permissions215 ```216217 The callee must agree, or the values arrive empty: it declares each secret under218 `on.workflow_call.secrets:` (with `required: true` where it cannot run without them). The219 **caller** job must grant `id-token: write` — a callee can only narrow the caller's permissions,220 never widen them — and every callee job that declares its own `permissions:` block must list221 `id-token: write` explicitly, because declaring a block replaces the inherited set rather than222 adding to it. Since Bitwarden workflows default to `permissions: {}` at the workflow level, in223 practice both sides need it spelled out.224225 ```yaml226 on:227 workflow_call:228 secrets:229 AZURE_SUBSCRIPTION_ID: { required: true }230 AZURE_TENANT_ID: { required: true }231 AZURE_CLIENT_ID: { required: true }232 ```233234 If you own only the caller and the callee lives in `bitwarden/gh-actions`, read its235 `on.workflow_call` block and match the names exactly rather than guessing.236237## Authoring procedure238239When asked to add or correct secret retrieval in a job:2402411. **Confirm the secret genuinely needs AKV.** Pure CI steps (`format`, `lint`, `test`, `build`242 with no external service) usually need no secrets. See "When AKV is needed" below.2432. **Use the vault and secret names you were given — never infer them.** The `keyvault` and244 `secrets` values are supplied per task. If they are missing or you are unsure, **flag that to the245 user and ask**; do not guess them from the repository or the workflow's content, and do not246 invent them. In drafts and examples, use the placeholders `KEY-VAULT` for the vault and247 `SECRET-NAME-1`, `SECRET-NAME-2` for secret names until the real values are confirmed.2483. **Wire `azure-login → get-keyvault-secrets → azure-logout`** in the job, using `id: secrets` on249 the retrieval step.2504. **Ensure `id-token: write`** is on the job, and keep the surrounding `permissions:` minimal.2515. **Place `azure-logout` correctly** — right after retrieval, unless a later step needs the live252 session, and matching any `if:` on the login.2536. **Use `@main` for the internal actions**; SHA-pin any third-party action you add. Resolve the254 real full-length SHA and its version comment — never guess one, and never leave a255 `<full-40-char-sha>` placeholder in a workflow you hand back.2567. **If the secret must reach another job or a reusable workflow**, use the mechanism above —257 re-retrieve per job, mint an App token for GitHub access, or forward the OIDC triad to the258 reusable workflow. If a reusable workflow is involved, **edit both sides**: match the caller's259 `secrets:` keys to the callee's `on.workflow_call.secrets:` declarations, and confirm each260 logging-in job carries `id-token: write`.2618. **Run the secret-hygiene checklist** before finishing.262263### Secret hygiene checklist264265Because an exposed token is a critical failure, verify each of these on any job you touch:266267- Every secret is consumed through a **step-scoped `env:`**, not inlined into a `run:` argument.268- No step `echo`s, `cat`s, prints, or writes a secret to a log, artifact, or committed file.269- The retrieval step uses `id: secrets` and pulls **only** the secrets that job actually uses — no270 speculative extras.271- `azure-logout` runs as early as possible, and the job's `permissions:` are the minimum required.272273## When AKV is needed274275| Capability | Why AKV is involved |276| ------------------------------ | ---------------------------------------------------------------- |277| Container registry push | `az acr login` (needs live session) or a registry token from AKV |278| External service integration | API keys, connection strings, third-party tokens |279| Failure / status notifications | Notification webhook URLs (e.g. Slack) retrieved from AKV |280281For pure CI capabilities with no external interaction, AKV steps are typically unnecessary — and282`azure-login` cannot succeed on a `pull_request` run from a fork. `pull_request_target` and283`workflow_run` do receive secrets, but choosing a trigger is a fork-PR access gate — out of scope284here; ask.285286## References287288- `references/actions.md` — input/output contracts for `azure-login` (including its built-in289 retry/backoff), `azure-logout`, and `get-keyvault-secrets`; plus how vault and secret names are290 supplied and the OIDC client-identity conventions.291- `Skill(bitwarden-devops-engineer:bitwarden-workflow-linter-rules)` — source of truth for all292 linted rules; invoke it for293 `permissions_exist`, `step_pinned`, `step_approved`, and anything `bwwl` checks.