Security model for GitHub Actions workflows
GitHub Actions normally combines workflow execution and GitHub-managed credentials behind a runner. The GitHub Actions Buildkite plugin and buildkite-gha runtime keep the workflow syntax, but run each supported job as a native Buildkite Pipelines job. The runtime doesn't create a GitHub Actions run or a GitHub-hosted runner, so the Buildkite agent, the queue, and Buildkite policies provide the security boundary.
Treat workflow steps and third-party actions as you would on a self-hosted GitHub Actions runner: code can use anything available to its job. Workflow syntax can request permissions, but it doesn't make code trusted or grant access by itself.
How GitHub Actions security maps to Buildkite Pipelines
| GitHub Actions concept | buildkite-gha and Buildkite Pipelines boundary | ||
|---|---|---|---|
| GitHub Actions concept | Runner or runner group | buildkite-gha and Buildkite Pipelines boundary | The Buildkite queue selects the agent environment. Use a disposable host or equivalent whole-job isolation. |
| GitHub Actions concept | Job | buildkite-gha and Buildkite Pipelines boundary | A native Buildkite Pipelines command job. All workflow steps share its workspace and Buildkite identity. |
| GitHub Actions concept |
permissions and GITHUB_TOKEN
|
buildkite-gha and Buildkite Pipelines boundary | Top-level workflow permissions and the Buildkite Pipelines workflow-token policy determine whether Buildkite issues a scoped token. GitHub repository and organization defaults aren't inherited. |
| GitHub Actions concept | Repository and environment secrets | buildkite-gha and Buildkite Pipelines boundary | Static secret names resolve through Buildkite secrets when the destination job's identity and the secret's access policy allow them. Environment-defined secret names resolve to <ENVIRONMENT>_<NAME> Buildkite secrets. Event and fork scoping aren't inherited. |
| GitHub Actions concept | Environment protection rules | buildkite-gha and Buildkite Pipelines boundary | Required reviewers become a Buildkite Pipelines block step that any user who can unblock the pipeline may approve. Reviewer lists, self-review prevention, wait timers, branch policies, and custom rules aren't enforced. Unsupported rules fail the compile. |
| GitHub Actions concept | Repository, organization, and environment variables | buildkite-gha and Buildkite Pipelines boundary | The Buildkite backend reads them from GitHub with its own credentials. Values are copied into the job plan artifact as organization_vars, repository_vars, and environment_vars. They're configuration, not secrets: anyone who can read build artifacts can read them. |
| GitHub Actions concept | OIDC | buildkite-gha and Buildkite Pipelines boundary | Actions use Buildkite-issued OIDC tokens and claims. Cloud trust policies must trust Buildkite rather than GitHub. |
The compatibility reference describes what works. This page describes where trust and authorization come from.
Isolate the whole job
Steps in one job share:
- The workspace
- Environment changes
- Running processes
- Action state
- The job's Buildkite identity
A shell step can affect a later action, and an action can affect a later shell step. Docker actions, job containers, and service containers add packaging. They aren't security boundaries.
Run untrusted jobs on a queue with:
- A disposable machine or equivalent whole-job isolation
- No ambient protected credentials
- A clean environment for every job
- Host-level CPU, memory, disk, and network limits
Buildkite hosted agents are ephemeral and are destroyed after each job.
Job and service container options can grant privileges, mount host paths, publish ports, and override Docker settings. Job container options can't override the runner-owned network or entrypoint, but other Docker create options pass through. Job container volumes accept named volumes, anonymous volumes, and absolute host bind mounts. The private Docker network, ownership labels, and cleanup checks reduce accidental residue. They don't contain hostile code.
On a persistent self-hosted agent, workflow code can read exposed host resources and leave state for later jobs.
Credential boundaries
| Credential | Boundary | ||
|---|---|---|---|
| Credential | Repository checkout | Boundary | The native adapter checks the event repository and exact commit. Buildkite authorizes private access. Credentials apply only to Git commands and aren't persisted. |
| Credential | Private reusable workflow source | Boundary | Git uses the importer's existing HTTPS credential helpers only while resolving an approved source. The importer passes no authenticated URL, captures no credential, and suppresses Git output. Credentials never reach plans, pipeline YAML, or runtime jobs. |
| Credential | GITHUB_TOKEN |
Boundary | A short-lived token for the event repository. Buildkite enforces the top-level workflow permission map and build provenance. The token isn't ambient. |
| Credential | Cache token | Boundary | A fresh job-bound token for each compatible JavaScript or Docker action lifecycle. Shell steps don't receive it. |
| Credential | Workflow secrets | Boundary | Static names resolve with buildkite-agent secret get in the destination job. The Buildkite secret access policy is the authority. |
| Credential | Registry credentials | Boundary | Explicit credentials resolve in the destination job. Passwords go to Docker through standard input and use a private per-job Docker configuration. Secret-derived values stay out of plans and pipeline YAML. Authored literals don't. |
| Credential | OIDC token | Boundary | Host JavaScript actions in jobs with id-token: write can request Buildkite OIDC tokens through a loopback endpoint. Shell steps and containerized actions can't. |
An action that receives a credential can use or exfiltrate it. The action can also export GITHUB_TOKEN to later steps through GITHUB_ENV. Masking reduces accidental disclosure. Masking isn't access control and can't catch transformed values.
OIDC secrets migration
The bk secret migrate github-actions command of the Buildkite CLI generates a reviewed GitHub Actions workflow with a static secret allowlist. To run a migration, see Migrate GitHub Actions secrets. buildkite-gha v0.102.0 removed its equivalent migrate-secrets command. Workflows that buildkite-gha migrate-secrets prepared have the same trust boundaries.
After the workflow is committed to the default branch, the Buildkite CLI creates a short-lived Buildkite migration grant bound to:
- The immutable GitHub repository and owner IDs
- The exact workflow path and commit
- The default branch
-
workflow_dispatchas the only accepted event - The destination cluster
- The secret names
- An access policy for every destination secret. The policy must contain at least one rule, and every rule must include at least one condition, such as
pipeline_id. Buildkite rejects a policy that would let every job in the cluster read the secrets, such as- {}, or no job at all, such as[].
The workflow proves its identity with a GitHub-signed OIDC token and consumes the grant once. The grant expires after a short time and can't be reused. Buildkite doesn't authorize the request from mutable repository names or the customizable sub claim alone.
GitHub OIDC authenticates the workflow. It doesn't choose the workflow's Buildkite authority. The authenticated Buildkite user fixes that authority when creating the grant. When the workflow uses the grant, Buildkite checks again that this user can still manage the destination cluster. If the user has left the organization or lost that permission, or the cluster has been deleted, the migration fails without writing secrets or consuming the grant. The workflow receives no Buildkite API token and can't change the destination, policy, or allowlist. No Buildkite API token or other long-lived bootstrap credential is stored in GitHub.
The generated workflow requests only the id-token: write permission and no repository write permission. The workflow contains one static ${{ secrets.NAME }} reference for each selected secret, and no dynamic secret-name input, secret enumeration, secrets: inherit, artifact, or output containing secret values. The workflow sends values only in one in-memory HTTPS request. It disables shell tracing, rejects redirects, and doesn't print Buildkite response bodies. Buildkite errors and audit data must never include the values.
Secret names are public metadata, and can appear in prompts, the generated workflow YAML, logs, and results. Secret values appear only in the GitHub Actions job's environment and the in-memory HTTPS request.
The migration is create-only. If a destination secret already exists, the migration fails before writing any secrets. Buildkite creates all secrets in the batch and consumes the grant together, so a failed migration leaves no usable partial set of secrets. The migration can't copy GITHUB_TOKEN, which stays on its separate workflow-token boundary.
Anyone who can change and run the default-branch workflow can read the selected GitHub secrets. Binding the grant to the reviewed commit prevents another workflow revision from using its authority. The CLI creates a grant and dispatches the workflow only when you run the corresponding command. The CLI never commits, pushes, merges, or deletes repository files. Remove the migration workflow after the run succeeds. This migration-specific OIDC path doesn't add OIDC support to imported Buildkite Pipelines jobs.
GitHub token
Token issuance requires the pipeline's Allow workflow-authorized GitHub access tokens setting, which is off by default for existing pipelines. Buildkite Pipelines issues tokens only to running command jobs on Buildkite hosted agents. Buildkite Pipelines reads only the top-level workflow permission policy from the pipeline repository at the build's immutable commit.
Important limits:
- Omitted permissions mean exactly
contents: read. GitHub repository and organization defaults aren't inherited. - Write access requires an explicit top-level map.
-
read-allexpands to the 13 supported repository scopes with read access. It doesn't includeid-tokenor unsupported aliases. - The compiler accepts
write-alland requests every supported write scope, but Buildkite Pipelines rejects the request when issuing the token, so no token is created. - An empty map, or a map containing only
none, creates no token. - Job-level and called-workflow repository maps don't narrow or expand the token. Reusable jobs use the top-level requesting workflow's permissions.
- Pull requests have a
contents: readceiling. Merge queues are denied. - Incomplete or cyclic trigger and rebuild provenance is denied.
- Native release builds need the GitHub Code Access App for server-side commit provenance.
The exact step call toJSON(github) also requests a token because the retained context includes token. Before evaluating an authorized step context, the runtime registers the token with both Buildkite agent redaction and local redaction.
Bounded workflow-token retries keep the requested scope, but they don't make token issuance idempotent. A failed response can leave an additional token with the same scope valid until it expires. The runtime doesn't cache tokens across jobs or retry ambiguous transport failures.
The serialized context contains only the fields listed in the compatibility reference. A runtime job also loads the verified event artifact when it requires github.event.
For non-pull-request builds, a user who can create a build at any commit may choose code that requests the workflow's allowed permissions. Enable write tokens only when those build-creation paths are trusted.
Workflow secrets
Workflow secrets are Buildkite secrets available to the destination job. They aren't GitHub repository, environment, event, or fork-scoped secrets. Jobs with a GitHub environment resolve environment-defined secret names to <ENVIRONMENT>_<NAME> Buildkite secrets. Buildkite secret access policies remain the authorization boundary. See Deployment environments.
secrets: inherit lets a repository-local reusable-workflow call, including a verified root-repository self call, place that callee job's statically referenced secret names in its plan. It's one hop, and every nested edge must repeat it. A repository-local call can instead map a declared callee alias from one direct caller secret reference. Required declarations must be mapped. Optional unmapped aliases stay empty. See Reusable workflows for the self-call forwarding scope.
Nested explicit mappings compose aliases to the original Buildkite secret. They can forward only authority received from the parent and never fall back to a same-named Buildkite secret. The runtime retrieves each original once and projects its value to the authorized aliases. Remote forwarding and secret names invented by action metadata remain unsupported.
Plans and pipeline YAML contain secret names, never values. Restrict the destination job with Buildkite secret access policies. Arbitrary code in the same job identity can also run buildkite-agent secret get.
GITHUB_TOKEN stays on its separate workflow-token boundary. Forwarding it to a declared alias preserves that scoped token boundary. Forwarding never requests an ordinary Buildkite secret.
Variables
GitHub Actions variables (${{ vars.NAME }}) are configuration values, not secrets. The Buildkite backend reads them from GitHub with its own credentials, restricted to the pipeline's configured repository. The importer copies the repository and organization scopes into every job plan, and each declared environment's variables into the plans of the jobs that declare it.
A plan artifact (.buildkite-gha/plans/<digest>.json) therefore contains plaintext values, readable by anyone who can read the build's artifacts. Running compile --format ir-json inside a job prints both scopes to stdout, and so to the job log, whenever the workflow references vars. Store sensitive values as secrets instead.
A value used in a compile-time field, such as a job name, matrix value, or runner label, also appears where that field does: in the pipeline YAML and in a compile diagnostic that quotes the field. Runtime references, processing reports, and resolution errors never carry values. They name variables only. A job sees the scopes GitHub gives each position, and a name that no scope defines evaluates as empty rather than exposing another repository's value. See Repository and organization variables.
OIDC
Jobs with id-token: write expose the getIDToken() wire contract to each host JavaScript action invocation. The endpoint is loopback-only and protected by a single-purpose bearer token. The runtime mints a Buildkite token for the action's requested audience, then registers it with both redactors before use.
Cloud providers must trust the Buildkite issuer and claims. GitHub-shaped claims and the GitHub issuer aren't emulated. Plugin OIDC configuration can add Buildkite claims without granting id-token: write. To configure a cloud provider, see Use OIDC with AWS.
Checkout and submodules
Buildkite authorizes managed GitHub and Origin repositories through the Git credential protocol. A checked-in .gitmodules file can select another repository from the same provider when Buildkite authorizes it.
- Managed tokens are repository-specific and read-only.
- External HTTPS submodules are anonymous.
- SSH and other non-HTTPS transports are disabled.
- The credential helper is offered only to the event provider's host, uses HTTP-path matching, and is scoped to repository, LFS, and submodule commands. The helper is never written to Git configuration or persisted for later steps. Git, Git LFS, and the Buildkite agent helper are resolved before action hooks. LFS filters use the resolved executable instead of searching the workflow's
PATHwhile credentials are available.
Checkout paths are relative to the workspace. Traversal, .git path segments, and symbolic-link parents are rejected before Git runs. Existing checkout directories aren't reused, even with clean: false.
Git owns submodule parsing and recursion, so keep Git current and prefer a pinned job image.
Command scoping limits accidental spread. It doesn't stop a hostile concurrent process under the same job identity from reaching the agent or credential helper. Use a separate UID, sandbox, or pre-job credential broker for that boundary.
Error-reporting boundary
Configured clients send Bugsnag error reports directly to Bugsnag, not through the job-authenticated Agent API. Job-log redaction doesn't protect HTTP payloads, so reports omit raw messages and causes rather than relying on log masks. An embedded ingestion key is public to anyone who receives the binary, and build outputs such as release binaries can contain it.
Operator checklist
- Leave the plugin
versionunset for the latest stable release, or pin an exact stable release from0.9.0onward for a controlled rollout. - Run imported jobs on an isolated queue with no ambient credentials.
- Treat public actions as third-party code and prefer immutable commit pins.
- Restrict managed repository access, secrets, and write tokens with Buildkite policy.
- Keep Git and the job image patched.
-
Validate before upload:
buildkite-gha validate \ --profile hosted \ --event-path event.json \ .github/workflows/ci.yml Keep private actions and protected queues out of imported workflows.
Approve only required private reusable workflow sources.
Configure OIDC trust for the Buildkite issuer, then restrict subjects and audiences to the intended jobs.