Skip to content

Wire your CI for verified attribution

By default a request from a CI runner is audited as actor_source = key_bound — we know which API key authenticated, but not which workflow or commit or human triggered the run. OIDC trust bindings lift every row from that workflow to actor_source = oidc_github (and equivalents for GitLab / CircleCI when those land). This page walks the wire-up end-to-end for GitHub Actions; GitLab and CircleCI follow the same shape and are listed at the bottom of the page.

The concept doc — CI runner attribution — covers why you’d want this. This page is the how.

  • An oidc:w capability on the tenant (Org owner / admin role by default; the dashboard policies page lets you grant it explicitly).
  • An admin API token (x-upwarden-admin-token) or an oidc:w-bearing session.
  • The GitHub repo that runs the workflow (you need the immutable numeric repository ID — see step 2 below).
  • An existing Upwarden project + API key the workflow uses for proxy traffic (the OIDC layer annotates audit rows; it does NOT replace the API key).

Step 1 — Mint the OIDC token in the CI workflow

Section titled “Step 1 — Mint the OIDC token in the CI workflow”

GitHub Actions can mint short-lived OIDC tokens scoped to the workflow. You enable it per-job with the id-token: write permission and pull the token from core.getIDToken() (Action SDK) or ACTIONS_ID_TOKEN_REQUEST_URL + ACTIONS_ID_TOKEN_REQUEST_TOKEN (raw HTTP).

Add this to the workflow YAML:

