Skip to content

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

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 stamp system:admin-token in added_by.
  • Stytch session (dashboard / interactive): vg_admin=<session-cookie>. Subject to RBAC (oidc:r or oidc:w). Audit rows stamp the Stytch member.id in added_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.

Terminal window
curl -fsS \
-H "x-upwarden-admin-token: $ADMIN_API_TOKEN" \
"https://vg-admin.<your-domain>/api/v1/admin/orgs/<slug>/oidc-bindings"
{
"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).

CodeMeaning
200List returned (may be empty: {org, count: 0, bindings: []})
401Missing or invalid auth
403Caller lacks oidc:r
404Org 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.

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",
"project_id": "proj_01HZ..."
}' \
"https://vg-admin.<your-domain>/api/v1/admin/orgs/<slug>/oidc-bindings"
FieldTypeRequiredNotes
issuerstringyesExact issuer URL. Currently the only accepted value is https://token.actions.githubusercontent.com. Off-allowlist returns 400.
sub_patternstringyesThe full sub claim — no prefix, no wildcards, no regex. Max 1024 chars. No whitespace, no control characters.
repository_idstring | integerrequired when the issuer config sets requiresRepositoryId (GH Actions: yes)Numeric ID as a string (preferred) or JSON number. Max 32 digits.
event_namesstring[]noAllowed event_name claim values. Each entry: 1–64 chars, no whitespace / control. Empty array (or omitted) = any event passes.
originstringyesorg_admin or team_admin. platform_admin is reserved for the platform-admin surface and rejected here.
project_idstringnoTenant’s project id. When set, the binding only attributes audit rows from API keys belonging to this project.
team_idstringyes when origin=team_adminTenant’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.
{
"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"
}
CodeBody shapeWhen
201binding rowCreated
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

The 400 path returns one of these error strings (verbatim) so a script can match on them:

Error stringCause
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 charactersToo long, empty, or contains whitespace / control characters
repository_id must be a positive integer stringString form with non-digits or > 32 chars
repository_id must be a positive integer or its string formNon-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 stringsWrong type
every event_names entry must be a non-whitespace 1..64-char stringEntry 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 surfaceplatform_admin or unknown value
project_id must be a stringWrong type
project_id not found for this orgProject id from a different tenant or unknown
team_id must be a stringWrong type
team_id not found for this orgTeam id from a different tenant or unknown
team_id is required for origin=team_adminMissing 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 firstTeam-admin envelope check failed

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

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

{ "revoked": "obind_01HZ..." }
CodeBody shapeWhen
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

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.

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 codeStrictness ruleWhat it means
missing_tokenHeader empty or whitespace-only
malformed_jwtNot a valid JWS three-part shape
unbound_issuer1iss not in allowlist
bad_audience2aud not upwarden.io
signature_invalid6JWKS signature didn’t verify (or algorithm not in pin list)
expired_or_not_yet_valid7exp past, or iat/nbf > 60s in future
missing_repository_id4Issuer requires it and token didn’t carry it
unbound_oidc_subject3 + 4 + 5No active binding matched. Detail subfield surfaces the specific cause: missing_sub, binding_ambiguous, event_name_not_allowed, or absent (the generic no-match path)

Every binding mutation writes one row into audit_log. Filter by these event strings:

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