A GitHub App that acts like a Security Token Service (STS) for the Github API
Go
407
1,166 commits
updated Sep 23, 2026
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 <ORG_POLICY_REPO>/.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.
# yaml-language-server: $schema=https://raw.githubusercontent.com/octo-sts/app/refs/heads/main/pkg/octosts/octosts.OrgTrustedIssuers.json
# "enforce" (the default) rejects a token that the allowlist does not permit.
# "audit" permits them and records each one the allowlist rejects.
mode: enforce
# octo-sts matches these exactly. A trailing slash makes a different issuer.
issuers:
- https://token.actions.githubusercontent.com
- https://accounts.google.com
# octo-sts matches these as anchored regexps against the whole issuer URL.
# Escape a literal dot. octo-sts rejects a wildcard that can match "/".
issuer_patterns:
- https://oidc\.eks\.[a-z0-9-]+\.amazonaws\.com/id/[A-Z0-9]+
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, using the GitHub Actions workflow that built them as the signing identity. Verify one before use:
cosign verify \
--certificate-identity-regexp '^https://github\.com/octo-sts/app/\.github/workflows/container\.yaml@refs/tags/v.*$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/octo-sts/app:latest
When multiple GitHub Apps are configured (GITHUB_APP_IDS has more than one
entry), OctoSTS distributes token exchanges across installations using
capacity-aware fairshare routing. Trust policies with checks: write require
sticky routing — the same (scope, identity) pair must always receive a token
from the same installation because GitHub check runs can only be updated by the
app that created them.
The sticky store persists these (scope, identity) -> installation mappings so
they survive process restarts and deploys. Without it, checks:write policies fall
back to round-robin (non-sticky) routing which may break check-run updates.
Firestore backend (recommended for GCP deployments):
| Variable | Default | Description |
|---|---|---|
OCTOSTS_STICKY_STORE | (empty) | Set to firestore to enable |
OCTOSTS_STICKY_STORE_FIRESTORE_PROJECT | running GCP project | Firestore GCP project |
OCTOSTS_STICKY_STORE_FIRESTORE_COLLECTION | sticky-routes | Firestore collection name |
OCTOSTS_STICKY_STORE_FIRESTORE_TTL | 1h | TTL for inactive mappings |
Active mappings have their TTL refreshed on every use, so they never expire. Only mappings unused for the TTL duration are automatically cleaned up.
Single-app deployments (GITHUB_APP_IDS has one entry) do not need sticky
routing and can ignore these settings.
APP_CONFIG_FILE)By default the Apps in GITHUB_APP_IDS / KMS_KEYS form a single flat pool
that serves every org. To assign dedicated Apps to specific GitHub
organizations — e.g. to use different credential types per org — point
APP_CONFIG_FILE at a YAML file that maps orgs to their App pools:
orgs:
# A dedicated pool for org-a (two Apps, KMS credentials).
- name: org-a
apps:
- app_id: 111
kms_key: projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions/1
- app_id: 112
kms_key: projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions/2
# A dedicated pool for org-b using an injected PEM instead of KMS.
- name: org-b
apps:
- app_id: 222
private_key: ${ORG_B_APP_PEM}
# Optional catch-all: "*" serves any org not listed above.
- name: "*"
apps:
- app_id: 999
private_key_file: /etc/octo-sts/keys/fallback.pem
Each app sets exactly one credential source:
| Field | Description |
|---|---|
kms_key | KMS key identifier, interpreted per KMS_PROVIDER (default gcp; also aws, akv) |
private_key | An inline PEM |
private_key_file | Path to a PEM file |
String values support ${VAR} expansion from the process environment. Only
the braced form expands — bare $VAR references and literal $ characters
pass through unchanged (so PEMs and values containing $ are safe).
Notes:
APP_CONFIG_FILE is set, the legacy GITHUB_APP_IDS / KMS_KEYS env
vars are ignored."*" fallback is rejected
(fail closed), not served by an arbitrary App.APP_CONFIG_FILE unset, behavior is unchanged (a single flat pool
serving every org).checks: write policies.org_name on each
entry in github_apps and the module generates this config for you. Setting
org_name on any app requires it on all of them (use "*" for a fallback
pool), and every app needs a KMS key (key_version > 0); both are enforced
at terraform plan time.app / app_pattern)By default a multi-App pool load-balances exchanges across its installations. Some workloads need one specific App — different permission grants, a dedicated rate limit, or deterministic check-run ownership. A trust policy can pin the App that mints its tokens:
# 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:my-org/my-repo:ref:refs/heads/main
permissions:
contents: read
checks: write
# Exactly one of:
app: reviewer-bot # a configured app name, or a numeric app ID
# app_pattern: ci-.* # route among all apps whose name matches (anchored)
Apps are named with the optional app_name field in APP_CONFIG_FILE
entries:
orgs:
- name: org-a
apps:
- app_id: 111
app_name: reviewer-bot
kms_key: projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions/1
Unnamed apps (and legacy GITHUB_APP_IDS deployments) can still be pinned by
numeric app ID. Names must be unique and non-numeric; with the terraform
module, set app_name on github_apps entries — uniqueness, non-numeric
names, and the org-mode requirement are enforced at terraform plan time.
Routing semantics:
checks: write), then the installation that read the policy. Policies
without a pin behave exactly as before.app selects one App from the target organization's pool. app_pattern
selects apps in that pool whose names match the anchored regexp;
exchanges then use the same
capacity-aware selection as unpinned routing. Policies with checks: write
instead pick deterministically within the matched set, independent of App
configuration order — with or without a sticky store — so check-run
ownership stays on one App.FailedPrecondition rather than silently re-routing:
an unknown name or ID, a pattern matching no configured app, or a pinned
App not installed on the target org all reject the exchange. A newly
installed App is picked up within about a minute.Notes:
app/webhook binaries before any policy uses app: or
any config uses app_name: — older binaries reject unknown fields (the
webhook fails the check run; the exchange returns an error).app/app_pattern oneof
and regexp validity) but has no access to server configuration, so a pin
naming a nonexistent App passes the check and fails at exchange time.app_pattern can remap checks: write callers in deployments without
a sticky store once the new App becomes visible.checks: write pattern routes once when no sticky store
is configured. Existing eligible sticky assignments are preserved.OctoSTS can be deployed against a GitHub Enterprise Server instance by setting
the GITHUB_BASE_URL environment variable to your GHES API endpoint:
| Variable | Default | Description |
|---|---|---|
GITHUB_BASE_URL | (empty — uses https://api.github.com) | GitHub API base URL for GHES (e.g. https://github.example.com/api/v3) |
The URL must use HTTPS. When set, all GitHub API interactions (installation lookups, trust policy reads, token exchanges, and token revocations) will target the configured endpoint instead of the public GitHub API.
To ensure secure and effective use of octo-sts, follow these recommended practices:
Enable branch protection: Configure branch protection rules on your main/default branch to prevent direct commits and require pull request reviews before merging changes. This prevents OctoSTS clients from bypassing security controls by directly merging changes to main without review.
Restrict who can approve pull requests: Limit pull request approval permissions to trusted team members or repository administrators.
Restrict trusted token issuers: Use the organization-wide allowlist so that only approved identity providers can federate with your repositories. Without it, anyone who can write a trust policy can point issuer: at a provider they control.
Principle of least privilege: Grant only the minimum permissions necessary for your workloads to function. Start with read-only permissions and add write permissions only when required.
Scope policies narrowly: Create specific trust policies for different workloads rather than using broad, catch-all policies.
Regular policy reviews: Periodically review and audit your trust policies (.github/chainguard/*.sts.yaml) to ensure they still align with your security requirements.
Use specific subject matching: Prefer exact subject matches over broad patterns when possible. For example, use repo:org/repo:ref:refs/heads/main instead of repo:org/repo:.*.
Rotate regularly: While octo-sts tokens are short-lived, ensure your OIDC token sources (like GitHub Actions) are properly configured and rotated according to best practices.
Secure OIDC token handling: Ensure your workloads properly secure and handle OIDC tokens before exchanging them with octo-sts.
Sometimes we need to add or remove a GitHub Permission in order to add/remove permissions that will be include in the octo-sts token for the users. Due to the nature of GitHub Apps, OctoSTS must request all permissions it might need to use, even if you don't want to use them for your particular installation or policy.
To avoid disruptions for the users, making them to review and approve the changes in the installed GitHub App we
will apply permissions changes for the octo-sts app quarterly at any day during the quarter.
An issue will be created to explain what permissions is being added or removed.
Special cases will be discussed in a GitHub issue in https://github.com/octo-sts/app/issues and we might apply more than one change during the quarter.
The following permissions are the currently enabled in octo-Sts and will be available when installing the GitHub APP
Read/WriteRead-onlyNo AccessRead/WriteRead/WriteNo AccessNo AccessNo AccessNo AccessRead/WriteRead/WriteNo AccessNo AccessNo AccessRead/WriteRead/WriteRead/WriteRead/WriteNo AccessRead-onlyRead/WriteRead/WriteRead/WriteRead/WriteNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessRead/WriteRead-onlyRead/WriteNo AccessRead and writeNo AccessRead and writeRead-onlyNo AccessNo AccessRead/WriteNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessRead/WriteNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo Access(top 30 of 35)
Go
97.5%
HCL
2.4%
A GitHub App that acts like a Security Token Service (STS) for the Github API
Go
407
1,166 commits
updated Sep 23, 2026
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 <ORG_POLICY_REPO>/.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.
# yaml-language-server: $schema=https://raw.githubusercontent.com/octo-sts/app/refs/heads/main/pkg/octosts/octosts.OrgTrustedIssuers.json
# "enforce" (the default) rejects a token that the allowlist does not permit.
# "audit" permits them and records each one the allowlist rejects.
mode: enforce
# octo-sts matches these exactly. A trailing slash makes a different issuer.
issuers:
- https://token.actions.githubusercontent.com
- https://accounts.google.com
# octo-sts matches these as anchored regexps against the whole issuer URL.
# Escape a literal dot. octo-sts rejects a wildcard that can match "/".
issuer_patterns:
- https://oidc\.eks\.[a-z0-9-]+\.amazonaws\.com/id/[A-Z0-9]+
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, using the GitHub Actions workflow that built them as the signing identity. Verify one before use:
cosign verify \
--certificate-identity-regexp '^https://github\.com/octo-sts/app/\.github/workflows/container\.yaml@refs/tags/v.*$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/octo-sts/app:latest
When multiple GitHub Apps are configured (GITHUB_APP_IDS has more than one
entry), OctoSTS distributes token exchanges across installations using
capacity-aware fairshare routing. Trust policies with checks: write require
sticky routing — the same (scope, identity) pair must always receive a token
from the same installation because GitHub check runs can only be updated by the
app that created them.
The sticky store persists these (scope, identity) -> installation mappings so
they survive process restarts and deploys. Without it, checks:write policies fall
back to round-robin (non-sticky) routing which may break check-run updates.
Firestore backend (recommended for GCP deployments):
| Variable | Default | Description |
|---|---|---|
OCTOSTS_STICKY_STORE | (empty) | Set to firestore to enable |
OCTOSTS_STICKY_STORE_FIRESTORE_PROJECT | running GCP project | Firestore GCP project |
OCTOSTS_STICKY_STORE_FIRESTORE_COLLECTION | sticky-routes | Firestore collection name |
OCTOSTS_STICKY_STORE_FIRESTORE_TTL | 1h | TTL for inactive mappings |
Active mappings have their TTL refreshed on every use, so they never expire. Only mappings unused for the TTL duration are automatically cleaned up.
Single-app deployments (GITHUB_APP_IDS has one entry) do not need sticky
routing and can ignore these settings.
APP_CONFIG_FILE)By default the Apps in GITHUB_APP_IDS / KMS_KEYS form a single flat pool
that serves every org. To assign dedicated Apps to specific GitHub
organizations — e.g. to use different credential types per org — point
APP_CONFIG_FILE at a YAML file that maps orgs to their App pools:
orgs:
# A dedicated pool for org-a (two Apps, KMS credentials).
- name: org-a
apps:
- app_id: 111
kms_key: projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions/1
- app_id: 112
kms_key: projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions/2
# A dedicated pool for org-b using an injected PEM instead of KMS.
- name: org-b
apps:
- app_id: 222
private_key: ${ORG_B_APP_PEM}
# Optional catch-all: "*" serves any org not listed above.
- name: "*"
apps:
- app_id: 999
private_key_file: /etc/octo-sts/keys/fallback.pem
Each app sets exactly one credential source:
| Field | Description |
|---|---|
kms_key | KMS key identifier, interpreted per KMS_PROVIDER (default gcp; also aws, akv) |
private_key | An inline PEM |
private_key_file | Path to a PEM file |
String values support ${VAR} expansion from the process environment. Only
the braced form expands — bare $VAR references and literal $ characters
pass through unchanged (so PEMs and values containing $ are safe).
Notes:
APP_CONFIG_FILE is set, the legacy GITHUB_APP_IDS / KMS_KEYS env
vars are ignored."*" fallback is rejected
(fail closed), not served by an arbitrary App.APP_CONFIG_FILE unset, behavior is unchanged (a single flat pool
serving every org).checks: write policies.org_name on each
entry in github_apps and the module generates this config for you. Setting
org_name on any app requires it on all of them (use "*" for a fallback
pool), and every app needs a KMS key (key_version > 0); both are enforced
at terraform plan time.app / app_pattern)By default a multi-App pool load-balances exchanges across its installations. Some workloads need one specific App — different permission grants, a dedicated rate limit, or deterministic check-run ownership. A trust policy can pin the App that mints its tokens:
# 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:my-org/my-repo:ref:refs/heads/main
permissions:
contents: read
checks: write
# Exactly one of:
app: reviewer-bot # a configured app name, or a numeric app ID
# app_pattern: ci-.* # route among all apps whose name matches (anchored)
Apps are named with the optional app_name field in APP_CONFIG_FILE
entries:
orgs:
- name: org-a
apps:
- app_id: 111
app_name: reviewer-bot
kms_key: projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions/1
Unnamed apps (and legacy GITHUB_APP_IDS deployments) can still be pinned by
numeric app ID. Names must be unique and non-numeric; with the terraform
module, set app_name on github_apps entries — uniqueness, non-numeric
names, and the org-mode requirement are enforced at terraform plan time.
Routing semantics:
checks: write), then the installation that read the policy. Policies
without a pin behave exactly as before.app selects one App from the target organization's pool. app_pattern
selects apps in that pool whose names match the anchored regexp;
exchanges then use the same
capacity-aware selection as unpinned routing. Policies with checks: write
instead pick deterministically within the matched set, independent of App
configuration order — with or without a sticky store — so check-run
ownership stays on one App.FailedPrecondition rather than silently re-routing:
an unknown name or ID, a pattern matching no configured app, or a pinned
App not installed on the target org all reject the exchange. A newly
installed App is picked up within about a minute.Notes:
app/webhook binaries before any policy uses app: or
any config uses app_name: — older binaries reject unknown fields (the
webhook fails the check run; the exchange returns an error).app/app_pattern oneof
and regexp validity) but has no access to server configuration, so a pin
naming a nonexistent App passes the check and fails at exchange time.app_pattern can remap checks: write callers in deployments without
a sticky store once the new App becomes visible.checks: write pattern routes once when no sticky store
is configured. Existing eligible sticky assignments are preserved.OctoSTS can be deployed against a GitHub Enterprise Server instance by setting
the GITHUB_BASE_URL environment variable to your GHES API endpoint:
| Variable | Default | Description |
|---|---|---|
GITHUB_BASE_URL | (empty — uses https://api.github.com) | GitHub API base URL for GHES (e.g. https://github.example.com/api/v3) |
The URL must use HTTPS. When set, all GitHub API interactions (installation lookups, trust policy reads, token exchanges, and token revocations) will target the configured endpoint instead of the public GitHub API.
To ensure secure and effective use of octo-sts, follow these recommended practices:
Enable branch protection: Configure branch protection rules on your main/default branch to prevent direct commits and require pull request reviews before merging changes. This prevents OctoSTS clients from bypassing security controls by directly merging changes to main without review.
Restrict who can approve pull requests: Limit pull request approval permissions to trusted team members or repository administrators.
Restrict trusted token issuers: Use the organization-wide allowlist so that only approved identity providers can federate with your repositories. Without it, anyone who can write a trust policy can point issuer: at a provider they control.
Principle of least privilege: Grant only the minimum permissions necessary for your workloads to function. Start with read-only permissions and add write permissions only when required.
Scope policies narrowly: Create specific trust policies for different workloads rather than using broad, catch-all policies.
Regular policy reviews: Periodically review and audit your trust policies (.github/chainguard/*.sts.yaml) to ensure they still align with your security requirements.
Use specific subject matching: Prefer exact subject matches over broad patterns when possible. For example, use repo:org/repo:ref:refs/heads/main instead of repo:org/repo:.*.
Rotate regularly: While octo-sts tokens are short-lived, ensure your OIDC token sources (like GitHub Actions) are properly configured and rotated according to best practices.
Secure OIDC token handling: Ensure your workloads properly secure and handle OIDC tokens before exchanging them with octo-sts.
Sometimes we need to add or remove a GitHub Permission in order to add/remove permissions that will be include in the octo-sts token for the users. Due to the nature of GitHub Apps, OctoSTS must request all permissions it might need to use, even if you don't want to use them for your particular installation or policy.
To avoid disruptions for the users, making them to review and approve the changes in the installed GitHub App we
will apply permissions changes for the octo-sts app quarterly at any day during the quarter.
An issue will be created to explain what permissions is being added or removed.
Special cases will be discussed in a GitHub issue in https://github.com/octo-sts/app/issues and we might apply more than one change during the quarter.
The following permissions are the currently enabled in octo-Sts and will be available when installing the GitHub APP
Read/WriteRead-onlyNo AccessRead/WriteRead/WriteNo AccessNo AccessNo AccessNo AccessRead/WriteRead/WriteNo AccessNo AccessNo AccessRead/WriteRead/WriteRead/WriteRead/WriteNo AccessRead-onlyRead/WriteRead/WriteRead/WriteRead/WriteNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessRead/WriteRead-onlyRead/WriteNo AccessRead and writeNo AccessRead and writeRead-onlyNo AccessNo AccessRead/WriteNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessRead/WriteNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo AccessNo Access(top 30 of 35)
Go
97.5%
HCL
2.4%