A GitHub App that acts like a Security Token Service (STS) for the Github API
A GitHub App that acts like a Security Token Service (STS) for the Github API
octo-sts: an STS for GitHubThis repository holds a GitHub App called octo-sts that acts like a Security
Token Service (STS) for the GitHub API. Using this App, workloads running
essentially anywhere that can produce OIDC tokens can federate with this App's
STS API in order to produce short-lived tokens for interacting with GitHub.
The ultimate goal of this App is to wholly eliminate the need for GitHub Personal Access Tokens (aka PATs).
The original blog post and the page on Chainguard Academy.
For the App to produce credentials that work with resources in your organization
it must be installed into the organization and have access to any repositories
that you will want workloads to be able to interact with. Unfortunately due to
limitations with GitHub Apps, the App must ask for a superset of the permissions
needed for federation, so the full set of permissions the App requests will be
large, but with one exception (contents: read reading policy files) the App
only creates tokens with these scopes based on the "trust policies" you have
configured.
Trust policies are checked into .github/chainguard/{name}.sts.yaml, and
consist of a few key parts:
Here is a simple example that allows the GitHub actions workflows in
chainguard-dev/foo running on the main branch to read the repo contents and
interact with issues:
# yaml-language-server: $schema=https://raw.githubusercontent.com/octo-sts/app/refs/heads/main/pkg/octosts/octosts.TrustPolicy.json
issuer: https://token.actions.githubusercontent.com
subject: repo:chainguard-dev/foo:ref:refs/heads/main
permissions:
contents: read
issues: write
The Trust Policy can also match the issuer, subject, and even custom claims with regular expressions. For example:
# yaml-language-server: $schema=https://raw.githubusercontent.com/octo-sts/app/refs/heads/main/pkg/octosts/octosts.TrustPolicy.json
issuer: https://accounts.google.com
subject_pattern: "[0-9]+"
claim_pattern:
email: ".*@chainguard.dev"
permissions:
contents: read
This policy will allow OIDC tokens from Google accounts of folks with a Chainguard email address to federate and read the repo contents.
Every pattern is matched against the whole value: subject_pattern: "[0-9]+"
accepts 123 but not 123x, and refs/heads/main|refs/heads/develop accepts
exactly those two refs. Do not add ^ or $ yourself.
JSONSchemas are available to aid in IDE autocompletion:
We recommend using vscode-yaml.
This will read the # yaml-language-server: $schema=... header and provide code completion.
An organization restricts which OIDC issuers can federate with its repositories.
Add /.github/chainguard/trusted-token-issuers.yaml to the
organization's policy repository (.github by default; see ORG_POLICY_REPO
below). octo-sts rejects a token with an issuer that the file does not permit.
It rejects the token before it reads the trust policy.
| Variable | Default | Description |
|---|---|---|
ORG_POLICY_REPO |
.github |
Repository within the organization that holds the org-issuer allowlist. Must exist and be accessible to the App before enforcement takes effect. |
Warning: migration hazard. If you set
ORG_POLICY_REPO=my-policieswhile the allowlist is still in.github, octo-sts readsmy-policiesinstead. That file does not exist yet, so the read returns 404. A 404 is treated as "no allowlist" — meaning all issuers are permitted for that organization, silently. Create or move the allowlist into the new repository and protect it before flippingORG_POLICY_REPO.
Note: two-binary config skew. The exchange service and the webhook validator each read
ORG_POLICY_REPOfrom their own deployment config. If you setORG_POLICY_REPOon only one of the two, the webhook validates a different repository than the exchange enforces. Update both deployments together.
…
octo-sts matches an entry in issuers exactly. It matches an entry in
issuer_patterns as an anchored regular expression against the whole issuer URL.
An issuer passes if it matches either list. Matching is case-sensitive, so write
the scheme and the host in lowercase.
octo-sts rejects a pattern that can match /. The / character separates the
host from the path. A pattern that matches / can therefore reach past the host.
Anchors at both ends do not prevent this, because a pattern can stay loose in the
middle.
octo-sts parses each pattern with the same parser that Go's regexp package uses.
It then applies two rules:
. that can match /, at any position. This
covers \S, [^\n], [[:ascii:]] and a range such as [.-9]./ inside a repetition, an optional group, or an
alternation. A group such as ([a-z0-9.-]+/)* repeats across separators.A literal / at a fixed position is correct. Every issuer with a path has one.
These rules catch a common mistake: a pattern that spans into another domain.
https://.*.example.com reads as "any subdomain of example.com". It also permits
https://evil.attacker-example.com, because the unescaped . before example
matches -. It also permits https://totally-evil.com/x.example.com, where an
attacker controls the whole host.
Write a literal dot as \.. Give an explicit character class for the part that
varies. https://[a-z0-9-]+\.example\.com expresses "subdomains of example.com",
and octo-sts accepts it. The error message names the pattern that failed.
Write a path one segment at a time, with literal separators. Use
https://example\.com/realms/[a-z0-9-]+. Do not use
https://example\.com/[a-z0-9/-]+. octo-sts deliberately gives you no way to match
a path of variable depth.
This is a best-effort guard, not a guarantee. It catches accidents. It does not stop a determined author. Write your patterns as narrowly as you can. An organization owner writes this file, and the control serves that owner. An over-broad pattern is therefore an unwise choice, not an attack.
No file means no restriction. An organization that adds no file sees no change.
mode: audit. octo-sts rejects nothing in audit mode.mode line to enforce.Audit mode does not protect you against a broken file or a failed lookup.
octo-sts reads the allowlist with a short-lived contents: read token. The token
is scoped to the ORG_POLICY_REPO repository (.github by default).
Enforcement silently does not apply if octo-sts cannot read that repository.
The most common cause is an App that is installed on selected repositories only.
octo-sts cannot report which cause it hit. GitHub answers a token request for an inaccessible repository with one 422 status. That status does not separate "the repository does not exist" from "you do not have access".
Grant the App access to the policy repository before you rely on this control.
| Situation | Result |
|---|---|
| File absent | octo-sts permits all issuers |
No App can read ORG_POLICY_REPO |
octo-sts permits all issuers and logs a warning. It first re-reads the installation list from GitHub, without its local cache. See effect 4 below |
| Rate limited, or GitHub unavailable | octo-sts uses the last known good allowlist. If there is none, it rejects the exchange |
| File present but invalid | octo-sts uses the last known good allowlist. If there is none, it rejects every exchange in the organization |
This caching creates four timing effects.
Plan for effect 3. "No file" is itself a valid last known good state. An organization that adds its first allowlist during a GitHub incident can therefore keep permitting all issuers for up to one hour. This behavior is deliberate. It keeps the organizations that do not use this feature working through the incident. One successful read replaces the state. Check that enforcement started. Do not assume it started.
Effect 4 has a different cause. Before octo-sts concludes that no installation can read the policy repository, it re-reads the installation list from GitHub without its local cache. GitHub does not list a new installation at once. octo-sts cannot see an installation that GitHub has not yet propagated.
This control is only as strong as write access to the policy repository.
Create the policy repository before you need it. GitHub does not reserve repository names. In an organization without this repository, any member who can create a repository becomes the sole author of this control. Restrict who can create repositories.
Protect the default branch of the policy repository. Make the
Trust Policy Validation check a required status check. The check validates
this file on every pull request. It only reports. It blocks nothing until you make
it required.
Add a CODEOWNERS entry for the file.
Scope organization-level trust policies with repositories:. An
organization-level policy that grants contents: write without a repositories:
restriction covers every repository the installation can see. That includes the
policy repository. A federated identity can then rewrite the allowlist that
constrains it. Any permission that writes, renames, or deletes the allowlist file
defeats this control. Deletion of the file fails open.
.github-private is not consulted. The file must live in ORG_POLICY_REPO.
The GitHub App implements the Chainguard SecurityTokenService GRPC service
definition here.
If a ${TOKEN} suitable for federation is sent like so:
curl -H "Authorization: Bearer ${TOKEN}" \
"https://octo-sts.dev/sts/exchange?scope=${REPO}&identity=${NAME}"
The App will attempt to load the trust policy from
.github/chainguard/${NAME}.sts.yaml from ${REPO} and if the provided ${TOKEN}
satisfies those rules, it will return a token with the permissions in the trust
policy.
Our release cadence at this moment is set to when is needed, meaning if we have a bug fix or a new feature we will might make a new release.
For self-hosting, container images are published to
ghcr.io/octo-sts/app and ghcr.io/octo-sts/webhook. A release tagged vX.Y.Z
is pushed as X.Y.Z and latest.
Images are signed keylessly with [cosign](https://github.com/
No open issues yet, or sync has not completed.