With GitHub Actions OIDC, your pipeline gets its own short-lived AWS credentials for every run instead of reading an access key from the repository secrets. The idea has been around for a few years. Even so, I still see the old pattern surprisingly often in examples and tutorials: one IAM user, two secrets, done. Until the key ends up in a log or nobody remembers who created it and when.
On top of that, a change is currently catching many people off guard: since July 15, 2026, the so-called sub claim looks different for new GitHub repositories. If you copy a trust policy from an older blog post, all you get is Not authorized to perform sts:AssumeRoleWithWebIdentity. That is why I build the setup cleanly from scratch in this article, with AWS CDK, a working workflow and tests.
In short
GitHub Actions can identify itself to AWS via OpenID Connect and receives its own short-lived credentials for every run. The key piece is the trust policy of the IAM role, which allows exactly one repository and one environment. The biggest pitfall is the sub claim, which for new repositories has also contained the numeric owner and repository IDs since July 2026.
What you will learn in this article
- How the token exchange between GitHub and AWS STS works
- How to create the OIDC provider and deploy role with AWS CDK in TypeScript
- How to restrict the sub claim correctly, including the new format since July 2026
- How to write a workflow that deploys without a single AWS key
- How to quickly make sense of the typical error messages
Why access keys in GitHub secrets are a risk
An IAM user access key is a permanent password. It does not expire on its own, it works from any machine in the world, and it usually has more permissions than a single workflow needs. GitHub stores it encrypted, but every workflow step that receives it can also pass it on: through a compromised action package, a careless env dump in the log, or a pull request that is allowed to do more than intended. I have already described in detail what can go wrong with pull_request_target.
Such a key is also an organizational burden. It has to be rotated, someone has to know which repository it belongs to, and during offboarding you end up searching for it in three accounts. With OIDC, all of that disappears: there is no key you could lose. There is only a rule in AWS that says which workflow may assume a role.
How GitHub Actions OIDC works with AWS
OpenID Connect (OIDC) is an open standard that lets one service confirm to another who is making a request. GitHub runs its own token service for this at token.actions.githubusercontent.com. Every job that has the id-token: write permission can request a signed JSON Web Token (JWT) there. A JWT is a small, signed data structure with details such as repository, branch, environment and trigger. These details are called claims.
On the AWS side, you create an OIDC identity provider once that trusts this token service. You also need an IAM role with a trust policy. The job then calls the Security Token Service (STS) with AssumeRoleWithWebIdentity and sends the JWT along. STS verifies the signature, IAM compares the claims with the trust policy, and if everything matches, it returns temporary credentials. The aws-actions/configure-aws-credentials action makes this call for you.
🎬 Flow: GitHub Actions OIDC with AWS – this animated diagram only runs on my site: watch it on sascha-ms.de
One thing matters for understanding: the OIDC token itself cannot do anything in AWS. It is only the ID card. What the job may do afterwards is defined solely by the permissions of the role. That is why a good setup always has two parts: a narrow trust policy and lean permissions.
The sub claim decides who gets the role
The most important claim is sub (subject). GitHub builds it from the repository and the context. Depending on how the job runs, it looks different:
| Situation | Value of sub |
|---|---|
Job with environment: production | repo:example-org/bike-rental-api:environment:production |
| Push to the main branch (no environment) | repo:example-org/bike-rental-api:ref:refs/heads/main |
| Tag v1.4.0 (no environment) | repo:example-org/bike-rental-api:ref:refs/tags/v1.4.0 |
| Pull request (no environment) | repo:example-org/bike-rental-api:pull_request |
As soon as a job uses an environment, the sub claim contains the environment and no longer the branch. That surprises many people the first time. I still like to use environments for deploys, because GitHub offers additional protection rules there, for example “only the main branch may deploy to production” or an approval by a second person.
By the way, AWS no longer gives you a choice here: if a role trusts the GitHub provider, IAM requires a condition on token.actions.githubusercontent.com:sub that does not consist of a wildcard alone. A policy that simply trusts every GitHub repository cannot even be saved.
New sub format since July 15, 2026
Until now, the sub claim consisted of names only. That becomes a problem when an organization is deleted and someone else registers its name later: a foreign repository could then produce exactly the same string again. That is why GitHub appends the immutable numeric IDs, separated by an @, for repositories created after July 15, 2026.
Since July 15, 2026, the sub claim of new repositories also contains the owner and repository ID.
Older repositories keep the previous format until you switch them over, either for the whole organization or for individual repositories. If you rename or transfer a repository, it also switches to the new format. For you, this means: check which format your repository sends before you write the trust policy. You can find the IDs in the repository’s OIDC settings, or you can display the token with the github/actions-oidc-debugger action, ideally in a private repository.
I think the new format is a good decision, even if it means some work in the short term. Nobody can get around a trust policy that is tied to an ID with a cleverly chosen name.
How to set up GitHub Actions OIDC with AWS CDK
As an example, I use a fictional repository example-org/bike-rental-api that deploys a small Serverless API for a bike rental shop with CDK. The OIDC part is a separate, small stack that you roll out once per AWS account.
Prerequisites
- An AWS account in which you may create roles and OIDC providers locally (once)
- Node.js 22 or 24 and the AWS CLI
- An account prepared with
cdk bootstrapif your application is deployed with CDK - A GitHub repository with an environment named
production(Settings → Environments)
Step 1: Create the project
mkdir github-oidc && cd github-oidc npm init -y npm install aws-cdk-lib constructs npm install -D aws-cdk typescript ts-node @types/node Also create a cdk.json with the content { "app": "npx ts-node --prefer-ts-exts bin/app.ts" } and a simple tsconfig.json. A strict default configuration with CommonJS modules is enough for the tsconfig.json.
Step 2: The stack with provider and role
The stack creates the OIDC provider unless you pass in the ARN of an existing one. There can only be one provider for the same URL per AWS account. I use the OidcProviderNative construct for this, which creates the CloudFormation resource AWS::IAM::OIDCProvider directly. The older OpenIdConnectProvider still works with a Lambda-based custom resource. You no longer need a thumbprint, because AWS now verifies GitHub’s certificate itself.
// lib/github-oidc-stack.ts import { Duration, Stack, StackProps, CfnOutput } from 'aws-cdk-lib'; import * as iam from 'aws-cdk-lib/aws-iam'; import { Construct } from 'constructs'; export interface GitHubOidcStackProps extends StackProps { /** e.g. "example-org/bike-rental-api" or the immutable form "example-org@1234567/bike-rental-api@89012345" */ readonly githubRepo: string; /** Name of the GitHub environment that is allowed to deploy */ readonly githubEnvironment: string; /** ARN of an existing GitHub OIDC provider (only one per account is possible) */ readonly existingProviderArn?: string; } const GITHUB_ISSUER = 'token.actions.githubusercontent.com'; export class GitHubOidcStack extends Stack { constructor(scope: Construct, id: string, props: GitHubOidcStackProps) { super(scope, id, props); // 1. OIDC provider: create a new one or reuse an existing one const provider = props.existingProviderArn ? iam.OidcProviderNative.fromOidcProviderArn(this, 'GitHubProvider', props.existingProviderArn) : new iam.OidcProviderNative(this, 'GitHubProvider', { url: `https://${GITHUB_ISSUER}`, clientIds: ['sts.amazonaws.com'], }); // 2. Trust policy: only this repository and only this environment const subject = `repo:${props.githubRepo}:environment:${props.githubEnvironment}`; const principal = new iam.OpenIdConnectPrincipal(provider, { StringEquals: { [`${GITHUB_ISSUER}:aud`]: 'sts.amazonaws.com', [`${GITHUB_ISSUER}:sub`]: subject, }, }); // 3. Deploy role with a short session duration const deployRole = new iam.Role(this, 'GitHubDeployRole', { roleName: 'bike-rental-github-deploy', assumedBy: principal, description: `GitHub Actions deploy for ${props.githubRepo}`, maxSessionDuration: Duration.hours(1), }); // 4. Permissions: may only assume the CDK bootstrap roles deployRole.addToPolicy( new iam.PolicyStatement({ actions: ['sts:AssumeRole', 'sts:TagSession'], resources: [`arn:${this.partition}:iam::${this.account}:role/cdk-hnb659fds-*-role-${this.account}-*`], }), ); new CfnOutput(this, 'DeployRoleArn', { value: deployRole.roleArn }); new CfnOutput(this, 'TrustedSubject', { value: subject }); } } Two details matter to me here. First, the policy checks with StringEquals instead of StringLike. A wildcard like repo:example-org/* opens the role to every repository in the organization, including the test repository where someone tried out an unknown action yesterday. Second, the role itself may only do one thing: assume the CDK bootstrap roles. The actual permissions for CloudFormation, S3 assets and lookups live in those roles that cdk bootstrap created.
Step 3: The app with context values
// bin/app.ts import { App } from 'aws-cdk-lib'; import { GitHubOidcStack } from '../lib/github-oidc-stack'; const app = new App(); const githubRepo = app.node.tryGetContext('githubRepo'); if (!githubRepo) { throw new Error('Please pass -c githubRepo=<org>/<repo>'); } new GitHubOidcStack(app, 'BikeRentalGitHubOidc', { githubRepo, githubEnvironment: app.node.tryGetContext('githubEnvironment') ?? 'production', existingProviderArn: app.node.tryGetContext('existingProviderArn'), env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, }); Step 4: Test and roll out once
Before anything lands in AWS, I check the trust policy with a test. CDK ships the assertions module for this, and the Node.js test runner is all you need.
// test/github-oidc-stack.test.ts import { test } from 'node:test'; import { App } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import { GitHubOidcStack } from '../lib/github-oidc-stack'; function synth(existingProviderArn?: string) { const app = new App(); const stack = new GitHubOidcStack(app, 'TestStack', { githubRepo: 'example-org/bike-rental-api', githubEnvironment: 'production', existingProviderArn, env: { account: '111122223333', region: 'eu-central-1' }, }); return Template.fromStack(stack); } test('trust policy checks aud and sub', () => { synth().hasResourceProperties('AWS::IAM::Role', { AssumeRolePolicyDocument: { Statement: [ Match.objectLike({ Action: 'sts:AssumeRoleWithWebIdentity', Condition: { StringEquals: { 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com', 'token.actions.githubusercontent.com:sub': 'repo:example-org/bike-rental-api:environment:production', }, }, }), ], }, }); }); test('creates the provider only when none is passed in', () => { synth().resourceCountIs('AWS::IAM::OIDCProvider', 1); synth('arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com') .resourceCountIs('AWS::IAM::OIDCProvider', 0); }); npx tsc --noEmit node -r ts-node/register --test test/github-oidc-stack.test.ts npx cdk synth -c githubRepo=example-org/bike-rental-api npx cdk deploy -c githubRepo=example-org/bike-rental-api The tests should end with # pass 2. After the deploy, CDK shows two outputs: the ARN of the role and the subject value the role trusts. If your repository already sends the new format, simply pass the IDs along, for example -c githubRepo=example-org@1234567/bike-rental-api@89012345. If the account already has a GitHub provider, append -c existingProviderArn=arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com.
Without CDK, the same trust policy looks like this as plain JSON. That is handy if you want to check it in the console or create it with the AWS CLI:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::111122223333: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:example-org/bike-rental-api:environment:production" } } } ] } Step 5: The workflow
Enter the role ARN in GitHub as the variable AWS_DEPLOY_ROLE_ARN (Settings → Secrets and variables → Actions → Variables). You don’t need a secret for this, because the ARN is not confidential. Then comes the workflow:
# .github/workflows/deploy.yml name: Deploy on: push: branches: [main] workflow_dispatch: # Default for all jobs: read only permissions: contents: read concurrency: group: deploy-production cancel-in-progress: false jobs: deploy: runs-on: ubuntu-latest environment: production permissions: contents: read id-token: write # required so the job may request an OIDC token steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 24 cache: npm - run: npm ci - name: AWS access via OIDC uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }} aws-region: eu-central-1 role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }} mask-aws-account-id: true - name: Who am I? run: aws sts get-caller-identity --query Arn --output text - name: Deploy run: npx cdk deploy --all --require-approval never I deliberately set the id-token: write permission only on the deploy job. Other jobs in the same workflow, such as linting or tests for pull requests, therefore never receive an OIDC token. The role-session-name with run ID and attempt makes it visible in CloudTrail later which run did what.
Step 6: Check the result
Push to main. The “Who am I?” step should print a line that starts with arn:aws:sts:: and ends with assumed-role/bike-rental-github-deploy/gh-…. After that, cdk deploy runs as usual. In CloudTrail, you will find an AssumeRoleWithWebIdentity event for every run, which also contains the sub claim. That is your audit trail, without any logging of your own.
Which permissions does the deploy role need?
With CDK, the answer is pleasantly short: sts:AssumeRole on the bootstrap roles is enough. The CDK CLI then assumes the deploy, file publishing and lookup roles itself. This has a nice side effect: if you want to restrict the permissions of your deploys, you adjust the bootstrap template and not every single pipeline.
The Serverless Framework works differently. There, the CLI calls CloudFormation, S3 and other services directly with the role’s credentials. The role then needs permissions for CloudFormation, the deployment bucket, the functions’ IAM roles (iam:PassRole only on those roles) and Lambda. The workflow stays almost the same, only the last step becomes npx serverless deploy --stage prod. For the dashboard login of version 4, you still need a SERVERLESS_ACCESS_KEY as a secret, but that has nothing to do with AWS. How to keep the deploy role for the Serverless Framework lean with a service role and a permissions boundary is shown step by step in Serverless Framework GitHub Actions: least-privilege deploys. How the switch to version 4 works overall is covered in my article on migrating to Serverless Framework v4.
A tip for multiple environments: create a separate role per environment, ideally in separate AWS accounts. The role for staging then only trusts environment:staging, the one for production only environment:production. That way, a mistake in the staging workflow can never affect production.
Common GitHub Actions OIDC errors
Not authorized to perform sts:AssumeRoleWithWebIdentity
This is by far the most common message. Almost always, the sub claim does not match the policy. Check three things: does the job use an environment (then the claim contains environment:…, not the branch)? Does the repository already send the new format with IDs? And is the upper and lower case correct? StringEquals compares exactly.
Credentials could not be loaded
Usually id-token: write is missing. Watch out: as soon as you set permissions anywhere, all permissions you don’t list are revoked. If you only add id-token: write, you will be surprised afterwards that actions/checkout can no longer read. That is why the example always includes contents: read as well.
Provider already exists
There is only one OIDC provider for token.actions.githubusercontent.com per account. If another team has already created it, your deploy fails. In that case, pass the existing ARN via existingProviderArn and leave the provider where it is.
Pull requests from forks and Dependabot
Workflows from pull requests of forks only get read permissions by default and therefore no id-token: write either. That is a good thing. Dependabot jobs carry the value dynamic in the event_name claim. If your role should only allow deploys, binding it to an environment with protection rules is the cleanest way to rule out both.
My take
In 2026, I no longer see a good reason to store AWS access keys in GitHub. The setup is manageable, and afterwards a whole class of problems is simply gone: no rotation, no orphaned keys, no “Who actually created this one?”. The fact that AWS now rejects policies without a sub condition and GitHub is moving to immutable IDs shows that both sides take the topic seriously.
Where I disagree: OIDC is sometimes sold as “secure by default”. That is only half true. A role with AdministratorAccess and a trust policy on repo:my-org/* is hardly better with OIDC than an access key, it is just more convenient. The real work lies in the narrow policy and in lean permissions. Even the nicest tool can’t make that decision for you, you have to make it consciously.
FAQ
Do I still need a thumbprint for GitHub Actions OIDC?
No. AWS now verifies the certificate of the GitHub token service itself. A thumbprint you specify is ignored for GitHub.
How long are the credentials from the OIDC exchange valid?
One hour by default. With role-duration-seconds you can request a shorter duration or, if the role’s maximum session duration allows it, a longer one.
Can I allow multiple repositories for one role?
Yes. Instead of a single value, you provide a list of sub values with StringEquals. I still recommend one role per repository and environment, because permissions can then be separated cleanly.
Does this also work with GitHub Enterprise Server?
In principle yes, but with your server’s issuer URL. The new sub format with IDs is not available on GitHub Enterprise Server.
Your next step
Search your repositories for AWS_ACCESS_KEY_ID. Take the first repository you find, roll out the stack from this article and then delete the old access key in IAM. From then on, there is nothing left in that repository that would need rotating. I am curious: which of your pipelines still depends on a permanent key, and what is holding you back from switching?
Sources
- GitHub Docs: OpenID Connect reference (including immutable subject claims)
- aws-actions/configure-aws-credentials (README, v6.3.0)
- AWS IAM: Create a role for OpenID Connect federation
- AWS Prescriptive Guidance: Security best practices (GitHub Actions with OIDC)
- AWS re:Post: Troubleshoot AssumeRoleWithWebIdentity errors
- AWS CDK API: OidcProviderNative
This post was created with AI assistance.
👉 Next step: Working with AWS CDK? Grab my printable AWS CDK cheat sheet with commands, concepts and a stack skeleton.
📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en


