OIDC tokens for cloud roles
A controller with a signing key is an OpenID Connect issuer. A run asks it for a short-lived ID token that names the run's team, repository, pipeline, trigger, ref and the kind of host it ran on, and exchanges that token with AWS, Google Cloud, Azure or Vault for credentials. No cloud key is stored in Sparkwing secrets, and a cloud role trusts only the pipelines its trust policy names.
The model matches GitHub Actions' id-token: write: the cloud provider fetches the controller's public keys over HTTPS and checks each token's signature, audience, expiry and subject before it hands out credentials.
How it works
- The controller publishes
https://<external-url>/.well-known/openid-configurationand the key set it points at,https://<external-url>/.well-known/jwks.json. Both are public and cacheable for one hour. - Pipeline code calls
sparkwing.OIDCToken(ctx, audience). The host sendsPOST /api/v1/runs/{run}/oidc-tokenusing the job’s live, run-bound capability withoidc.mint. - The controller signs a token only for a caller holding a live claim on that run, reads every claim from the run's stored rows, and never from the request, except the audience.
- The pipeline hands the token to the cloud provider's token exchange.
Purely local runs have no OIDC issuer. To obtain tokens, run the pipeline through
a controller with OIDC configured as described in Turn it on.
If the controller has no signing key, sparkwing.OIDCToken reports not_found
with a setup explanation and a pointer to this guide.
Turn it on
The issuer is the controller's --external-url. It must be an https origin with no path, query or fragment, because every cloud provider fetches <issuer>/.well-known/openid-configuration and compares the token's iss claim to the issuer byte for byte. AWS also expects no port.
Generate an RSA key and give it to the controller as the oidc-key file in its
--credentials-dir:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out oidc-signing.pem
| Setting | Meaning |
|---|---|
oidc-key credential | RSA private key, PKCS #1 or PKCS #8 PEM, at least 2048 bits, that signs every token. Only its owner may read the file (mode 0400 or 0600), or its group when that group is one of the controller process's own, such as a Kubernetes pod's fsGroup (mode 0440); startup refuses any other group or user access. Without it the controller issues no tokens and the three routes answer 404. |
oidc-key.published credential | A second key's public half, as a public key PEM (openssl pkey -in key.pem -pubout), that the key set publishes and that never signs: the next key before a rotation, the previous key after one. Startup refuses private key material in this file. |
--oidc-token-ttl | Token lifetime. Default 10m, at most 1h, at least 1m. |
The controller refuses to start when a key is set and --external-url is empty or is not an https origin. It never logs key material; the startup line names the key ids.
Each key's kid is its RFC 7638 JWK thumbprint, so the same key always publishes the same id.
Rotate the key
Relying parties cache the key set for up to an hour, so a new key is published before anything signs with it:
- Publish the next key. Keep the current key in
oidc-keyand put the new key's public half inoidc-key.published. Tokens still carry the currentkid. - Wait longer than the one-hour cache time, so every relying party's cached key set holds the new key.
- Switch signing. Put the new key in
oidc-keyand the old key's public half inoidc-key.published. New tokens carry the newkid, and tokens the old key signed keep verifying. - Wait longer than one token lifetime, so every old-key token has expired, then remove
oidc-key.published.
Switching signing before step 2 has passed makes a relying party that cached the old key set reject new tokens until its cache expires.
Signing algorithm
Tokens are signed with RS256. It is the one algorithm every target accepts: AWS STS accepts RS256, RS384, RS512, ES256, ES384 and ES512; Google Cloud workload identity federation accepts RS256 and ES256; Microsoft Entra workload identity federation supports only RS256-signed issuer tokens; Vault's JWT auth method accepts RS256.
The token
Header: {"alg": "RS256", "kid": "<thumbprint>", "typ": "JWT"}.
| Claim | Value |
|---|---|
iss | The controller's external URL, for example https://api.sparkwing.dev. |
aud | The audience the caller asked for, as one string. |
sub | The subject described below. |
iat, nbf | Issue time. |
exp | Issue time plus the token lifetime, shortened by the claim’s ancestry expiry, revocation deadline and binding deadline. |
jti | 128 random bits, hex. |
team | The run's team slug. |
pipeline | The pipeline the run speaks for. A child at its parent's exact revision speaks for its parent's pipeline. |
trigger | push, pull_request, release, create, delete, cron, actions or manual, described below. |
runner_kind | runner, personal, github-actions, user or service, described below. |
runner_environment | fresh or persistent, described below. Like GitHub Actions' runner_environment (github-hosted or self-hosted), it says whether a job of another run could have touched the host. |
ref | The ref the controller established: the verified delivery's ref (refs/pull/<number>/head for a pull request, refs/heads/<base> for a merged pull request's closed event, refs/tags/<tag> for a tag push or release, otherwise refs/heads/<branch>), the default branch an admin-armed schedule follows, the push a GitHub Actions credential is bound to, or the branch or tag head (refs/heads/<branch> or refs/tags/<tag>) the GitHub App resolved for a manual run whose code Sparkwing fetched and ran there. Absent for every other manual run. |
sha | The commit the controller established alongside ref; absent whenever ref is. A branch delete event's run has ref naming the deleted branch, which has no commit, so sha is the head of the repository's default branch when the delivery arrived, and that is the code the run executes. |
repository | GitHub's <owner>/<name>, for example acme/api, as GitHub reported it when the run was admitted. Absent when the run proved no repository. |
repository_id | GitHub's numeric repository id as a string, like GitHub's own claim; absent when the run proved no repository. |
run_id | The run id. |
Subject format
team:<team>:repository_id:<repository_id>:pipeline:<pipeline>:trigger:<trigger>:runner:<runner_kind>:runner_environment:<runner_environment>:ref:<ref>
<repository_id> is GitHub's numeric repository id, which survives a rename or a transfer. Read it with gh api repos/<owner>/<name> --jq .id. It is set for a run a signed GitHub App delivery started, a run bound to a GitHub Actions push, a fire of an admin-armed default-branch schedule, a run whose code Sparkwing fetched from the repository itself, and a retry or child run that inherits one of those identities whole. It is empty for every other run, such as an upload from the CLI or a run on the submitter's own machine. trigger:push, trigger:pull_request, trigger:release, trigger:create, trigger:delete, trigger:cron and trigger:actions always carry a repository id.
For example, a push to main of repository 123456789 delivered by the GitHub App and executed on a launcher pod:
team:acme:repository_id:123456789:pipeline:deploy:trigger:push:runner:runner:runner_environment:fresh:ref:refs/heads/main
A tag push keeps trigger:push and uses its tag ref, for example team:acme:repository_id:123456789:pipeline:deploy:trigger:push:runner:runner:runner_environment:fresh:ref:refs/tags/v1.2.3. A policy for a protected branch should match refs/heads/<branch> explicitly.
The same pipeline run for pull request 42, even one opened from a branch named main:
team:acme:repository_id:123456789:pipeline:deploy:trigger:pull_request:runner:runner:runner_environment:fresh:ref:refs/pull/42/head
The format is stable. Segments run from the coarsest to the finest: a team holds repositories and a repository declares pipelines, so a trust condition that ends in * narrows by prefix, and team:acme:repository_id:123456789:* admits every pipeline of one repository. A pipeline name is not unique across repositories: a team that subscribes deploy on two repositories gets two subjects that differ only in repository_id. <repository_id> is empty for a run that proved no repository, which leaves repository_id:: in the subject, and <ref> is empty when the ref claim is absent, which leaves the subject ending in :ref:. Every segment value is printable ASCII with no :, * or ?: the controller refuses to sign for a run whose pipeline or branch holds one of those, whitespace, or any other character (400), so a submitted value cannot forge a later segment or match as a wildcard.
What each value proves
Intake seals every run with its repository proof and the identity it speaks for: a trust class, and the ref, commit, event and pipeline that class establishes. trigger, repository_id, ref, sha and pipeline read only that sealed identity, never the request body. The controller vouches for the team: it comes from the claimed run's row.
triggernames the trust class:push,pull_request,release,create,delete: a GitHub App delivery whose signature the controller verified. Therepository_id,refandshacome from that delivery. A pull request'srefisrefs/pull/<number>/headwhatever the head branch is called, and itsshais the head commit. Aclosedevent runs only a merged pull request, at its merge commit, withrefrefs/heads/<base>. Pull requests from forks never run.cron: a fire of an admin-armed default-branch schedule: a team admin armed it (through GitHub App automation consent, or as a member who holdsteam.adminwhen it fires), it follows the repository's default branch as GitHub reports it through the App at fire time, it has no locked commit and no override of arguments, cadence, timezone, overlap or catch-up, and the App resolves its commit at fire time.refisrefs/heads/<default>,shais that branch's head when the schedule fired, andrepository_idis set.actions: a run bound to a GitHub Actions push.repository_id,refandshaare the push's, which GitHub's own ID token proved.manualwithrepository_id,repository,refandsha: Sparkwing fetched the code from that repository itself, through the team's GitHub App, at a branch or tag head the App resolved, and ran it there. This covers a fire, override or "run now" of a schedule declared through the GitHub App that is not locked to a commit and does not qualify forcron, and a console or CLI run of a GitHub branch or tag that the controller fetched. Sparkwing resolved and verified that head, sorefandshaare the ones the run executed, as in GitHub'sworkflow_dispatchtokens; a member still chose which branch, tag or arguments to run, whichtrigger:manualrecords. A run-now onmaintherefore matches a policy that pinsref:refs/heads/main, and one on another branch does not; a policy that cares how a run started requirestrigger:push. An unqualified branch name that resolves both as a branch and as a tag at the same commit claims noreforsha.manualwithrepository_idandrepositorybut noreforsha: Sparkwing fetched the code from that repository, but the code ran at another commit than the head the App resolved: an App delivery, console or CLI run, or schedule whosesource:pin is a branch or tag, or a historical commit that passed App source selection and the intake compare check.manualwith norepository_id,reforsha: every other run, including an upload or working tree from the CLI, a run on the submitter's own machine, a historical commit or locked schedule that failed repository proof, a pull request ref that failed proof, or a schedule armed outside the GitHub App, and a child run that does not run its parent's exact revision. The controller proves none of its repository, ref or commit, so the token claims none of them.
repositoryis emitted only alongsiderepository_id.- A child run either runs its parent's exact revision (same repository, ref, commit and code commit) and inherits the parent's identity whole, including its
pipeline, or is asserted under its own pipeline name and mintsmanualwith norepository_id,reforsha. The accepted plan declares each child pipeline and repository. Award issuance seals the authorized pairs in a separate child-start capability, using the team's registered repositories or live App installation coverage. The claim's source and data scope remain on the parent repository. A child in another repository names a branch the controller resolves there, never a commit, and is asserted. A job may start only the child pipelines its plan declares withStartsChildrenfor its own repository orStartsChildrenIn("gh:123", "deploy")for an explicit repository. A retry keeps its source run's OIDC identity, so a retried push stayspushand a retried schedule fire stayscron. - A
source:code pin that is a full commit ID keeps the run's identity. A branch or tag pin, unless it resolves to the execution commit itself, runs code whoever can push that ref chooses, so the run mintsmanualwith noreforsha.
A raw commit or locked schedule whose code the App selects can carry the repository claim after a successful intake compare check. It carries no branch or commit OIDC claim because it did not execute a resolved branch or tag head.
runner_environmentisfreshwhen the job ran on a host no job of another run touched: a launcher pod, a Sparkwing Cloud pod or a GitHub-hosted Actions runner. A launcher or Cloud pod can land on a warm node that earlier jobs of the same run used, sofreshdoes not separate jobs within one run. It ispersistenton every other host: a plain machine, a persistent Kubernetes runner, a member's personal machine, a run "here", and a self-hosted Actions runner. It is the same classification that decides whether verified work may write the shared default-branch cache. The award seals this classification once. Personal machines and here hosts cannot claim repository-proven runs or mint their repository identity. A role that deploys should requirerunner_environment:fresh, because on a persistent host an earlier job, possibly another repository's, can leave behind code that the next job runs.runner_kindnames the credential that holds the claim:runnerfor a shared runner token, a launcher pod or a Sparkwing Cloud pod,personalfor a member's personal machine,github-actionsfor the credential a GitHub Actions job received through the runner exchange,userfor a person's token or session, which is how a laptop run reaches the controller, andservicefor a deployment service or operator token that claims work directly.
A deploy role should therefore require one repository, trigger:push on a protected branch, runner:runner and runner_environment:fresh, for example team:acme:repository_id:123456789:pipeline:deploy:trigger:push:runner:runner:runner_environment:fresh:ref:refs/heads/main. Branch protection on that repository then decides what reaches main; the protection of another repository that subscribes the same pipeline name does not matter, because its runs carry a different repository_id. A policy that leaves the repository open, such as team:acme:repository_id:*:pipeline:deploy:..., accepts a push to main of any repository the team subscribes, and so trusts the weakest branch protection among them. Data records retain their repository, ref and provenance. The controller
authorizes every reference edge. Manual publication retains local provenance,
and pipeline binaries stay local. A self-hosted runner's machine is a different matter: whoever runs code on it can change what the next run there finds. trigger:cron means "declared by the repository's configuration on that branch", so it is as protected as the branch. A trust policy that accepts trigger:manual with an empty repository_id or runner:user accepts any code a team member with runs.write chooses to run. A GitHub Actions-bound run carries trigger:actions with its own repository_id, so a policy for it names the repository like any other, and subjects never collide across a team's repositories. A run Sparkwing fetched carries trigger:manual with its repository_id and, when it ran at a resolved head, that ref, so a policy that names the repository but accepts trigger:* also accepts any branch or tag of it a member chooses to run, and one that also pins ref:refs/heads/main accepts a member's run of main.
A GitHub Actions job holding a claim can request a token too, with runner:github-actions. Such a job's credential is bound to its own repository's push, so its repository_id, ref and sha are the push's. GitHub's own ID token proves the same repository facts with GitHub as the issuer; use Sparkwing's when the trust policy should name the Sparkwing team and pipeline.
The token route
POST /api/v1/runs/{run}/oidc-token requires a live awarded or delegated claim with oidc.mint, bound to that run. A machine root or account credential cannot call it.
{"audience": "sts.amazonaws.com"}
{"token": "eyJ...", "expires_at": "2026-01-01T00:10:00Z"}
audienceis required: 1 to 256 printable ASCII characters with no spaces, which admits URLs and plain identifiers.- The caller must hold the live run-bound claim. Missing, expired, revoked, cancelled or ended authority answers 401
unauthenticated. A live credential without the grant or matching run binding answers 403forbidden. Request headers and query parameters cannot select the team, holder, repository or signing facts. - Each claim may mint 30 tokens per run on each controller replica, refilled over five minutes; past that the route answers 429 with
Retry-After, the 30-token limit, current budget usage and support@sparkwing.dev to request a higher limit. - A controller with no signing key answers 404.
Get a token in a pipeline
token, err := sparkwing.OIDCToken(ctx, "sts.amazonaws.com")
OIDCToken works in any node a runner executes against a controller. A run without an OIDC issuer gets an error matching sparkwing.ErrOIDCUnavailable through errors.Is. This includes a local controller with no signing key. The error retains the controller's not_found code and setup guidance. Authorization and transport failures remain errors.
The AWS SDKs and CLI read a web identity token from a file named by AWS_WEB_IDENTITY_TOKEN_FILE:
func awsEnv(ctx context.Context, roleARN string) ([]string, error) {
token, err := sparkwing.OIDCToken(ctx, "sts.amazonaws.com")
if err != nil {
return nil, err
}
f, err := os.CreateTemp("", "aws-web-identity-*")
if err != nil {
return nil, err
}
defer f.Close()
if _, err := f.WriteString(token); err != nil {
return nil, err
}
return []string{
"AWS_ROLE_ARN=" + roleARN,
"AWS_WEB_IDENTITY_TOKEN_FILE=" + f.Name(),
"AWS_ROLE_SESSION_NAME=sparkwing",
}, nil
}
Pass those variables to the step that runs aws or the SDK. The SDK re-reads the file when its credentials expire, so a step longer than the token lifetime writes a fresh token to the same file first. To exchange by hand:
aws sts assume-role-with-web-identity \
--role-arn arn:aws:iam::123456789012:role/sparkwing-deploy \
--role-session-name sparkwing \
--web-identity-token "$TOKEN"
AWS
Create the OIDC provider once per account. AWS verifies the issuer's TLS certificate against its trusted CAs, so the thumbprint only matters for a private CA:
aws iam create-open-id-connect-provider \
--url https://api.sparkwing.dev \
--client-id-list sts.amazonaws.com
Trust policy for the role:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Federated": "arn:aws:iam::123456789012:oidc-provider/api.sparkwing.dev"},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {"api.sparkwing.dev:aud": "sts.amazonaws.com"},
"StringLike": {"api.sparkwing.dev:sub": "team:acme:repository_id:123456789:pipeline:deploy:trigger:push:runner:runner:runner_environment:fresh:ref:refs/heads/main"}
}
}]
}
AWS evaluates only aud and sub from a generic OIDC provider and ignores the custom claims, which is why the subject carries every value a trust policy needs. The policy above admits only fresh hosts; replacing runner_environment:fresh with runner_environment:* also admits persistent runners. Use * at the end to admit several refs, for example team:acme:repository_id:123456789:pipeline:deploy:trigger:push:runner:runner:runner_environment:fresh:ref:refs/heads/release-*. A * in the trigger segment also admits manual, actions and cron runs, and a * in the repository segment admits every repository of the team.
Google Cloud
gcloud iam workload-identity-pools create sparkwing --location=global
gcloud iam workload-identity-pools providers create-oidc api-sparkwing-dev \
--location=global --workload-identity-pool=sparkwing \
--issuer-uri=https://api.sparkwing.dev \
--allowed-audiences=sparkwing-gcp \
--attribute-mapping='google.subject=assertion.run_id,attribute.team=assertion.team,attribute.pipeline=assertion.pipeline,attribute.trigger=assertion.trigger,attribute.runner_kind=assertion.runner_kind,attribute.runner_environment=assertion.runner_environment,attribute.ref=assertion.ref,attribute.repository=assertion.repository' \
--attribute-condition="assertion.team == 'acme' && assertion.repository_id == '123456789' && assertion.runner_kind == 'runner' && assertion.runner_environment == 'fresh' && assertion.trigger == 'push' && assertion.ref == 'refs/heads/main'"
gcloud iam service-accounts add-iam-policy-binding deploy@my-project.iam.gserviceaccount.com \
--role=roles/iam.workloadIdentityUser \
--member='principalSet://iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/sparkwing/attribute.pipeline/deploy'
google.subject is limited to 127 bytes, which a long subject can pass, so the mapping above uses the run id and conditions on the individual claims instead. Condition on repository_id as well as pipeline, because a pipeline name is not unique across a team's repositories. A trigger of push always carries the repository from the signed delivery. Request tokens with sparkwing.OIDCToken(ctx, "sparkwing-gcp"), and write a credential configuration with gcloud iam workload-identity-pools create-cred-config ... --credential-source-file=<token file>.
Azure
Entra matches the subject exactly, so each federated credential names one subject:
az ad app federated-credential create --id <app-object-id> --parameters '{
"name": "sparkwing-deploy-main",
"issuer": "https://api.sparkwing.dev",
"subject": "team:acme:repository_id:123456789:pipeline:deploy:trigger:push:runner:runner:runner_environment:fresh:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'
Request tokens with sparkwing.OIDCToken(ctx, "api://AzureADTokenExchange") and sign in with az login --service-principal -u <client-id> -t <tenant-id> --federated-token "$TOKEN".
Vault
vault auth enable jwt
vault write auth/jwt/config oidc_discovery_url=https://api.sparkwing.dev bound_issuer=https://api.sparkwing.dev
vault write auth/jwt/role/deploy role_type=jwt user_claim=sub \
bound_audiences=vault \
bound_claims_type=glob \
bound_claims='{"team":"acme","repository_id":"123456789","pipeline":"deploy","trigger":"push","runner_kind":"runner","runner_environment":"fresh","ref":"refs/heads/main"}' \
token_policies=deploy token_ttl=10m
Request tokens with sparkwing.OIDCToken(ctx, "vault") and log in with vault write auth/jwt/login role=deploy jwt="$TOKEN".