Skip to content

Deploying to AWS from GitHub Actions Without a Single Stored Key

This site deploys itself from GitHub Actions, and there is no AWS access key anywhere in the repository, the secrets store, or my shell history. The runner asks GitHub for a short-lived identity token, hands it to AWS, and gets back credentials that expire in an hour.

The concept takes a paragraph to explain. Getting it working took considerably longer, and every hour of that went into four things nobody puts in the quickstart. This is a writeup of those four.

What replaces the key

A stored AWS_ACCESS_KEY_ID is a bearer credential: whoever holds it is you, forever, until you notice and rotate it. It sits in a settings page, gets copied into a local .env, and occasionally ends up pasted into a terminal where a shell history file remembers it.

OIDC replaces the secret with a claim. On every run, GitHub mints a signed JSON Web Token describing the workflow: which repository, which branch, which environment. The runner presents that token to AWS STS. AWS validates the signature against GitHub’s public keys, checks the claims against a role’s trust policy, and — if they match — issues temporary credentials.

Nothing persists. There is no key to leak, and no key to rotate.

The whole GitHub side is three lines:

permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@<sha>
    with:
      role-to-assume: ${{ vars.AWS_ROLE_ARN }}
      aws-region: ${{ vars.AWS_REGION }}

id-token: write is what allows the job to request the token at all. Without it, the action fails with an unhelpful credentials error rather than a permissions one.

The AWS side is a role whose trust policy names the exact workflow identities it will accept:

{
  "Effect": "Allow",
  "Principal": {
    "Federated": "arn:aws:iam::<account-id>:oidc-provider/token.actions.githubusercontent.com"
  },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
      "token.actions.githubusercontent.com:sub": [
        "repo:owner/portfolio:ref:refs/heads/dev",
        "repo:owner/portfolio:environment:production"
      ]
    }
  }
}

That sub list is the entire security boundary. Two strings. Everything below is about how easy it is to change one of them by accident.

Trap 1: the subject changes shape when you add an environment

My workflow has two deploy jobs. The dev job deploys on every push to dev; the production job runs only from a manual dispatch and declares environment: production, which is what produces the environment:production subject the role trusts.

Seeing that, the obvious tidy-up is to give the dev job an environment: dev for symmetry. Dashboards, deployment history, consistency.

It breaks the deploy immediately.

The subject claim is not a fixed identifier for the repository. Its shape depends on how the job is configured:

Job configurationResulting sub claim
Push to a branch, no environmentrepo:owner/repo:ref:refs/heads/dev
Any job declaring environment: Xrepo:owner/repo:environment:X

Declaring an environment replaces the ref-based subject entirely. The dev job stops presenting ref:refs/heads/dev and starts presenting environment:dev, which is not in the trust policy, and AssumeRole fails.

The failure is honest but not obviously connected to the cause — you changed a label and got a credentials error. So the workflow now carries a comment at exactly the place someone would make that edit:

- name: Deploy dev
  # No `environment:` on this job on purpose — it would change the OIDC
  # subject to environment:dev, which the AWS role does not trust.
  run: npx sst deploy --stage dev

A comment is not a guard rail. But this is the single easiest way to break the CI while making the workflow look better, and that combination is worth a sentence in the file.

Trap 2: PowerUserAccess is not enough, and IAM:* is too much

My deploy tool creates Lambda execution roles as part of standing up the stack. PowerUserAccess — the AWS managed policy that grants everything except IAM and Organizations — therefore fails partway through a deploy, leaving the stack half-updated.

The quick fix is to attach IAM permissions. The lazy version of that fix hands the role iam:*, which means a workflow triggered by a push can now create an IAM user, attach AdministratorAccess to it, and mint an access key. You have carefully removed the long-lived credential from CI and then granted CI the ability to manufacture one.

The role instead carries PowerUserAccess plus a narrow inline policy covering only the IAM actions the deploy tool actually needs for execution roles. It can create the roles it needs. It cannot create IAM users or access keys.

That distinction is the whole point of the exercise. If a compromised workflow can mint a permanent credential, the temporary credential bought you nothing.

Trap 3: the OIDC provider is account-level, and probably not yours

The IAM identity provider for token.actions.githubusercontent.com is a single account-wide resource. Every repository federating into that AWS account shares it.

Mine was created by a different project’s Terraform. I found this out by reading its tags — ManagedBy=terraform, and a project name that was not this one. The portfolio does not manage it, does not own it, and would break instantly if that other stack were ever destroyed or refactored.

This is worth knowing before you are debugging at speed, because the symptom is indistinguishable from a broken trust policy: AssumeRole fails, credentials never materialise. So the first diagnostic step is not to read the trust policy. It is:

aws iam list-open-id-connect-providers

If it is gone, nothing else you check matters. That single line now leads the runbook.

Trap 4: not every job should be able to mint a token

I later added a verify job that typechecks and builds before either deploy runs, and wired the workflow to run on pull requests so proposed changes get checked.

At that point id-token: write was declared at workflow level — which meant verify, the one job that executes code from a pull request, could request an AWS token. It had no reason to. It assumes no role and touches no cloud resources; it runs npm ci and a build.

Permissions moved to the jobs that need them:

permissions:
  contents: read          # workflow-wide default

jobs:
  verify:
    # inherits contents: read only — cannot request a token at all
  dev:
    permissions:
      id-token: write
      contents: read

Same idea as the IAM split: the capability exists in exactly one place, and the job running the least trusted code is not that place.

For the same reason, every action is pinned to a full commit SHA rather than a major tag:

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

A tag like v7 is mutable — whoever controls that repository can repoint it at any commit, and these jobs hold a token that assumes a deploy role. A SHA cannot be repointed. The cost is that upgrades become manual, which is a real cost and worth accepting deliberately rather than by default.

What it looks like now

Push to dev and the dev stage deploys itself. Push to main and nothing deploys — main is verified, not released; production ships only from a deliberate workflow_dispatch. Both deploy paths verify first, so a broken build fails in about a minute against no credentials at all, rather than halfway through a deploy with the stage already partly updated.

And there is no key. Not in the repository, not in the secrets store, not on my laptop. The credential exists for the length of a job and then stops existing.

The four traps above all share a shape: each one is a place where the system looks fine and is quietly not. A subject string that changed because you added a label. An IAM policy that grants the ability to undo your own design. A resource you depend on and do not own. A permission granted more broadly than the job that needs it.

Worth writing down, because none of them announce themselves.