jellyace
← All posts
DevOps·4 min read

Deploying to AWS from GitHub Actions without long-lived keys

Replace stored AWS access keys with short-lived OIDC credentials, and set up a test-then-deploy pipeline with approvals and safe concurrency.

Plenty of CI/CD pipelines still deploy to AWS with an access key stored as a repository secret. It works — until that key leaks through a log, a fork or a compromised dependency, and someone has permanent access to your account.

GitHub Actions supports OpenID Connect (OIDC), which lets a workflow get short-lived AWS credentials on demand. No keys stored anywhere, and nothing to rotate.

Comparison: a stored access key lives in repository secrets, stays valid until rotated and works wherever it leaks to. An OIDC role stores nothing in GitHub, its credentials expire on their own (one hour by default), and only one repository and environment can get them.

How it works

When a workflow runs, GitHub can issue a signed token describing the job: which repository, which branch, which environment. AWS checks that token against an identity provider you've set up, and if it matches the conditions on an IAM role, hands back temporary credentials that expire automatically.

Sequence diagram: the job requests a token from GitHub's OIDC provider, sends it to AWS STS with the role ARN, STS checks the audience and subject against the role's trust policy, and returns credentials valid for one hour by default.

By default the credentials last one hour. If a deploy takes longer, raise role-duration-seconds on the credentials step, and the role's maximum session duration with it.

The AWS side

You need two things, ideally both defined in Terraform:

  1. An IAM OIDC identity provider for token.actions.githubusercontent.com, with audience sts.amazonaws.com.
  2. An IAM role whose trust policy only allows the exact repository and environment you intend:
{
  "Effect": "Allow",
  "Principal": {
    "Federated": "arn:aws:iam::123456789012: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:acme/api:environment:production"
    }
  }
}

The sub condition is what matters. IAM won't accept a GitHub role without one, but a loose pattern such as repo:* would still let any repository on GitHub assume the role. Use separate roles for staging and production, each with only the permissions its deploy needs.

The sub value depends on what triggered the job, so match the pattern to how you deploy:

Job runs for sub looks like
A GitHub environment repo:acme/api:environment:production
A branch push repo:acme/api:ref:refs/heads/main
A tag repo:acme/api:ref:refs/tags/v1.2.0
A pull request repo:acme/api:pull_request

Update (October 2026): Repositories created, renamed or transferred after 15 July 2026 use an immutable sub format that includes owner and repository IDs, for example repo:acme@123456/api@456789:environment:production. Older repositories keep the format above unless they opt in. Check which one yours uses before writing the trust policy.

If a job uses an environment, the environment wins and the branch isn't in sub. Protect the environment's deployment branches in GitHub instead.

The workflow

name: Deploy

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

concurrency:
  group: deploy-production
  cancel-in-progress: false

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-deploy-production
          aws-region: eu-west-1
      - run: ./scripts/deploy.sh

Update (October 2026): The @v4 versions of both actions run on Node 20, which GitHub removed from its runners in September 2026. Use the current majors, actions/checkout@v7 and aws-actions/configure-aws-credentials@v6, or pin them to commit SHAs.

A few details worth calling out:

  • id-token: write lets the job request an OIDC token. Without it, the credentials step fails.
  • environment: production puts environment:production into the token, which the trust policy checks. It also enables GitHub's environment protection rules.
  • concurrency stops deploys from running on top of each other. Only the newest waiting run is kept, and older waiting runs are cancelled, which is usually what you want for deploys. cancel-in-progress: false means a deploy that has started is never cut off halfway.
  • needs: test means nothing ships unless the tests pass.

Update (October 2026): GitHub now supports queue: max in a concurrency block, which keeps up to 100 waiting runs instead of only the newest.

Hardening checklist

  • Add required reviewers to the production environment in GitHub, so deploys wait for approval. For private repositories this needs GitHub Enterprise.
  • Restrict which branches can deploy to that environment.
  • Pin third-party actions to a full commit SHA rather than a tag.
  • Keep each deploy role narrow — a role that only updates one ECS service is much less dangerous than one with admin rights.
  • Use CloudTrail to see when and from which repository and environment each role was assumed. Set role-session-name (for example to the run ID) to trace individual runs.

Using GitLab?

GitLab CI supports the same approach with id_tokens. The AWS side — identity provider plus a tightly scoped role — is almost identical.

Want this done for you?See our DevOps services

Have something you need built?

Tell us a bit about your product and what you’re trying to get done. You’ll hear back from an engineer, not a sales team — no obligation.

hello@jellyace.net