jobs:
install:
permissions:
id-token: write # required to mint an OIDC token
contents: read # whatever your job already needs
steps:
- uses: actions/checkout@v4
# Mint the token scoped to Upwarden's audience.
- name: Get Upwarden OIDC token
id: vg-oidc
run: |
AUDIENCE="upwarden.io"
TOKEN=$(curl -fsS \
-H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$AUDIENCE" \
| jq -r .value)
echo "::add-mask::$TOKEN"
echo "token=$TOKEN" >> "$GITHUB_OUTPUT"
# Use the token on every request that needs verified attribution.
- name: Install via Upwarden
env:
NPM_REGISTRY: https://npm.pkg.upwarden.io
run: |
npm config set registry "$NPM_REGISTRY"
npm config set //npm.pkg.upwarden.io/:_authToken "$VG_KEY"
# Upwarden reads the OIDC token from the x-upwarden-ci-oidc header.
# npm doesn't natively send custom headers; the wrapper script
# below adds the header to every npm install request.
export VG_OIDC_TOKEN="${{ steps.vg-oidc.outputs.token }}"
curl-with-oidc npm install
env:
VG_KEY: ${{ secrets.VG_KEY }}

A few notes:

  • audience MUST be upwarden.io — the verifier rejects every other value (strictness rule 2). If you typo the audience, you’ll see bad_audience in your structured reject logs.
  • The token is short-lived (~10 min per GitHub’s docs). Don’t cache it across jobs.
  • ::add-mask:: redacts the token from any GitHub Actions log line that mentions it.

Adding the header to package-manager requests

Section titled “Adding the header to package-manager requests”

npm / pip / cargo / etc. don’t natively pass custom HTTP headers on every install request. Three options:

ApproachWhen to use itTrade-off
HTTP proxy with the header injected (e.g. mitmproxy, a tiny caddy/nginx sidecar)Multi-package-manager workflows where you want the header on every request without per-tool configOne extra moving piece in CI
Per-tool config (.npmrc //npm.pkg.upwarden.io/:_authToken, pip --extra-index-url, cargo [registries.upwarden.token]) — set the header via tool-specific envPer-language workflows, simplest pathHas to be repeated per tool
Wrapper script (intercept the registry URL and add the header)Tools you can fork the registry URL through (@vanguard/npm-proxy-style)Custom code to maintain

We don’t ship a canonical wrapper today. If you wire one, please file an issue with the snippet so we can publish it as a reference.

GitLab CI and CircleCI both expose OIDC tokens at the runner. Upwarden’s verifier accepts only GitHub Actions today — see docs/dev/oidc-actor-attribution.md for the issuer allowlist and the planned-provider list. If you’re running on GitLab / CircleCI and need verified attribution now, please file an issue; the verifier code is provider-agnostic and adding an issuer is a small one-shot change.

Step 2 — Get your repo’s immutable repository_id

Section titled “Step 2 — Get your repo’s immutable repository_id”

repository_id is GitHub’s numeric repo ID, set at creation and never changes (rename-safe, transfer-safe). It’s what the binding pins on — strictness rule 4.

From the GitHub UI: it’s not directly visible. The easiest path is the REST API:

Terminal window
curl -fsS \
-H "Authorization: bearer $GH_TOKEN" \
https://api.github.com/repos/<owner>/<repo> | jq .id

The response field id is the numeric ID. Take note of it — you’ll paste it into the binding payload in step 3.

Why not just bind on <owner>/<repo>? The repository claim in the OIDC payload is a string and is mutable. A renamed repo (or one with a transferred owner) keeps its slot in any binding that pins the string. Pinning on repository_id — the immutable numeric — closes the “took the deleted repo’s name” attack class.

A binding is one POST to /api/v1/admin/orgs/<slug>/oidc-bindings. The minimal shape:

Terminal window
curl -fsS -X POST \
-H "x-upwarden-admin-token: $ADMIN_API_TOKEN" \
-H "content-type: application/json" \
-d '{
"issuer": "https://token.actions.githubusercontent.com",
"sub_pattern": "repo:acme/payments:ref:refs/heads/main",
"repository_id": "12345",
"event_names": ["pull_request", "push"],
"origin": "org_admin"
}' \
"https://vg-admin.<your-domain>/api/v1/admin/orgs/<slug>/oidc-bindings"

Field by field:

FieldRequired?What to put there
issueryesExactly https://token.actions.githubusercontent.com for GitHub Actions. NO trailing slash. Other issuers TBD.
sub_patternyesThe full sub claim the GitHub token will carry. For a branch: repo:<owner>/<repo>:ref:refs/heads/<branch>. For an environment: repo:<owner>/<repo>:environment:<env-name>. For a PR: repo:<owner>/<repo>:pull_request. Full string — no prefixes, no wildcards. Verifier compares with exact equality.
repository_idyes (for GH Actions)The numeric ID from step 2. String form ("12345") preferred; we also accept JSON numbers for convenience.
event_namesnoArray of allowed event_name claim values. Empty array = any event_name passes. Common values: pull_request, push, schedule, workflow_dispatch. We recommend clamping to pull_request + push for production-impacting bindings; schedule and workflow_dispatch have weaker provenance and you should allowlist them only when you mean to.
originyesorg_admin for tenant-wide bindings (any oidc:w caller). team_admin for team-scoped bindings (caller must be admin on the named team, AND the proposed sub/repo MUST sit inside an active org_admin binding for the same triple — see reference doc for the envelope check).
project_idnoWhen set, the binding only attributes audit rows from API keys belonging to this project. Defense-in-depth — a GH Actions token from one repo can’t stamp claims onto a different project’s rows.
team_idyes for origin=team_adminThe team the binding belongs to.

On success the response is the created binding row (status 201). On duplicate (an active binding already exists for the same (tenant, issuer, sub_pattern, repository_id)), the API returns 409 — the existing binding stays in place; revoke it first if you want to recreate.

Common sub_pattern shapes (GitHub Actions)

Section titled “Common sub_pattern shapes (GitHub Actions)”
Triggered bysub_pattern
push / pull_request on a branchrepo:<owner>/<repo>:ref:refs/heads/<branch>
Workflow tied to a deployment environmentrepo:<owner>/<repo>:environment:<env-name>
Pull-request workflowsrepo:<owner>/<repo>:pull_request
Tag pushrepo:<owner>/<repo>:ref:refs/tags/<tag>

You’ll usually want one binding per (branch, repo) or one binding per (environment, repo). Trying to cover an entire repo with a single binding requires multiple POSTs — one per sub_pattern shape the workflow can produce.

Push a no-op commit to the bound ref. Watch the audit log:

Terminal window
curl -fsS \
-H "x-upwarden-admin-token: $ADMIN_API_TOKEN" \
"https://vg-admin.<your-domain>/api/v1/admin/orgs/<slug>/audit?limit=5"

Look for a row from your workflow with:

{
"actor_source": "oidc_github",
"actor_id": "jdoe",
"ci_context": {
"workflow": "release.yml",
"run_id": "1234567890",
"repository": "acme/payments",
"ref": "refs/heads/main",
"commit": "7f3a9c2...",
"pr_number": null,
"event_name": "push"
},
"oidc_binding_id": "obind_..."
}

If you see actor_source: key_bound instead, the token didn’t make it through — check step 5 below.

Upwarden’s verifier emits a structured reject reason on every failure. Pull the last hour:

Terminal window
# If you have Cloud Logging access (admin / SRE)
gcloud logging read 'jsonPayload.event=~"^auth_failure_"' \
--limit 50 --freshness=1h

The most-common reject reasons and what they mean:

Failure codeWhat it meansMost-common cause
unbound_issuerThe iss claim isn’t on Upwarden’s allowlistYou typed the issuer URL wrong, or you’re on GitLab / CircleCI (not yet supported)
bad_audienceThe aud claim isn’t upwarden.ioYour workflow’s audience= parameter is missing or wrong
signature_invalidThe signature didn’t verifyToken tampered or expired-while-cached; re-mint per request
expired_or_not_yet_validexp is in the past (or iat/nbf is in the future)Token is older than ~10 min, or there’s clock skew > 60s between your runner and us
missing_repository_idThe token has no repository_id claimThe issuer requires one (GH Actions always emits it; the absence means the token is malformed)
unbound_oidc_subjectThe token verified but no binding matchedYour sub_pattern / repository_id / event_names don’t match the token’s claims — read on

The verifier compares the claim in full. Common gotchas:

  1. sub_pattern has a typo. The GitHub OIDC token’s sub looks like repo:acme/payments:ref:refs/heads/main. Copy it verbatim from the GitHub docs example for your trigger; don’t hand-construct.
  2. event_names is too narrow. If you bound ["pull_request"] but the workflow runs on push, the event-name mismatch is the reject. Either expand the binding’s event_names or trigger the workflow differently.
  3. repository_id is wrong. Step 2’s numeric — confirm it matches the binding row.
  4. The binding belongs to a different tenant. Bindings are tenant-scoped; a binding on tenant A doesn’t help tenant B.
  5. The binding was revoked. A soft-revoked binding (revoked_at IS NOT NULL) doesn’t match. The reference page describes the revoke flow and how to recreate.

If you want to see exactly what GitHub minted (without it ever leaving your runner), decode the payload locally:

Terminal window
echo "$VG_OIDC_TOKEN" | cut -d. -f2 | base64 --decode 2>/dev/null | jq .

Compare each field to your binding row. Most rejects resolve from this single check.