From AWS OIDC to didbot #
A GitHub Actions workflow that assumes an AWS role through OIDC becomes a didbot pipeline with one command on the operator's machine and two steps in the workflow. This page is the port, piece by piece: what each part of the AWS setup becomes, a workflow to paste, how the trust policy's wildcards are spelled when only equality exists, which claim to bind so a renamed repository cannot take the account, and the lines that differ for Google, Kubernetes, Azure, GitLab and Cognito. Who operates an account is the model; the command line documents every command used here.
Side by side #
| AWS | didbot |
|---|---|
An IAM OIDC identity provider for https://token.actions.githubusercontent.com, with the issuer's thumbprint and audience sts.amazonaws.com |
Nothing to register. The server fetches the issuer's keys through its discovery document when a token arrives. [oidc] issuers in the server's config narrows which issuers it will read; empty admits any public HTTPS issuer. |
A role whose trust policy has StringEquals token.actions.githubusercontent.com:aud: sts.amazonaws.com and StringLike token.actions.githubusercontent.com:sub: repo:org/repo:* |
didbot operate deploy.pds.example op.example --kind pipeline --oidc https://token.actions.githubusercontent.com repository_id=456789 --creates agent, once. The account did:web:deploy.pds.example is the identity: the server writes the issuer and the claims as the account's bot.did.credential record. |
The audience sts.amazonaws.com |
The server's own DID, did:web:pds.example. GitHub mints a token for any audience string. |
aws-actions/configure-aws-credentials with role-to-assume (AssumeRoleWithWebIdentity) |
Request the token yourself with audience=did:web:pds.example, write it to a file, and didbot oauth pending --token-file <file>: a session as the pipeline. |
| Temporary credentials, one hour by default | A session, one hour by default ([sessions] proof_ttl_secs); a run that needs longer presents a fresh token. |
| The role's permissions policy | The session writes the pipeline's own repository and nothing else. Whether the pipeline may create accounts beneath itself is the creates entry on the operator's record for it: --creates agent above lets each run start an agent, and a record without it admits a pipeline that creates nothing. |
StringLike and ForAnyValue conditions |
Equality on the claims GitHub already splits out of sub; see below. |
The trust policy in GitHub's own AWS
instructions
matches sub against repo:octo-org/octo-repo:ref:refs/heads/octo-branch,
which is a name somebody else can hold later. AWS cannot condition on
repository_id: its OIDC federation condition
keys
are amr, aud, email, oaud and sub, so a role that wants the ID
opts the repository into GitHub's immutable subject and matches
repo:octo-org@123456/octo-repo@456789:*. didbot compares any claim, so it
binds repository_id itself.
The workflow #
permissions:
id-token: write # the run may mint an OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
env:
DIDBOT_PDS: https://pds.example
steps:
- uses: actions/checkout@v4
- name: Sign in as the pipeline
run: |
# One token per didbot command: a token is spent the first time
# it is presented, and it must be under five minutes old.
curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=did:web:pds.example" \
| jq -r .value > "$RUNNER_TEMP/id-token"
didbot oauth pending --token-file "$RUNNER_TEMP/id-token"
- name: Create this run's agent beneath the pipeline
run: |
curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=did:web:pds.example" \
| jq -r .value > "$RUNNER_TEMP/id-token"
didbot register agent "run-$GITHUB_RUN_ID.pds.example" \
--under deploy.pds.example --server pds.example \
--token-file "$RUNNER_TEMP/id-token"
didbot oauth keeps the session where DIDBOT_ACCOUNT_TOKEN_FILE points
didbot-oauth; every later didbot oauth in the job runs as the pipeline.
A token is never an argument: --token <JWT> is refused so ps and the
job log cannot show one.
A name can be recycled #
An account here is permanent, and its OpenID Connect identity is an issuer
and a set of claims. Bind a claim that carries a name and the account
belongs to whoever holds that name today. Rename a GitHub repository,
transfer it to another owner, or delete it and create one with the same
name, and a workflow in the new repository mints a token whose repository
is the old value. That token is a session as the pipeline, and the new
owner writes the pipeline's own repository.
Numeric IDs are handed out once and never reused, so bind those. GitHub
added immutable subject
claims
on 2026-04-23, and since 2026-07-15 every repository created, renamed or
transferred gets them: sub reads
repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main. The same IDs
arrive as claims of their
own,
repository_id and repository_owner_id, and those are what --oidc
names. Azure asks for the same pair: a flexible federated identity
credential for GitHub must
match
repository_id, repository_owner_id or both, whatever its sub says.
Which claim to bind #
Every issuer below hands out a name and an ID for the same thing. The name is what a person reads and what a run prints; the ID is what an account binds.
| Issuer | Bind | Not |
|---|---|---|
| GitHub Actions | repository_id, repository_owner_id |
repository, repository_owner, and a sub without its @<id> halves |
| GitLab CI | project_id, namespace_id |
project_path, namespace_path — a reclaimed path is why GitLab stops issuing tokens until the sub is rebuilt around project_id |
| Google service accounts | sub, the service account's numeric unique ID |
email — delete a service account and create one with that name again, and the address is back with a new ID |
| Kubernetes | /kubernetes.io/serviceaccount/uid |
sub, system:serviceaccount:<namespace>:<name> — a service account deleted and recreated carries the same one |
| Azure AD / Entra | sub, the workload identity's object ID |
a display name |
| Amazon Cognito | sub, the pool's own identifier for the user |
email, preferred_username |
Where the wildcard went #
AWS sees sub and aud and matches sub with StringLike. GitHub also
puts each part of sub in a claim of its own, and didbot matches those
claims for equality, so what was a pattern is a choice of claim:
| Intent | AWS sub condition |
--oidc https://token.actions.githubusercontent.com … |
|---|---|---|
| Any branch, tag or environment of one repository | repo:org/repo:* |
repository_id=456789 |
| One branch | repo:org/repo:ref:refs/heads/main |
repository_id=456789 ref=refs/heads/main |
| Tags only | repo:org/repo:ref:refs/tags/* |
repository_id=456789 ref_type=tag |
| One environment | repo:org/repo:environment:production |
repository_id=456789 environment=production |
| One reusable workflow, wherever it is called | job_workflow_ref custom claim |
job_workflow_ref=org/workflows/.github/workflows/deploy.yml@refs/heads/main, a path — add repository_owner_id=123456 so only that owner's repositories call it |
| Every repository of one owner | repo:org/* |
repository_owner_id=123456 — any repository anyone creates under that owner, including a fork's pull request if the workflow runs there |
| Several repositories | one StringLike per pattern |
one pipeline account per repository, or repository_owner_id; a claim set is a conjunction |
456789 is org/repo's ID and 123456 its owner's. Both ride in every
token the repository's workflows mint, and the repository's OIDC settings
show the sub a workflow emits before anything relies on it.
Two accounts at one issuer may overlap — deploy with
repository_id=456789 and deploy-prod with repository_id=456789 environment=production. A
production run's token matches both and is the account naming the most
claims, deploy-prod; a staging run's is deploy. Two accounts naming as
many claims as each other, both matching, is a refusal.
A claim whose value is a number or a boolean is spelled as JSON spells it:
email_verified=true. A nested claim is named as a JSON pointer:
/kubernetes.io/namespace=agents. A top-level claim may be named either
way, so repository and /repository are one claim, and an identity that
names one claim twice is refused.
Lifetimes, and two runs at once #
GitHub's token carries iat at the moment it was requested. The server
refuses a token whose iat is older than [oidc] freshness_secs (five
minutes by default), so a workflow requests the token in the step that
uses it, not in an earlier one. exp is checked too, and nbf when
present.
A token is spent the first time it is presented, so two didbot commands
in one job request two tokens. Two workflows running at once each request
their own and each hold a session; a pipeline may hold [sessions] live_per_account sessions (eight by default), and the one expiring
soonest makes room for the next.
Other platforms #
Each platform below is the --oidc line for didbot operate, the way
its token reaches the machine, and what differs from GitHub.
Google service accounts (Compute Engine, Cloud Run, GKE workload
identity). --oidc https://accounts.google.com sub=106589273465000000000.
sub is the service account's numeric unique ID, which Google never hands
out twice; email rides in the same token and is the address a person
recognises. An instance reads its token from the metadata server with
audience=did:web:pds.example&format=full. The token is an hour long and
carries no jti; the server spends the whole token instead. A metadata
server that caches the token hands the same one out again, and the same
token presented twice is a replay; a token read more than five minutes
before it is presented is stale. Register once per boot, straight after
reading the token. Google's keys sit on
www.googleapis.com, not the issuer's host, and are fetched from there.
Kubernetes service accounts. --oidc <issuer> /kubernetes.io/serviceaccount/uid=<uid>, from kubectl get sa runner -n agents -o jsonpath='{.metadata.uid}'. sub reads
system:serviceaccount:agents:runner, and a service account deleted and
recreated under that name carries the same one, so the UID is the binding
and sub is the line a person reads in a log; one pod of that account adds
/kubernetes.io/pod/name=runner-0. The issuer is the cluster's public
one — EKS's https://oidc.eks.<region>.amazonaws.com/id/<id>, GKE's
https://container.googleapis.com/v1/projects/<p>/locations/<l>/clusters/<c>
— not https://kubernetes.default.svc, which resolves to a private
address the server will not fetch from. The token is a projected volume
with audience: did:web:pds.example; aud arrives as an array, which is
fine. The kubelet rewrites the file only as it nears expiry, so a pod that
registers later than five minutes after the file was written finds it
stale: set expirationSeconds: 600 on the projection, the minimum, and
read the file at the moment of registering.
Azure AD / Entra workload identity. --oidc https://login.microsoftonline.com/<tenant>/v2.0 sub=<object id>.
The iss of a v2 token ends in /v2.0; a v1 token's is
https://sts.windows.net/<tenant>/ and does not match a v2 entry. Azure's
keys sit under /discovery/v2.0/keys, off the issuer's path, and are
fetched from there.
GitLab CI. --oidc https://gitlab.com project_id=12345678, with
ref_type=tag or environment=production the way GitHub's are;
project_path is beside it in the token and moves with a rename. The
token is id_tokens: in .gitlab-ci.yml with aud: did:web:pds.example.
Amazon Cognito identity pools. --oidc https://cognito-idp.<region>.amazonaws.com/<pool> sub=<subject>.
Both RSA (RS256) and EC (ES256) signatures verify; RS512 and PS256
too.