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.
Prerequisites
Section titled “Prerequisites”- An
oidc:wcapability 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 anoidc: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:
audienceMUST beupwarden.io— the verifier rejects every other value (strictness rule 2). If you typo the audience, you’ll seebad_audiencein 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:
| Approach | When to use it | Trade-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 config | One 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 env | Per-language workflows, simplest path | Has 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.
Tokens for non-GitHub providers
Section titled “Tokens for non-GitHub providers”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:
curl -fsS \ -H "Authorization: bearer $GH_TOKEN" \ https://api.github.com/repos/<owner>/<repo> | jq .idThe 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>? Therepositoryclaim 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 onrepository_id— the immutable numeric — closes the “took the deleted repo’s name” attack class.
Step 3 — Register the trust binding
Section titled “Step 3 — Register the trust binding”A binding is one POST to /api/v1/admin/orgs/<slug>/oidc-bindings. The minimal shape:
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:
| Field | Required? | What to put there |
|---|---|---|
issuer | yes | Exactly https://token.actions.githubusercontent.com for GitHub Actions. NO trailing slash. Other issuers TBD. |
sub_pattern | yes | The 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_id | yes (for GH Actions) | The numeric ID from step 2. String form ("12345") preferred; we also accept JSON numbers for convenience. |
event_names | no | Array 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. |
origin | yes | org_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_id | no | When 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_id | yes for origin=team_admin | The 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 by | sub_pattern |
|---|---|
push / pull_request on a branch | repo:<owner>/<repo>:ref:refs/heads/<branch> |
| Workflow tied to a deployment environment | repo:<owner>/<repo>:environment:<env-name> |
| Pull-request workflows | repo:<owner>/<repo>:pull_request |
| Tag push | repo:<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.
Step 4 — Test it
Section titled “Step 4 — Test it”Push a no-op commit to the bound ref. Watch the audit log:
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.
Step 5 — Troubleshooting
Section titled “Step 5 — Troubleshooting”Upwarden’s verifier emits a structured reject reason on every failure. Pull the last hour:
# If you have Cloud Logging access (admin / SRE)gcloud logging read 'jsonPayload.event=~"^auth_failure_"' \ --limit 50 --freshness=1hThe most-common reject reasons and what they mean:
| Failure code | What it means | Most-common cause |
|---|---|---|
unbound_issuer | The iss claim isn’t on Upwarden’s allowlist | You typed the issuer URL wrong, or you’re on GitLab / CircleCI (not yet supported) |
bad_audience | The aud claim isn’t upwarden.io | Your workflow’s audience= parameter is missing or wrong |
signature_invalid | The signature didn’t verify | Token tampered or expired-while-cached; re-mint per request |
expired_or_not_yet_valid | exp 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_id | The token has no repository_id claim | The issuer requires one (GH Actions always emits it; the absence means the token is malformed) |
unbound_oidc_subject | The token verified but no binding matched | Your sub_pattern / repository_id / event_names don’t match the token’s claims — read on |
unbound_oidc_subject — what to check
Section titled “unbound_oidc_subject — what to check”The verifier compares the claim in full. Common gotchas:
sub_patternhas a typo. The GitHub OIDC token’ssublooks likerepo:acme/payments:ref:refs/heads/main. Copy it verbatim from the GitHub docs example for your trigger; don’t hand-construct.event_namesis too narrow. If you bound["pull_request"]but the workflow runs onpush, the event-name mismatch is the reject. Either expand the binding’sevent_namesor trigger the workflow differently.repository_idis wrong. Step 2’s numeric — confirm it matches the binding row.- The binding belongs to a different tenant. Bindings are tenant-scoped; a binding on tenant A doesn’t help tenant B.
- 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.
Confirming the token’s claims
Section titled “Confirming the token’s claims”If you want to see exactly what GitHub minted (without it ever leaving your runner), decode the payload locally:
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.
What’s next
Section titled “What’s next”- CI runner attribution — concepts — the trust-tier model behind
actor_source. - OIDC trust bindings — admin API reference — full CRUD reference (
GET/POST/DELETE), payload shapes, response codes. - Querying the audit log — JSON-API patterns including “show me only OIDC-attributed rows”.
- Dashboard → Audit log — the dashboard surfaces
actor_sourceand theci_contextsubpanel on every row.