# Managing Workflow Secrets

> 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.

- Skill: `bitwarden/managing-workflow-secrets` (Agent Skill, multi-file: 13 files)
- Install (CLI): `npx skillmds@latest add bitwarden/managing-workflow-secrets`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bitwarden/managing-workflow-secrets/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: bitwarden (https://skillmd.com/u/bitwarden)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/bitwarden/managing-workflow-secrets

---


## 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](#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
```

```yaml
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:

  ```yaml
  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.

1. **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)`.)

2. **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.

3. **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.

4. **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.

5. **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:

  ```yaml
  - 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.

  ```yaml
  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.

  ```yaml
  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:

1. **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.
2. **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.
3. **Wire `azure-login → get-keyvault-secrets → azure-logout`** in the job, using `id: secrets` on
   the retrieval step.
4. **Ensure `id-token: write`** is on the job, and keep the surrounding `permissions:` minimal.
5. **Place `azure-logout` correctly** — right after retrieval, unless a later step needs the live
   session, and matching any `if:` on the login.
6. **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.
7. **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`.
8. **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 `echo`s, `cat`s, 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.

