When I first tried to make a CI/CD pipeline from GitHub Actions to AWS, the easiest way was to use access keys. Put them in GitHub Secrets, call them inside the workflow, and it works. The deployment goes to AWS, and you feel happy that things are automated.
But everybody knows this method has problems. Static keys are always risk: if they leak, it is a big issue. If you want to rotate them, it is pain. And in bigger teams, keys are shared too much. It is not the secure way.
Today, most people don’t use access keys directly anymore. GitHub already supports OIDC for some time, and AWS IAM role assumption with OIDC is almost the default way now. Instead of long-lived keys, GitHub can get short-lived credentials directly from AWS, only for the job that is running. No secret to rotate, no risk to copy, no password to lose.
This approach is called passwordless, and it is more secure by design. There is no static secret staying in your repository or GitHub settings. There is nothing that can be stolen once and reused many times. Every credential is created fresh, valid only for a short time, and tied to the workflow that requested it. This makes the pipeline not only easier to manage, but also much safer against common security issues.
In this post, I will first show the “old way” with access keys, explain why it is not good, and then move to the OIDC + IAM role assumption way. We will see step by step how it looks in GitHub Actions, and what problems it solves. The deeper technical comparison will come later, but already at the start it is clear: going passwordless is the future for secure CI/CD on AWS.
The Traditional Approach: Using Hardcoded AWS Credentials
When teams first start connecting GitHub Actions with AWS, the default approach is usually to rely on long-lived access keys. An IAM user is created in AWS, the keys are generated, and then these values are stored in GitHub Secrets. From there, workflows can consume them directly to authenticate and run deployments.
At first glance, this looks fine. The pipeline runs, artifacts are built, and deployments happen automatically. But from a security and operational point of view, this method creates a fragile dependency on static credentials that are always present, always valid, and difficult to control at scale. For organizations that already run multiple environments, dozens of repositories, or shared CI/CD infrastructure, this pattern becomes hard to justify.
Example GitHub Actions Workflow with Access Keys
A practical example makes this clear. Below is a workflow that builds a frontend application, pushes the result to S3, and invalidates a CloudFront distribution. The key part is how credentials are configured:
name: Deploy my frontend application application into Amazon S3 + CloudFront distribution
on:
push:
branches: [ main ]
env:
AWS_REGION: us-east-1
NODE_VERSION: '20'
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build site
run: npm run build
env:
# Add any build-time environment variables here
SITE_URL: ${{ secrets.SITE_URL }}
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Deploy to S3
run: |
aws s3 sync dist/ s3://${{ secrets.S3_BUCKET }} --delete --cache-control "max-age=31536000,immutable"
- name: Invalidate CloudFront cache
if: github.ref == 'refs/heads/main'
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/*"
Here, the workflow is authenticating with a static AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. These are long-lived values created once in AWS and then manually copied into GitHub. Every single workflow run is reusing the same credentials.
Where the Secrets Live: GitHub Secrets
GitHub Secrets provides encrypted storage for sensitive values. The secrets are not visible in the repository and are masked in logs, which gives the impression that they are protected. But what actually happens is that GitHub injects these secrets as environment variables into the job runtime. Once injected, they behave like any other variable, meaning that if a workflow step logs them accidentally or a third-party action misuses them, the secret can be exposed.
At an operational level, this means the pipeline is carrying around long-lived AWS credentials on every execution. Even if the GitHub platform itself is secure, the model is still flawed: you have to provision, distribute, and rotate credentials that are meant to be permanent by design. This creates an unnecessary attack surface, because a single compromise of one workflow execution can leak credentials that stay valid long after that run has finished.
Over time, the operational cost grows. Secrets must be rotated, usually by generating new keys in AWS and updating every repository that depends on them. DevOps teams with many pipelines often end up automating the rotation process itself, which is ironic: writing automation just to keep long-lived credentials “fresh”. This cycle shows the fundamental problem. Static secrets do not fit well into modern CI/CD systems where jobs are ephemeral and execution environments are short-lived. The mismatch between permanent credentials and temporary runners is the root cause of many of the security concerns around this approach.
Risks and Limitations
The downsides of this approach become clearer as soon as you look at it from a security architecture perspective:
- Credential leakage risks - Long-lived keys are valid until revoked. If a key is leaked in logs, exposed in a misconfigured repository, or compromised through a GitHub breach, it can be used immediately and repeatedly outside the pipeline. The blast radius is large because nothing ties the key to a specific repository, workflow, or job.
- Secret rotation challenges - IAM user keys must be rotated regularly. But in practice, rotation is often delayed because it requires updating both AWS and GitHub Secrets, sometimes across many repositories. Until that rotation happens, the same static credentials continue to be trusted. The result is a higher probability of stale credentials and operational friction when changes are finally made.
- Human error and maintainability issues - Teams often share the same IAM user keys across multiple projects, simply because it seems convenient. But this erodes accountability. If a deployment fails or suspicious activity is detected in CloudTrail, it is nearly impossible to trace it back to a specific workflow or individual. Over time, this sprawl of unmanaged keys becomes a security debt that is hard to clean up.
From a distance, using static AWS credentials in GitHub Actions may look like a shortcut to achieve automation, but in practice it introduces more complexity than it solves. This is why the industry has shifted towards ephemeral, short-lived credentials through OIDC and IAM role assumption. In the next part, we will see how that model changes the security and operational picture completely.
To make this picture more clear, the following diagram shows how a traditional pipeline with static credentials actually flows. You can see how GitHub Secrets are injected into the runner and used to access AWS resources. This model works, but as we already discussed, it creates a permanent trust link that is hard to manage and exposes unnecessary risks.

What is OIDC (OpenID Connect) in GitHub Actions?
OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0 that allows applications to obtain identity information through signed tokens. In GitHub Actions, every workflow run can request an OIDC JSON Web Token (JWT) that proves its own identity. This token contains claims such as the repository name, the workflow reference, and the commit SHA that triggered the run.
From a CI/CD perspective, OIDC removes the need for static credentials. Instead of storing access keys inside GitHub, the runner requests a signed OIDC token, presents it to AWS, and receives short-lived credentials. Each token is unique for the workflow run, expires within minutes, and cannot be reused outside its context.
The most important point here is that GitHub itself becomes the identity provider. AWS does not need to trust a long-lived key anymore. Instead, AWS trusts the OIDC provider operated by GitHub, and validates tokens against the trust policy of the IAM role. This moves the trust anchor from static secrets to cryptographic identity, which is much more aligned with how ephemeral CI/CD runners should work.
How IAM Role Assumption Works with OIDC
To use OIDC with AWS, you need to configure three main components:
- IAM Role with Trust Policy – The role defines which GitHub identities are allowed to assume it. Trust policies can filter by repository, organization, or even specific branch (
subandrefclaims inside the OIDC token). This fine-grained control is what makes OIDC more secure than just handing over an IAM user key. - GitHub Workflow Permissions – The workflow must have
id-token: writepermissions enabled. This allows the runner to request an OIDC token from GitHub’s identity provider during runtime. - AWS STS (AssumeRoleWithWebIdentity) – The GitHub runner uses the OIDC token to call
sts:AssumeRoleWithWebIdentity. AWS STS validates the token, checks the trust policy, and if conditions are satisfied, issues temporary credentials valid only for that session.
The lifecycle looks like this:
- Runner requests JWT from GitHub’s OIDC endpoint.
- AWS validates the JWT signature against GitHub’s published keys.
- Claims inside the token are matched against IAM trust policy conditions.
- If validation passes, STS returns temporary credentials (by default valid for 1 hour, configurable shorter).
- Workflow uses these ephemeral credentials to deploy resources such as S3 objects or CloudFront invalidations.
At this point, you can check the following diagram to illustrate the high-level trust relationship flow.

Benefits of Going Passwordless
Moving to OIDC and IAM role assumption solves the problems of static keys in several ways:
- Security: No static secrets are stored in GitHub. Credentials are created on-demand, expire quickly, and are scoped tightly by IAM trust policies. This drastically reduces the blast radius in case of compromise.
- Automation and scalability: There is no need for key rotation or distribution. Each repository or branch can be tied to specific IAM roles, making access control declarative and centralized. Large organizations benefit most because there is no exponential growth of keys to manage.
- Reduced operational overhead: Administrators don’t need to set up rotation pipelines or worry about forgotten credentials. Developers no longer need to manually copy values between AWS and GitHub. Everything is resolved at runtime by AWS STS.
This model aligns perfectly with ephemeral runners in GitHub Actions. Every run gets only the credentials it needs, for only as long as it needs them. That is why in modern AWS deployments, passwordless CI/CD with OIDC is already considered the default best practice.
Setting Up GitHub Actions with OIDC and IAM Role Assumption
The migration to OIDC is not only about removing static secrets, it also requires setting up the correct trust model between GitHub and AWS. This setup can be divided into three clear steps: configure an IAM role with trust, attach the right permissions, and finally update the GitHub Actions workflow.
Step 1: Configure an IAM Role in AWS
First, make sure that your AWS account has GitHub’s OIDC identity provider registered. This is usually done once per account. The URL is https://token.actions.githubusercontent.com and the default audience is sts.amazonaws.com.
Next, create a new IAM role that will be assumed by GitHub workflows. Unlike IAM users, this role has no long-lived keys. Instead, the role can only be assumed via sts:AssumeRoleWithWebIdentity when a valid OIDC token is presented.
The most important part is the trust policy. This document decides which workflows from which repository and which branch are allowed to assume the role. Here is a minimal but realistic example:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "GitHubActionsOIDCTrust",
"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"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:<OWNER>/<REPO>:ref:refs/heads/main"
}
}
}
]
}
Key points:
- The
audclaim must matchsts.amazonaws.com, otherwise the token is rejected. - The
subclaim identifies the repository and branch. The format isrepo:OWNER/REPO:ref:refs/heads/<branch>. By scoping tomainyou prevent feature branches from deploying. - You can add more conditions, for example limiting by
repository_ownerclaim, or by environment. This provides fine-grained control over who can assume the role. - If you allow multiple repos or branches, add multiple patterns to
StringLike.
This trust relationship is the backbone of the passwordless model. Instead of trusting static credentials forever, AWS validates every workflow identity at runtime.
Step 2: Define Permissions for the Role
After the trust is configured, the next step is attaching IAM policies to the role. These policies define what the workflow can do once it is authenticated. The principle of least privilege applies here: grant only what is necessary for the deployment.
For example, if your workflow only needs to upload build artifacts to S3 and invalidate a CloudFront distribution, the policy might look like this:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "S3WriteToBucket",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket",
"s3:PutObjectAcl"
],
"Resource": [
"arn:aws:s3:::<S3_BUCKET>",
"arn:aws:s3:::<S3_BUCKET>/*"
]
},
{
"Sid": "CloudFrontInvalidate",
"Effect": "Allow",
"Action": [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation",
"cloudfront:ListInvalidations"
],
"Resource": "arn:aws:cloudfront::<ACCOUNT_ID>:distribution/<DISTRIBUTION_ID>"
}
]
}
Notes:
- If the bucket is private you can remove
PutObjectAcl. - If you only need to invalidate, limit CloudFront actions to just
CreateInvalidation. - For production-grade setups, it is better to create separate roles for different repositories or environments. This way, a staging repo cannot accidentally access production resources.
- Use resource-level ARNs, not wildcards like
*. Wildcards in CloudFront or S3 can lead to over-privileged pipelines.
This step ensures that even if the workflow identity is valid, it can only perform the minimal required actions.
Step 3: Update GitHub Actions Workflow
Now the AWS side is ready. The last part is updating the GitHub Actions workflow. The big difference compared to static keys is that we no longer use aws-access-key-id or aws-secret-access-key. Instead, we grant the workflow permission to request an OIDC token (id-token: write) and configure the AWS credentials action to assume the IAM role.
name: Deploy my frontend application application into Amazon S3 + CloudFront distribution
on:
push:
branches: [ main ]
permissions:
id-token: write
contents: read
env:
AWS_REGION: us-east-1
NODE_VERSION: '20'
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build site
run: npm run build
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::<ACCOUNT_ID>:role/<IAM_ROLE_NAME>
role-session-name: <IAM_ROLE_SESSION_NAME>
aws-region: ${{ env.AWS_REGION }}
- name: Deploy to S3
run: |
aws s3 sync dist/ s3://${{ secrets.S3_BUCKET }} --delete --cache-control "max-age=31536000,immutable"
- name: Invalidate CloudFront cache
if: github.ref == 'refs/heads/main'
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/*"
Key points to notice:
permissions.id-token: writeis mandatory. Without it, GitHub will not issue a JWT.- The
aws-actions/configure-aws-credentialsaction automatically exchanges the OIDC token with AWS STS and exportsAWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY, andAWS_SESSION_TOKENas environment variables for the rest of the job. No manual handling of JWT is needed. role-to-assumemust match the ARN of the role you created in Step 1.role-session-nameis important for auditing. Use a format that makes sense in CloudTrail, for example${{ github.repository }}-${{ github.run_id }}. This makes it easier to track who did what.- You still use secrets for non-sensitive values like
S3_BUCKETorCLOUDFRONT_DISTRIBUTION_ID. These are resource identifiers, not credentials. Keeping them in GitHub Secrets or repository variables is fine. - Temporary credentials are valid by default for one hour. If your builds run longer, you can adjust the maximum session duration on the IAM role.
With this setup, the workflow runs as before, but the trust model is completely different. Instead of injecting permanent secrets into every job, the pipeline now requests ephemeral credentials directly from AWS at runtime.
Comparing the Two Approaches
Shifting from static AWS credentials to OIDC with IAM role assumption is not just a cosmetic change in YAML syntax. It fundamentally alters how trust, security, and operations are managed in your CI/CD pipelines. Below we break down the comparison from three perspectives: security, operations, and developer experience.
Security Perspective
Static credentials:
- Long-lived keys are valid until explicitly revoked or rotated.
- If a key leaks, it can be reused outside GitHub indefinitely.
- Hard to enforce granular trust: the key works from anywhere, not only from GitHub.
OIDC and ephemeral tokens:
- Credentials are short-lived (default 1 hour, often less).
- Tokens are tightly scoped: AWS validates claims such as repo, branch, or environment.
- No permanent secrets exist in GitHub, so there is nothing to exfiltrate in advance.
This is where the following diagram is useful. It illustrates the difference in lifecycle: static keys are injected and reused, while OIDC credentials are requested and validated at runtime before being issued by STS.

Operational Perspective
Static credentials:
- Keys must be rotated manually or with extra automation. For multiple repositories, rotation quickly becomes a burden.
- Hard to track ownership. One IAM user key is often reused across several pipelines, which breaks accountability in CloudTrail logs.
- Every new project requires provisioning and distributing a new set of keys, leading to credential sprawl.
OIDC and ephemeral tokens:
- No need for rotation pipelines. Credentials are minted automatically at runtime.
- Centralized trust policies in AWS IAM define who can assume what. No manual distribution of secrets across repositories.
- Easy to scale: add a new repo by adding a condition in the trust policy, without creating another IAM user or secret.
Developer Experience
Static credentials:
- Setup looks simple at first (copy key into GitHub Secrets), but becomes painful when you manage many repos.
- Developers often become responsible for managing AWS keys, which distracts from building features.
- Any mistake in copying or rotating breaks the pipeline.
OIDC and ephemeral tokens:
- Workflow YAML looks simpler: no secrets for AWS credentials, only the role assumption step.
- Developers focus on deployment logic, not secret management.
- Auditing in CloudTrail is clearer because every role session can be tied back to a repository, branch, and run ID.
With these differences side by side, the contrast becomes obvious: static credentials create long-term risk and operational overhead, while OIDC aligns with the ephemeral nature of CI/CD. For modern AWS deployments, the question is less “should we move?” and more “why haven’t we already?”