Skip to content
API Reference

API Reference

Token Exchange

GET /sts/exchange

Exchange an OIDC bearer token for a scoped GitHub installation token.

ParameterRequiredDescription
scopeYesExact current canonical org/repo target. Organization-level scopes are rejected.
identityYesPolicy selector, maps to {base_path}/{app}/{identity}.sts.yaml
appNoApp name (defaults to single configured app)
permissionNoRepeatable name:level pair narrowing the token below what the trust policy allows. Omit to receive everything the policy grants.
curl -H "Authorization: Bearer $OIDC_TOKEN" \
  "http://localhost:8080/sts/exchange?scope=myorg/myrepo&app=default&identity=ci"

POST /sts/exchange

Same endpoint, JSON body variant. The permissions object is the body form of the permission query parameter.

curl -X POST -H "Authorization: Bearer $OIDC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"scope":"myorg/myrepo","app":"default","identity":"ci"}' \
  http://localhost:8080/sts/exchange

Requesting less privilege

The trust policy is a ceiling, not a fixed grant. A caller may ask for a subset of the policy’s permissions, or for a lower level than the policy allows, and receive a token limited to exactly that. This lets one policy serve a read-only job and a job that writes, instead of forcing either a second policy or an over-privileged token.

# policy grants contents:write and issues:write.
# this job only reads, so it asks for only that.
curl -X POST -H "Authorization: Bearer $OIDC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"scope":"myorg/myrepo","app":"default","identity":"ci","permissions":{"contents":"read"}}' \
  http://localhost:8080/sts/exchange

# the same request over GET
curl -H "Authorization: Bearer $OIDC_TOKEN" \
  "http://localhost:8080/sts/exchange?scope=myorg/myrepo&app=default&identity=ci&permission=contents:read"

Narrowing can only ever reduce privilege, so it needs no opt-in in the trust policy. Every entry must already be granted by the policy at the same level or higher. Anything else is an escalation attempt and returns 400 bad_request before any token is minted:

Policy grantsRequestResult
contents: writecontents: readToken carries contents: read
contents: writecontents: writeToken carries contents: write
contents: write, issues: writecontents: readToken carries contents: read only; issues is dropped
contents: writepackages: write400 – not granted by the policy
contents: writecontents: admin400 – above the policy ceiling
contents: write{} (empty object)400 – omit the field instead

Omitting the field entirely keeps the previous behaviour: the token receives everything the policy grants.

The error body is deliberately generic and names no permission, so a caller that cannot read the trust policy cannot map it out by probing. Operators get the full reason from the audit log via trace_id.

Response

Success (200):

{
  "token": "ghs_xxxxxxxxxxxxxxxxxxxx",
  "expires_in": 3600,
  "scope": "myorg/myrepo",
  "app": "default",
  "identity": "ci",
  "permissions": {
    "contents": "read",
    "pull_requests": "write",
    "metadata": "read"
  }
}

The returned ghs_… token is a standard GitHub App installation token.

expires_in is the token’s remaining lifetime in whole seconds, the field OAuth 2.0 Token Exchange (RFC 8693) defines for this purpose. The server derives it from the expiry GitHub returned with the token and measures it when it writes the response, so read it instead of hardcoding GitHub’s current one-hour lifetime. Refresh before the value reaches zero: the token stops working when it does.

The field is absent when GitHub returned no usable expiry. The token still works, so fall back to your own refresh interval rather than treating the absence as an error.

permissions reports the grant GitHub actually attached to the token, read back from GitHub’s create-token response. It is not the trust policy’s ceiling and not the request. It can legitimately differ from both: GitHub adds metadata: read to every installation token, and a narrowed request reports the narrowed set. To learn what an identity is allowed to ask for, read its trust policy rather than this field.

Error responses

Error responses share this shape:

{ "error": "forbidden", "code": "policy_denied", "trace_id": "abc-123" }

error is deliberately generic so attackers can’t probe the validator. Use the code field (the machine-readable error code) to branch in your client logic, and trace_id to correlate with server/audit logs (the log line carries the full reason).

