Replacing Cloud Secrets with GitHub Actions OIDC: An Architecture Note
GitHub Actions can mint short-lived OIDC tokens that cloud providers exchange for temporary credentials — no stored secrets. The security lives in the cloud-side trust condition, not the token.
26 Jul 2025, 09:02 UTC

Most GitHub Actions pipelines that deploy to AWS, Azure, or GCP still authenticate with a long-lived access key stored as a repository secret. That secret is valid from anywhere, never expires unless you rotate it, and leaks the moment it lands in a log line or a compromised runner. The useful alternative is already built in: GitHub can mint a short-lived OpenID Connect (OIDC) token for each workflow run, and your cloud provider exchanges it for temporary credentials. No stored secret exists to steal.
Requirements
The design has to satisfy four constraints:
- No long-lived cloud credential stored in GitHub, on runners, or in code.
- Credentials scoped so that only the intended repository, branch, and (ideally) environment can obtain them.
- Permissions limited to what the deploy actually needs — not an admin role "for now."
- A manual approval gate before production credentials become usable.
OIDC federation meets all four, but only if the cloud-side trust condition is written carefully. The token itself is not the security boundary; the condition that decides which tokens may assume the role is.
The smallest suitable design
The flow has three parts. First, the workflow requests an OIDC token from GitHub's token endpoint by declaring the id-token: write permission. Second, the workflow presents that token to the cloud provider (for AWS, via the official aws-actions/configure-aws-credentials action; Azure and GCP have equivalent login actions). Third, the provider validates the token against GitHub's issuer (https://token.actions.githubusercontent.com), checks the claims against the role's trust condition, and returns temporary credentials that expire in minutes to an hour.
Keep the design minimal:
- One role per deploy target (e.g., one for staging, one for production), each with least-privilege policies. Resist a shared "deploy" role.
- One trust condition per role that pins the subject claim to a specific repository and ref or environment.
- The audience claim set to the provider's expected value (for AWS this is typically
sts.amazonaws.com).
A workflow job that deploys from the main branch looks like this:
permissions:
id-token: write # required to request the OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # ties the run to a protected environment
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gh-deploy-prod
aws-region: eu-west-1
- run: aws sts get-caller-identity # sanity check: which role did we get?
Run this in the workflow job itself; no special runner permissions are needed beyond the id-token: write grant. The get-caller-identity call is a cheap verification step — it confirms the exchange succeeded and shows which role was assumed, so a misconfigured ARN fails loudly instead of at the first real API call.
The trust boundary is the cloud-side condition
The trust policy on the cloud role is where security actually lives. For AWS, the condition on the assumed-role policy should match the token's subject (sub) and audience (aud) claims. A strict example:
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:my-org/my-app:environment:production"
}
}
GitHub's default sub format is repo:ORG/REPO:ref:refs/heads/BRANCH for branch pushes, or repo:ORG/REPO:environment:NAME when the job targets an environment. Binding to an environment is the stronger pattern: a GitHub protected environment can require reviewer approval before the job starts, which means the credentials are unobtainable until a human approves. That gives you a manual gate for free.
Note the asymmetry: GitHub controls what goes into the token, but the cloud provider controls what it accepts. A forked repository or a feature branch can request a token — it simply will not satisfy this condition, so the exchange is denied. That is exactly the behavior you want, and you should test it rather than assume it.
Operational checks
- Verify the deny path. Point a workflow on a feature branch at the production role and confirm the provider rejects the token exchange. Also open a pull request from a fork and confirm it cannot assume any role.
- Log claims, never the token. A debug step can decode and print the JWT claims (repository, ref, environment, actor) to confirm they match expectations. The raw token must never appear in logs — it is replayable until it expires.
- Alert on exchange failures. A spike in failed assume-role calls usually means either a misconfigured condition or someone probing the trust boundary; both are worth a look.
- Audit periodically. List which roles trust GitHub's issuer and review their conditions. Roles created during a proof-of-concept with broad subjects tend to survive.
Failure modes
- Wildcard subjects. A condition like
repo:my-org/*trusts every repository in the organization, including ones created tomorrow. Use wildcards only when that is a deliberate decision. - Missing
id-token: write. The token request fails with a permissions error. Because workflow-levelpermissionsblocks replace defaults, addingid-token: writewithoutcontents: readcan silently break checkout. - The
pull_requesttrigger. Workflows triggered by pull requests from forks run with restricted permissions by default, but any design that grants token access to PR builds lets forked code attempt the exchange. Preferpushto protected branches plus environment approvals for anything that deploys. - Clock skew and expiry. Tokens are short-lived; a long job that requests a token early and uses it late can hit expiry. Request credentials close to where they are used.
- Provider drift. Exact claim names, console UIs, and setup wizards differ across AWS, Azure, and GCP and change over time. The concepts above are stable; the screenshots in any tutorial may not be. Verify against your provider's current documentation before rollout.
When this design changes
One role per target stops being sufficient when multiple teams share a repository with different deploy permissions — at that point, split by environment and use per-environment required reviewers to encode ownership. If you need to deploy from many repositories, consider a reusable workflow in a single trusted repo rather than widening subject conditions. And if a provider or compliance regime requires attestation beyond OIDC claims (for example, hardware-backed key custody), federation alone will not satisfy it; treat OIDC as the default, not the ceiling.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.