# 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](ownership.md) is the model; [the command line](cli.md) 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 `: 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](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws) 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](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_iam-condition-keys.html) 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 ```yaml 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 ` 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](https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/) 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](https://docs.github.com/en/actions/reference/openid-connect-reference), `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](https://learn.microsoft.com/en-us/entra/workload-id/workload-identities-github-immutable-subjects) `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](https://docs.github.com/en/actions/reference/openid-connect-reference) | `repository_id`, `repository_owner_id` | `repository`, `repository_owner`, and a `sub` without its `@` halves | | [GitLab CI](https://docs.gitlab.com/ci/secrets/id_token_authentication/) | `project_id`, `namespace_id` | `project_path`, `namespace_path` — [a reclaimed path](https://support.gitlab.com/hc/en-us/articles/28598294286364-CI-ID-token-issuance-is-disabled-after-reclaiming-a-previously-used-project-path) is why GitLab stops issuing tokens until the `sub` is rebuilt around `project_id` | | [Google service accounts](https://cloud.google.com/iam/docs/service-accounts-delete-undelete) | `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](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/) | `/kubernetes.io/serviceaccount/uid` | `sub`, `system:serviceaccount::` — a service account deleted and recreated carries the same one | | [Azure AD / Entra](https://learn.microsoft.com/en-us/entra/workload-id/workload-identities-github-immutable-subjects) | `sub`, the workload identity's object ID | a display name | | [Amazon Cognito](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-with-identity-providers.html) | `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 /kubernetes.io/serviceaccount/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..amazonaws.com/id/`, GKE's `https://container.googleapis.com/v1/projects/

/locations//clusters/` — 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//v2.0 sub=`. The `iss` of a v2 token ends in `/v2.0`; a v1 token's is `https://sts.windows.net//` 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..amazonaws.com/ sub=`. Both RSA (`RS256`) and EC (`ES256`) signatures verify; `RS512` and `PS256` too.