StatuscodeWhat to fix
400bad_requestMissing/invalid query params, malformed/non-canonical target scope, unsupported organization scope, invalid JSON body, or requested permissions the trust policy does not allow.
403oidc_invalidOIDC token rejected (missing/expired, bad signature, unknown iss, missing kid, malformed). Refresh or re-mint the token; verify allowed_issuers.
403github_identity_invalidGitHub.com source identity is missing immutable ID claims, has contradictory repository claims, or uses a legacy sub while immutable subjects are required. Check repository OIDC settings and the audit reason at trace_id.
403audience_mismatchToken’s aud does not match the policy’s audience:. Pass the right value to core.getIDToken(<audience>) or update the policy.
403app_unknown?app= does not match a configured app. Check spelling or omit when only one app is configured.
403policy_not_foundNo .sts.yaml for this scope/app/identity. Verify the file path: {base_path}/{app}/{identity}.sts.yaml in the target (or org policy) repo.
403trust_policy_invalidThe loaded policy is malformed, lacks a workload selector or required GitHub ID binding, or otherwise violates the policy contract. Fix the target policy; details are in the audit log.
403policy_deniedA valid policy did not match the workload or exact immutable source-to-target relationship. Check the audit log line at trace_id for the precise mismatch.
403org_policy_deniedEnterprise policy was evaluated and denied the exchange after the YAML policy allowed it. Check audit bundle_decisions for bundle, package, rule, exception, and reason details.
405method_not_allowedUse GET or POST.
409replay_detectedJTI already consumed; obtain a fresh OIDC token.
503bundle_staleA configured bundle is older than max_staleness and uses fail-closed behavior. Restore bundle pull/verification or raise max_staleness intentionally.
503bundle_unavailableRequired mode could not prove that the mandatory baseline participated. Restore the global baseline and retry.
503bundle_evaluation_failedBundle evaluation faulted, such as a timeout, strict built-in error, or malformed decision result. Inspect the server log at trace_id.
500internal_errorServer-side problem (cache backend, app misconfig). Check server logs at trace_id.
502upstream_errorPolicy fetch or GitHub token mint failed. Check server logs at trace_id.

Bundle-related audit events always include bundle_enforcement. Once the bundle path is reached, bundle_digest and org_decision.applicable / org_decision.evaluated record participation; completed evaluations add bundle_decisions. This distinguishes org_policy_denied from dependency and evaluation failures.

Trust Policy Validation

POST /sts/v1/trust-policy/validate

Validate and lint .github/sts/{app}/{identity}.sts.yaml content for editor tooling such as VS Code. This endpoint validates the YAML trust-policy contract only. It does not evaluate enterprise Rego bundles and never returns exception inventory.

Send raw YAML:

curl -X POST http://localhost:8080/sts/v1/trust-policy/validate \
  -H "Content-Type: application/x-yaml" \
  --data-binary @.github/sts/default/ci.sts.yaml

Or send JSON:

curl -X POST http://localhost:8080/sts/v1/trust-policy/validate \
  -H "Content-Type: application/json" \
  -d '{"content":"issuer: https://issuer.example.com\nsubject: workload-1\naudience: https://sts.example.com\npermissions:\n  contents: read\n"}'

Successful validation returns 200 with valid: true and canonical formatted YAML:

{
  "valid": true,
  "diagnostics": [],
  "formatted": "issuer: https://issuer.example.com\nsubject: workload-1\naudience: https://sts.example.com\npermissions:\n    contents: read\n"
}

Invalid policy content returns 422 with diagnostics:

{
  "valid": false,
  "diagnostics": [
    {
      "severity": "error",
      "code": "subject_conflict",
      "message": "subject and subject_pattern are mutually exclusive",
      "path": "$.subject_pattern",
      "line": 3,
      "column": 1
    }
  ]
}

Diagnostic severities are error or warning. Errors include YAML parse errors, unknown fields, selector conflicts, unsupported repositories, invalid immutable IDs, and the same trust-policy validation errors enforced by the exchange path.

GET /sts/v1/trust-policy.json

Return the JSON Schema file from the loaded bundle at /data/sts/v1/trust-policy.json. The broker republishes those bytes without interpreting them, so this endpoint reflects whatever schema the operator’s own Rego bundle ships. It depends on an enabled bundle that carries the schema document and returns 503 if the bundle integration is disabled or the schema file is missing.

