Exchange OIDC tokens for short-lived, scoped GitHub installation tokens. No PATs. No long-lived secrets.
Workloads with OIDC tokens (GitHub Actions, Azure, GCP, any IdP) present their identity and receive a least-privilege GitHub token scoped to exactly the repositories and permissions they need. Supports multiple GitHub Apps with YAML-based configuration, making it ideal for Kubernetes ConfigMaps.
Inspired by octo-sts/app, which pioneered OIDC federation for GitHub token exchange.
| Feature | Description | |
|---|---|---|
| Zero-trust | OIDC Federation | No stored credentials — identity verified via OIDC JWT validation |
| Least-privilege | Policy-based Scoping | YAML trust policies define exact permissions per workload identity |
| Multi-app | Multiple GitHub Apps | Route different workloads through different GitHub Apps |
| Org-scope | Organization Tokens | Issue tokens scoped to an entire org or a subset of repositories |
| Observable | Prometheus Metrics | Built-in metrics and structured audit logging |
| Replay-safe | JTI Cache | Memory or Redis-backed JTI tracking prevents token replay attacks |
| Portable | Distroless Container | Single static binary in a minimal container — runs anywhere |
- Architecture
- Quick Start
- Usage
- Trust Policies
- Configuration
- API Reference
- Deployment
- Observability
- Development
- Troubleshooting
- Contributing
- License
flowchart LR
subgraph Workloads
A["GitHub Actions"]
B["Cloud Providers"]
C["Internal Tools"]
end
subgraph github-sts
D["OIDC Validator"]
F["Token Issuer"]
end
subgraph GitHub
E["Trust Policies\n.sts.yaml"]
G["GitHub API"]
end
A -- "OIDC JWT" --> D
B -- "OIDC JWT" --> D
C -- "OIDC JWT" --> D
D -- "load & evaluate" --> E
E -- "approved scope + perms" --> F
F -- "create installation token" --> G
G -- "scoped token" --> F
F -- "short-lived token" --> A
F -- "short-lived token" --> B
F -- "short-lived token" --> C
- A workload presents its OIDC JWT to the
/sts/exchangeendpoint - github-sts validates the token signature, expiry, and issuer against JWKS
- The trust policy (stored in the target repo) is loaded and evaluated against the JWT claims
- If approved, github-sts requests a scoped installation token from the GitHub API
- The short-lived token is returned to the workload with only the permitted permissions
Workload ──OIDC JWT──> github-sts ──validates──> loads policy ──approved──> GitHub API
│
Workload <──scoped token + permissions──────────────────────────────────────────
cmd/github-sts/ Entry point — server bootstrap, signal handling
client/ Importable Go client library (token exchange + revocation)
internal/
config/ YAML + env var configuration
audit/ Channel-based async audit logger
handler/ HTTP handlers (exchange, health, readiness)
server/ HTTP server lifecycle, middleware, graceful shutdown
metrics/ Prometheus metrics registry
oidc/ OIDC JWT validation with JWKS caching
policy/ Trust policy loading & claim evaluation
jti/ JTI replay cache (in-memory + Redis)
github/ GitHub App auth, installation token provider
config/examples/ Ready-to-use trust policy templates
- Go 1.26+ (local development) or Docker (container builds)
- A GitHub App with the permissions you want to delegate
# Option A: Environment variables
export GITHUBSTS_APP_DEFAULT_APP_ID="123456"
export GITHUBSTS_APP_DEFAULT_PRIVATE_KEY="$(cat /path/to/private-key.pem)"
# Option B: YAML config file (see config/github-sts.example.yaml)
export GITHUBSTS_CONFIG_PATH=./config/github-sts.example.yaml| Go | Docker |
|---|---|
go build -o github-sts ./cmd/github-sts
./github-sts |
docker build -t github-sts:local .
docker run -p 8080:8080 \
-e GITHUBSTS_APP_DEFAULT_APP_ID \
-e GITHUBSTS_APP_DEFAULT_PRIVATE_KEY \
github-sts:local |
curl http://localhost:8080/health # {"status":"ok"}
curl http://localhost:8080/ready # {"status":"ready"}curl -H "Authorization: Bearer $OIDC_TOKEN" \
"http://localhost:8080/sts/exchange?scope=org/repo&app=default&identity=ci"import "github.com/depthmark/github-sts/client"
// Direct GitHub App token (requires private key)
provider, _ := client.NewAppTokenProvider(appID, orgOrOwner, pemBytes)
token, _ := provider.Token(ctx, "org/repo", "ci", permissions, nil)
// STS token exchange (requires OIDC token + STS URL)
stsProvider := client.NewSTSTokenProvider(stsURL, oidcTokenPath)
token, _ := stsProvider.Token(ctx, "org/repo", "ci", "my-app", "")
// Token revocation
err := client.RevokeToken(ctx, token, "https://api.github.com")Trust policies are YAML files stored in the target repository that define which OIDC identities can request tokens and with what permissions.
Location: .github/sts/{app_name}/{identity}.sts.yaml
For example, app=my-app and identity=ci resolves to:
.github/sts/my-app/ci.sts.yaml
| Field | Type | Description |
|---|---|---|
issuer |
string |
OIDC iss claim (exact match) |
subject |
string |
OIDC sub claim (exact match) |
subject_pattern |
regex |
OIDC sub claim (regex, used when subject is absent) |
claim_pattern |
map[string]regex |
Additional JWT claims to match |
audience |
string |
Required. Expected OIDC aud claim. A policy without it would accept tokens minted for any other relying party sharing the issuer (cross-RP token reuse) and is rejected at parse time. |
repositories |
list[string] |
Restrict org-scoped tokens to specific repos |
permissions |
map[string]string |
GitHub App permissions (read / write / admin) |
Exact match (most secure):
issuer: https://token.actions.githubusercontent.com
subject: repo:org/repo:ref:refs/heads/main
audience: https://sts.example.com
permissions:
contents: read
issues: writeRegex patterns (flexible — Azure example):
issuer: https://login.microsoftonline.com/{tenant-id}/v2.0
subject_pattern: "[a-f0-9-]+"
claim_pattern:
azp: "your-azure-app-client-id"
permissions:
contents: readRestrict to specific workflow (least-privilege):
issuer: https://token.actions.githubusercontent.com
subject_pattern: "repo:org/repo:.*"
audience: https://sts.example.com
claim_pattern:
job_workflow_ref: "org/repo/.github/workflows/deploy\\.yml@.*"
permissions:
deployments: write
statuses: write
audienceis mandatory. Every policy must declare the OIDC audience it trusts. The same value must be passed tocore.getIDToken(<audience>)in the workflow that requests the token. A missingaudience:is rejected at policy parse time — it would otherwise accept tokens minted for any other relying party that shares the issuer (cross-RP token reuse).
In addition to repo-level scope (scope=org/repo), github-sts supports org-level scope (scope=myorg):
- Org-wide tokens — permissions across all repositories
- Repo-restricted org tokens — scope to a subset via the
repositoriesfield - Org-level permissions —
organization_administration,members, etc.
Configure org_policy_repo to specify where org-level policies live:
# In server config
apps:
default:
app_id: 123456
private_key_path: "/etc/github-sts/keys/default.pem"
org_policy_repo: ".github"# Or via environment variable
export GITHUBSTS_APP_DEFAULT_ORG_POLICY_REPO=".github"Org-level policy example (placed in myorg/.github/.github/sts/default/org-ci.sts.yaml):
issuer: https://token.actions.githubusercontent.com
subject_pattern: "repo:myorg/.*"
audience: https://sts.example.com
repositories:
- frontend
- backend
- shared-libs
permissions:
contents: read
pull_requests: writeWhen the same identity (e.g. default/ci) has a policy file in both the
requesting repo and the org policy repo, policy_resolution decides which one
wins:
| Mode | Order | On collision | Use when |
|---|---|---|---|
org_first (default) |
org → repo fallback | org wins | Org admin owns identity names; repos may self-service identities the org has not claimed. |
repo_first (legacy, deprecated) |
repo → org fallback | repo wins | Backwards-compat only; allows repo owners to override the centralized policy. Emits a deprecation warning at startup. |
org_only |
org repo only, no fallback | n/a | Strictly forbid self-service. Repos cannot define their own policies. |
The mode is configured per app:
apps:
default:
app_id: 123456
private_key_path: "/etc/github-sts/keys/default.pem"
org_policy_repo: ".github"
policy_resolution: org_first # default; can be "repo_first" or "org_only"export GITHUBSTS_APP_DEFAULT_POLICY_RESOLUTION="org_first"The org_first default treats the org policy repo as a reservation list:
any identity name the org admin writes a file for is reserved org-wide;
identities the org has not claimed are delegated to repos. This closes a
historical bypass where a repo owner could shadow a centralized policy by
dropping a permissive file in their own repo.
If org_policy_repo is unset, only the requesting repo is consulted regardless
of mode.
github-sts supports YAML configuration files and environment variable overrides.
See config/github-sts.example.yaml for a complete example.
export GITHUBSTS_CONFIG_PATH=/etc/github-sts/config.yamlAll environment variables use the GITHUBSTS_ prefix. Per-app variables use GITHUBSTS_APP_{NAME}_{FIELD}.
| Variable | Default | Description |
|---|---|---|
GITHUBSTS_CONFIG_PATH |
— | Path to YAML config file |
GITHUBSTS_PORT |
8080 |
HTTP listen port |
GITHUBSTS_LOG_LEVEL |
INFO |
DEBUG, INFO, WARNING, ERROR |
GITHUBSTS_SUPPRESS_HEALTH_LOGS |
true |
Suppress health endpoint access logs |
GITHUBSTS_METRICS_ENABLED |
true |
Enable Prometheus metrics |
| Variable | Default | Description |
|---|---|---|
GITHUBSTS_APP_{NAME}_APP_ID |
required | GitHub App numeric ID |
GITHUBSTS_APP_{NAME}_PRIVATE_KEY |
required | PEM string (mutually exclusive with _PATH) |
GITHUBSTS_APP_{NAME}_PRIVATE_KEY_PATH |
— | Path to PEM file |
GITHUBSTS_APP_{NAME}_ORG_POLICY_REPO |
— | Repo for org-level policies (e.g. .github) |
GITHUBSTS_APP_{NAME}_POLICY_RESOLUTION |
org_first |
Resolution mode: org_first, repo_first (deprecated), or org_only |
| Variable | Default | Description |
|---|---|---|
GITHUBSTS_POLICY_BASE_PATH |
.github/sts |
Base path in repos for trust policies |
GITHUBSTS_POLICY_CACHE_TTL |
60s |
Policy cache TTL (0 to disable) |
GITHUBSTS_OIDC_ALLOWED_ISSUERS |
— | Comma-separated issuer allowlist (empty = any) |
GITHUBSTS_OIDC_REQUIRED_AUDIENCE |
— | Server-wide required aud claim. When set, every token must carry this value (defense-in-depth on top of the per-policy audience: field). |
GITHUBSTS_JTI_BACKEND |
memory |
memory or redis |
GITHUBSTS_JTI_REDIS_URL |
— | Redis connection URL (when backend=redis) |
GITHUBSTS_JTI_TTL |
1h |
JTI replay protection window |
| Variable | Default | Description |
|---|---|---|
GITHUBSTS_AUDIT_FILE_PATH |
./audit.log |
Audit log file path |
GITHUBSTS_AUDIT_BUFFER_SIZE |
1024 |
Audit channel buffer size |
Exchange an OIDC bearer token for a scoped GitHub installation token.
| Parameter | Required | Description |
|---|---|---|
scope |
Yes | org/repo (repo-level) or org (org-level) |
identity |
Yes | Policy selector — maps to {base_path}/{app}/{identity}.sts.yaml |
app |
No | App name (defaults to single configured app) |
curl -H "Authorization: Bearer $OIDC_TOKEN" \
"http://localhost:8080/sts/exchange?scope=myorg/myrepo&app=default&identity=ci"Same endpoint, JSON body variant.
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/exchangeSuccess (200):
{
"token": "ghs_xxxxxxxxxxxxxxxxxxxx",
"scope": "myorg/myrepo",
"app": "default",
"identity": "ci",
"permissions": {
"contents": "read",
"pull_requests": "write"
}
}Errors:
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 code to branch in your client and trace_id to correlate with server/audit logs (the log line carries the full reason).
| Status | code |
What to fix |
|---|---|---|
400 |
bad_request |
Missing/invalid query params or JSON body. |
403 |
oidc_invalid |
OIDC token rejected (missing/expired, bad signature, unknown iss, missing kid, malformed). Refresh or re-mint the token; verify allowed_issuers. |
403 |
audience_mismatch |
Token's aud does not match the policy's audience:. Pass the right value to core.getIDToken(<audience>) or update the policy. |
403 |
app_unknown |
?app= does not match a configured app. Check spelling or omit when only one app is configured. |
403 |
policy_not_found |
No .sts.yaml for this scope/app/identity. Verify the file path: {base_path}/{app}/{identity}.sts.yaml in the target (or org policy) repo. |
403 |
policy_denied |
Policy exists but evaluation failed (subject, claim_pattern). Check the audit log line at trace_id for the precise mismatch. |
405 |
method_not_allowed |
Use GET or POST. |
409 |
replay_detected |
JTI already consumed; obtain a fresh OIDC token. |
500 |
internal_error |
Server-side problem (cache backend, app misconfig). Check server logs at trace_id. |
502 |
upstream_error |
Policy fetch or GitHub token mint failed. Check server logs at trace_id. |
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 expiredOr via the Go client:
err := client.RevokeToken(ctx, token, "https://api.github.com")| Endpoint | Method | Success | Failure |
|---|---|---|---|
/health |
GET |
200 {"status":"ok"} |
— |
/ready |
GET |
200 {"status":"ready"} |
503 {"status":"not ready"} |
/metrics |
GET |
Prometheus text format | — |
# Build
docker build -t github-sts:local .
# Run with config file
docker run -p 8080:8080 \
-v $(pwd)/config/github-sts.example.yaml:/etc/github-sts/config.yaml:ro \
-e GITHUBSTS_CONFIG_PATH=/etc/github-sts/config.yaml \
-e GITHUBSTS_APP_DEFAULT_APP_ID="$GITHUBSTS_APP_DEFAULT_APP_ID" \
-e GITHUBSTS_APP_DEFAULT_PRIVATE_KEY="$GITHUBSTS_APP_DEFAULT_PRIVATE_KEY" \
github-sts:local
# Run with env vars only
docker run -p 8080:8080 \
-e GITHUBSTS_CONFIG_PATH=/dev/null \
-e GITHUBSTS_APP_DEFAULT_APP_ID="$GITHUBSTS_APP_DEFAULT_APP_ID" \
-e GITHUBSTS_APP_DEFAULT_PRIVATE_KEY="$GITHUBSTS_APP_DEFAULT_PRIVATE_KEY" \
-e GITHUBSTS_OIDC_ALLOWED_ISSUERS="https://token.actions.githubusercontent.com" \
-e GITHUBSTS_OIDC_REQUIRED_AUDIENCE="https://sts.example.com" \
github-sts:localThe image uses distroless with a nonroot user for a minimal attack surface.
A Helm chart is maintained in a separate repository: github-sts-helm.
See the github-sts-helm repository for installation instructions, Ingress/HTTPRoute setup, and full configuration options.
All metrics are exposed at GET /metrics in Prometheus text format with the githubsts_ prefix.
Full metrics reference
| Metric | Type | Description |
|---|---|---|
githubsts_http_requests_total |
Counter | HTTP requests by method, path, status |
githubsts_http_request_duration_seconds |
Histogram | HTTP request latency |
githubsts_http_requests_in_flight |
Gauge | Concurrent requests |
githubsts_token_exchanges_total |
Counter | Exchange attempts by app, scope, identity, result |
githubsts_token_exchange_duration_seconds |
Histogram | Exchange latency |
githubsts_oidc_validation_errors_total |
Counter | OIDC failures by issuer, reason |
githubsts_policy_loads_total |
Counter | Policy loads by app, backend, result |
githubsts_policy_cache_hits_total |
Counter | Cache hits by app |
githubsts_policy_cache_misses_total |
Counter | Cache misses by app |
githubsts_github_api_calls_total |
Counter | GitHub API calls by app, endpoint, result |
githubsts_github_tokens_issued_total |
Counter | Tokens issued by app, scope, permissions |
githubsts_github_rate_limit_remaining |
Gauge | Remaining rate limit by app, resource |
githubsts_github_reachable |
Gauge | GitHub API reachability (1/0) by app |
githubsts_jti_replay_attempts_total |
Counter | JTI replay attacks detected |
githubsts_audit_events_dropped_total |
Counter | Audit events dropped (full buffer) |
githubsts_ready |
Gauge | Instance readiness (1/0) |
go build -o github-sts ./cmd/github-sts # dev build
CGO_ENABLED=0 go build -ldflags="-s -w" -o github-sts ./cmd/github-sts # production
go vet ./... # static analysis
golangci-lint run ./... # lintinggo test -race -v ./... # all tests
go test -race -coverprofile=coverage.out ./... # with coverage
go tool cover -html=coverage.out # view coverage in browser
go test -race -v ./internal/policy/... # specific package
go test -race -v -run TestExchange ./internal/handler/... # specific testRun GitHub Actions workflows locally with act:
make act # all CI jobs
make act-lint # lint only
make act-test # test only
make act-build # build only| Problem | Solution |
|---|---|
Docker build fails with go.mod requires go >= X |
Update FROM golang:X-alpine in Dockerfile to match go.mod |
| Health check fails | Verify GITHUBSTS_CONFIG_PATH is set and the file exists |
Exchange returns 401 |
Check OIDC token expiry, verify allowed_issuers includes the issuer, review server logs |
Exchange returns 403 with code: "audience_mismatch" |
Token's aud does not match. Verify core.getIDToken(<audience>) in the workflow uses the same value as the policy's audience: field (and oidc.required_audience if configured server-side). |
Exchange returns 403 (any other code) |
Look at code in the response body — it tells you which layer rejected the request (oidc_invalid, app_unknown, policy_not_found, policy_denied). Then grep server logs for the trace_id returned in the same response for the precise reason. See Errors for the full table. |
Exchange returns 404 |
Verify the trust policy exists at {base_path}/{app}/{identity}.sts.yaml in the target repo |
Exchange returns 409 |
JTI replay — the OIDC token was already used. Obtain a fresh token |
Contributions are welcome! Areas of interest:
- Security hardening
- Policy evaluation capabilities
- Observability and monitoring
- Documentation improvements
- New identity provider integrations
MIT License — Copyright (c) 2026 Alexandre Delisle
- octo-sts/app — Original Go implementation that pioneered OIDC-to-GitHub token exchange
- GitHub OIDC Documentation
- OpenID Connect Specification
- GitHub App Documentation