OIDC trust bindings — admin API reference
Reference for the oidc-bindings admin API. The companion how-to walks the wire-up; the concept doc covers the trust model. This page documents the JSON API surface — endpoints, payloads, status codes, and the reject-reason catalog.
All endpoints live under /api/v1/admin/orgs/<slug>/oidc-bindings. All paths are case-sensitive. Capability gates: oidc:r for reads, oidc:w for writes (defaults to Org+ owner role; the dashboard’s role-override page lets you grant explicitly).
Authentication
Section titled “Authentication”Two auth modes accepted on every endpoint:
- Admin token (CI / scripted):
x-upwarden-admin-token: $ADMIN_API_TOKEN. Bypasses RBAC; treated as super-user. Audit rows stampsystem:admin-tokeninadded_by. - Stytch session (dashboard / interactive):
vg_admin=<session-cookie>. Subject to RBAC (oidc:roroidc:w). Audit rows stamp the Stytchmember.idinadded_by.
GET /api/v1/admin/orgs/<slug>/oidc-bindings
Section titled “GET /api/v1/admin/orgs/<slug>/oidc-bindings”List active + revoked bindings for the tenant. Capability: oidc:r. Returns every binding row — including soft-revoked rows — so a SIEM can reconstruct the history of trust changes.
Request
Section titled “Request”curl -fsS \ -H "x-upwarden-admin-token: $ADMIN_API_TOKEN" \ "https://vg-admin.<your-domain>/api/v1/admin/orgs/<slug>/oidc-bindings"Response — 200
Section titled “Response — 200”{ "org": "acme", "count": 2, "bindings": [ { "id": "obind_01HZ...", "projectId": "proj_01HZ...", "teamId": null, "issuer": "https://token.actions.githubusercontent.com", "subPattern": "repo:acme/payments:ref:refs/heads/main", "repositoryId": "12345", "eventNames": ["pull_request", "push"], "origin": "org_admin", "addedBy": "stytch:member-01HZ...", "addedAt": "2026-06-04T12:34:56Z", "revokedAt": null, "revokedBy": null, "revokedReason": null }, { "id": "obind_01HY...", "projectId": null, "teamId": null, "issuer": "https://token.actions.githubusercontent.com", "subPattern": "repo:acme/payments:ref:refs/heads/staging", "repositoryId": "12345", "eventNames": [], "origin": "org_admin", "addedBy": "system:admin-token", "addedAt": "2026-05-30T08:12:00Z", "revokedAt": "2026-06-02T15:00:00Z", "revokedBy": "stytch:member-01HZ...", "revokedReason": "deprecated staging branch" } ]}Ordering: newest first by addedAt (descending).
Response codes
Section titled “Response codes”| Code | Meaning |
|---|---|
| 200 | List returned (may be empty: {org, count: 0, bindings: []}) |
| 401 | Missing or invalid auth |
| 403 | Caller lacks oidc:r |
| 404 | Org slug not found |
POST /api/v1/admin/orgs/<slug>/oidc-bindings
Section titled “POST /api/v1/admin/orgs/<slug>/oidc-bindings”Create a binding. Capability: oidc:w.
Request
Section titled “Request”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", "project_id": "proj_01HZ..." }' \ "https://vg-admin.<your-domain>/api/v1/admin/orgs/<slug>/oidc-bindings"Payload fields
Section titled “Payload fields”| Field | Type | Required | Notes |
|---|---|---|---|
issuer | string | yes | Exact issuer URL. Currently the only accepted value is https://token.actions.githubusercontent.com. Off-allowlist returns 400. |
sub_pattern | string | yes | The full sub claim — no prefix, no wildcards, no regex. Max 1024 chars. No whitespace, no control characters. |
repository_id | string | integer | required when the issuer config sets requiresRepositoryId (GH Actions: yes) | Numeric ID as a string (preferred) or JSON number. Max 32 digits. |
event_names | string[] | no | Allowed event_name claim values. Each entry: 1–64 chars, no whitespace / control. Empty array (or omitted) = any event passes. |
origin | string | yes | org_admin or team_admin. platform_admin is reserved for the platform-admin surface and rejected here. |
project_id | string | no | Tenant’s project id. When set, the binding only attributes audit rows from API keys belonging to this project. |
team_id | string | yes when origin=team_admin | Tenant’s team id. Caller must be admin on this team. The proposed (sub_pattern, repository_id) MUST sit inside an active org_admin envelope for the same tenant — see Origin envelope check below. |
Response — 201
Section titled “Response — 201”{ "id": "obind_01HZ...", "issuer": "https://token.actions.githubusercontent.com", "subPattern": "repo:acme/payments:ref:refs/heads/main", "repositoryId": "12345", "eventNames": ["pull_request", "push"], "origin": "org_admin", "addedAt": "2026-06-05T14:22:01Z"}Response codes
Section titled “Response codes”| Code | Body shape | When |
|---|---|---|
| 201 | binding row | Created |
| 400 | {"error": "..."} | Validation failure — see Validation errors |
| 401 | {"error": "..."} | Missing or invalid auth |
| 403 | {"error": "..."} | Caller lacks oidc:w |
| 404 | {"error": "Org not found"} | Org slug unknown |
| 409 | {"error": "An active binding already exists for this (issuer, sub, repository_id)"} | Active binding already covers this triple |
| 500 | {"error": "..."} | Internal — paged-on if persistent |
Validation errors
Section titled “Validation errors”The 400 path returns one of these error strings (verbatim) so a script can match on them:
| Error string | Cause |
|---|---|
issuer is required (string) | Missing or non-string issuer |
issuer is not on the allowlist: <value> | Off-allowlist issuer URL |
sub_pattern is required (string) | Missing or non-string sub_pattern |
sub_pattern must be 1..1024 chars with no whitespace / control characters | Too long, empty, or contains whitespace / control characters |
repository_id must be a positive integer string | String form with non-digits or > 32 chars |
repository_id must be a positive integer or its string form | Non-string, non-integer, or negative |
repository_id is required for issuer <issuer> | Issuer requires it (GH Actions always does) and it’s missing |
event_names must be an array of strings | Wrong type |
every event_names entry must be a non-whitespace 1..64-char string | Entry too long, empty, or whitespace-bearing |
origin is required (string) | Missing or non-string origin |
origin must be 'org_admin' or 'team_admin' on this surface | platform_admin or unknown value |
project_id must be a string | Wrong type |
project_id not found for this org | Project id from a different tenant or unknown |
team_id must be a string | Wrong type |
team_id not found for this org | Team id from a different tenant or unknown |
team_id is required for origin=team_admin | Missing team_id for team-admin origin |
team_admin binding falls outside any active org_admin envelope — ask an org admin to expand the trust envelope first | Team-admin envelope check failed |
Origin envelope check
Section titled “Origin envelope check”When origin = team_admin, the server confirms there’s already an active org_admin binding for the same (tenant, issuer, sub_pattern, repository_id) triple. Without it, the create returns 400 with the envelope-check error above.
The rationale: team admins narrow within bounds the org has already approved. If your org admin hasn’t pre-approved trust for (repo, sub, repo_id), a team admin cannot grant it on their own. The recovery path is “ask an org admin to create the org-wide binding first; then add the team-scoped narrow binding”.
DELETE /api/v1/admin/orgs/<slug>/oidc-bindings/<id>
Section titled “DELETE /api/v1/admin/orgs/<slug>/oidc-bindings/<id>”Soft-revoke a binding. Capability: oidc:w. The row stays in the table forever so audit replay can answer “what binding stamped this old row?”.
Request
Section titled “Request”curl -fsS -X DELETE \ -H "x-upwarden-admin-token: $ADMIN_API_TOKEN" \ "https://vg-admin.<your-domain>/api/v1/admin/orgs/<slug>/oidc-bindings/obind_01HZ...?reason=rotated%20repo"The optional ?reason= query string (max 1024 chars) lands in revoked_reason for audit pivot.
Response — 200
Section titled “Response — 200”{ "revoked": "obind_01HZ..." }Response codes
Section titled “Response codes”| Code | Body shape | When |
|---|---|---|
| 200 | {"revoked": "obind_..."} | Revoked |
| 401 | {"error": "..."} | Missing or invalid auth |
| 403 | {"error": "..."} | Caller lacks oidc:w |
| 404 | {"error": "Binding not found for this org (or already revoked)"} | Wrong id, wrong tenant, or already-revoked |
Recreating after revoke
Section titled “Recreating after revoke”Revoking a binding frees the unique-index slot. After a DELETE you can POST a fresh binding with the same (issuer, sub_pattern, repository_id) triple and it will land as a new active row. The revoked row stays in the list and can be filtered by revoked_at IS NOT NULL.
Reject-reason catalog (verifier failures)
Section titled “Reject-reason catalog (verifier failures)”These are NOT admin-API responses — they’re the structured codes the verifier emits on every rejected proxy / admin request that carried an x-upwarden-ci-oidc header. They surface in Cloud Logging via gcloud logging read 'jsonPayload.event=~"^auth_failure_"'.
| Failure code | Strictness rule | What it means |
|---|---|---|
missing_token | — | Header empty or whitespace-only |
malformed_jwt | — | Not a valid JWS three-part shape |
unbound_issuer | 1 | iss not in allowlist |
bad_audience | 2 | aud not upwarden.io |
signature_invalid | 6 | JWKS signature didn’t verify (or algorithm not in pin list) |
expired_or_not_yet_valid | 7 | exp past, or iat/nbf > 60s in future |
missing_repository_id | 4 | Issuer requires it and token didn’t carry it |
unbound_oidc_subject | 3 + 4 + 5 | No active binding matched. Detail subfield surfaces the specific cause: missing_sub, binding_ambiguous, event_name_not_allowed, or absent (the generic no-match path) |
Audit envelope
Section titled “Audit envelope”Every binding mutation writes one row into audit_log. Filter by these event strings:
| Event | Carries |
|---|---|
oidc_binding_created | {binding_id, issuer, sub_pattern, repository_id, event_names, origin, project_id, team_id, by_user_id} |
oidc_binding_revoked | {binding_id, revoked_reason, by_user_id} |
The actor_source column on rows from a successful OIDC-verified request is one of oidc_github, oidc_gitlab, oidc_circleci. Pivot the audit log by actor_source LIKE 'oidc_%' to find verified-attribution rows; pivot by oidc_binding_id to find every row a specific binding stamped.
What’s next
Section titled “What’s next”- CI runner attribution — concepts — trust tiers, attack model, why this exists.
- Wire your CI for verified attribution — step-by-step wire-up + troubleshooting.
- Querying the audit log — query patterns including
actor_source LIKE 'oidc_%'. - Internal:
docs/dev/oidc-actor-attribution.md— the seven strictness rules, attack model, how to add an issuer.