Most users do not need this endpoint. The project’s own trust policy schema is published as a static file that needs no broker and no authentication: see Trust Policy Schema. Use this endpoint when a deployment has customised the schema in its bundle and editors should follow that customised version.

Token Revocation

Tokens issued by github-sts are standard GitHub App installation tokens and can be revoked directly via the GitHub API:

curl -X DELETE https://api.github.com/installation/token \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  -H "Accept: application/vnd.github+json"
# 204 = revoked, 401/404 = already expired

Or via the Go client:

err := client.RevokeToken(ctx, token, "https://api.github.com")

When you use the github-sts-action, the token is revoked automatically at the end of the job. See Job Lifecycle for details.

Health & Readiness

EndpointMethodSuccessFailure
/healthGET200 with liveness, security posture, and configured bundle state401 when health authentication is configured and the Bearer token is missing or incorrect
/readyGET200 {"ready":true}503 {"ready":false}
/metricsGETPrometheus text format401 when metrics authentication is configured and the Bearer token is missing or incorrect

/health is a liveness probe. When authentication is disabled or satisfied, it returns 200 as long as the process is up. Set health.auth_token or GITHUBSTS_HEALTH_AUTH_TOKEN to require an Authorization: Bearer <token> header. A missing or incorrect token returns 401 with {"error":"unauthorized"} and WWW-Authenticate: Bearer realm="health". When neither setting supplies a token, /health remains unauthenticated. /ready stays unauthenticated.

After starting github-sts with GITHUBSTS_HEALTH_AUTH_TOKEN set, verify the behavior from the same host:

export GITHUBSTS_HEALTH_AUTH_TOKEN="replace-with-the-configured-secret"

curl -o /dev/null -s -w '%{http_code}\n' http://127.0.0.1:8080/health
# 401

curl -o /dev/null -s -w '%{http_code}\n' \
  -H "Authorization: Bearer $GITHUBSTS_HEALTH_AUTH_TOKEN" \
  http://127.0.0.1:8080/health
# 200

Use HTTPS whenever the Bearer token crosses a network. The loopback example uses HTTP only for local verification.

Its security object reports require_immutable_subject_claims, bundle_enforcement, and yaml_only_authorization_possible. yaml_only_authorization_possible is always false in required mode. In optional mode, it is true only when at least one configured App lacks both global bundle coverage and App-scoped bundle coverage. This field describes a configuration capability, not the authorization path taken by a specific exchange. /ready returns 200 with {"ready":true} once the server is serving and 503 with {"ready":false} while it is not. Readiness is not gated on GitHub API reachability; the reachability prober only updates the githubsts_github_reachable metric. Use /ready for Kubernetes readiness probes and load balancer health checks.

When bundles are configured, /health includes aggregate bundle state and a bundles array. Per-bundle status includes name, enabled, mandatory, digest, authoritative signed manifest policy_revision, age_seconds, and optional last_pull_error. Bundle pull failures do not change liveness; exchange-time behavior follows each bundle’s fail_mode and required-participation contract.

Completed exchange audit events include policy_revision in each bundle_decisions[] entry alongside the exact evaluated digest.

Go Client Library

import "github.com/depthmark/github-sts/client"

// Direct GitHub App token (requires the App private key)
provider, err := client.NewAppTokenProvider(appID, pemData, "myorg", "https://api.github.com")
if err != nil { /* handle */ }
token, err := provider.Token(ctx)

// STS token exchange (requires an OIDC token and the STS URL)
stsProvider := &client.STSTokenProvider{
    STSURL:     "https://sts.example.com",
    Identity:   "ci",
    Scope:      "myorg/myrepo",
    App:        "default",
    Audience:   "https://sts.example.com",
    SATokenPath: client.DefaultSATokenPath,
}
token, err = stsProvider.Token(ctx)

// Token revocation
err = client.RevokeToken(ctx, token, "https://api.github.com")

NewAppTokenProvider(appID int64, pemData []byte, owner, apiURL string, opts ...Option) returns (*AppTokenProvider, error); its Token(ctx context.Context) (string, error) method mints a token. STSTokenProvider is a plain struct with a Token(ctx) method (there is no constructor). client.DefaultSATokenPath is /var/run/secrets/tokens/github-sts-token.

The client lives at client/ and is importable as github.com/depthmark/github-sts/client.