Configuring Amazon IAM OIDC Provider for GitHub Actions

How to configure IAM OIDC to allow GitHub actions to securely access on Amazon Web Services


GitHub Actions with AWS credentials

Introduction

If you're using GitHub Actions to deploy workloads to AWS, one critical question arises: how do you securely authenticate your GitHub Actions workflow runners with AWS services? This isn't just a matter of convenience—proper authentication can mean the difference between a secure, automated pipeline and potential security vulnerabilities that could compromise your AWS resources.

In this post, we'll dive deep into IAM OIDC providers, as they represent the more secure and maintainable approach. We'll cover:

  • GitHub Actions with AWS credentials Anti-Pattern
  • Creating a trust relationship between GitHub and AWS using an IAM OIDC provider
  • Disecting the OIDC token
  • An end-to-end IAM OIDC workflow
  • The code implementation

GitHub Actions with AWS credentials (Anti-pattern)

The simplest way to authenticate your GitHub Actions workflow with AWS is to use environment variables and secrets with an IAM user. While simple to set up, it requires careful management and can pose security risks and is not recommended.

Here's how you can do it:

  1. First, you'd need to create an IAM user in AWS specifically for GitHub Actions.

  2. Next, create a role for the IAM user and attach an IAM policy to this role, granting permissions only for the specific resources and actions you want to allow.

  3. Then, generate the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY for the IAM user. These credentials uniquely identify the IAM user and allow secure authentication. You'll want to store them as encrypted secrets in your GitHub repository.

  4. Once the credentials are stored, the IAM user will interact with AWS on behalf of your GitHub Actions workflow, enabling your workflow runner to interact with AWS services securely within the permissions defined in the role policy.

Security Risks with storing AWS credentials in GitHub

Storing AWS credentials in GitHub is not recommended due to security risks. Leaking your AWS credentials could grant a malicious actor access to your AWS account, potentially leading to unauthorized access to your resources or stealing sensitive information. Therefore, you should avoid using broad permissions like AdministratorAccess and instead use IAM roles and policies to grant the necessary permissions to your workflow runner.

IAM OIDC Provider

Another option is using an IAM OIDC provider to authenticate your GitHub Actions workflow with AWS.

At a high-level, this is what the process looks like:

  1. When the GitHub Actions workflow runs, it will send a request to the GitHub OIDC authorization server to get an OIDC token.

  2. The OIDC token is sent to AWS IAM to validate the token. IAM expects the token to have a specific structure.

  3. If the token is valid, AWS IAM will make a request to AWS STS to return a set of temporary security credentials to the GitHub Actions workflow. If not, the request will fail.

  4. Finally, the GitHub Actions workflow can then use the temporary credentials to make calls to AWS APIs.

Establishing a trust relationship between GitHub and AWS

In order to allow GitHub Actions to access AWS resources securely using OpenID Connect (OIDC), you must configure an IAM OIDC identity provider in AWS, which establishes a trust relationship between GitHub and your AWS account.

You define this trust relationship by doing the following 2 things:

  1. Configuring an IAM OIDC identity provider with GitHub's OIDC provider information:

    • OIDC provider endpoint: The authentication server endpoint for GitHub, token.actions.githubusercontent.com
    • Audience: The service that will validate the token, in this case sts.amazonaws.com
    • Thumbprint (optional): OIDC provider endpoint certificate thumbprint, and will be used as a fallback if the primary method fails. This is already pre-computed by AWS.
    • Allowed repositories: which STS will check from the sub claim in the OIDC token.
  2. Define permissions for the federated principal (the GitHub Actions workflow) by creating:

    • A trust policy, a special type of resource-based policy that defines which principals can assume the role, and under which conditions.
    • An IAM role with IAM policies that defines what AWS actions the federated principal that assumes the role can perform in AWS.

Peeling back the layers

Now that we have a high-level understanding of the process, let's take a look at each of the components that make up the process in a little more detail and what's their role in the system.

IAM OIDC Provider Components

The OIDC Provider URL

The provider url is the OpenID Connect (OIDC) identity provider endpoint for GitHub actions.

When a GitHub Actions workflow is triggered, it will send a request to the GitHub OIDC identity endpoint (the server responsible for issuing tokens) to get an OIDC token. AWS will later on use this URL to validate the token.

https://token.actions.githubusercontent.com

Audience

Indicates the recipient(s) that the token is intended for. It represents the service that is going to validate the token. In this case, it's Amazon Security Token Service (STS). The audience responsibility is to prevent token misuse. AWS will reject tokens that don't specify the intended audience.

sts.amazonaws.com

Thumbprint

When setting up GitHub Actions with AWS, you might notice something called a "thumbprint" in the OIDC provider configuration. Let's clear up what this actually does.

AWS uses two layers of security to make sure it's really talking to GitHub:

  1. Primary Method: AWS first tries to verify GitHub using trusted certificate authorities (CAs) - the same way your browser verifies secure websites.

  2. Fallback Method: Only if the primary method fails (like when TLS v1.3 is required), AWS falls back to using the thumbprint - a cryptographic hash of GitHub's server certificate.

0a4b45c74a886bfd4c25a6f2b5e1bc8da4a9f16c

Trust Policy

A trust policy is a specific type of resource-based policy that defines which principals can assume the role, and under which conditions. These conditions are used by AWS to determine which GitHub repositories/branches can assume the role.

The StringEquals condition verifies that the token's audience (aud claim) exactly matches the string "sts.amazonaws.com". This strict matching is required by AWS to ensure the token was specifically intended for use with AWS STS (Security Token Service).

The StringLike condition checks if the token's subject (sub claim) matches a specified pattern. The pattern repo:owner/repository/* ensures the token refers to a GitHub repository under a specific owner. While the repository branch can vary (indicated by the * wildcard), the owner portion must match exactly.

If and only if the conditions match, then AWS will provide temporary credentials via STS which the GitHub Actions workflow can use to assume the role.

// trust-policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "arn:aws:iam::173189841979: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:sergiopichardo/iam-oidc-with-github:*"
                }
            }
        }
    ]
}

IAM role

When setting up GitHub Actions with AWS, you need two IAM related components:

  1. An IAM OIDC Provider with its trust policy (which establishes the trust relationship)
  2. An IAM role that your GitHub Actions workflows can temporarily assume

In the example below, the role will allow the Github actions workflow to:

  • Interact with S3:

    • Upload files to a bucket
    • List bucket contents
    • Get objects from the bucket
  • Manage CloudFront:

    • Invalidate the distribution's cache
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:ListBucket",
            ],
            "Resource": [
                "arn:aws:s3:::iam-oidc-with-github-bucket",
                "arn:aws:s3:::iam-oidc-with-github-bucket/*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "cloudfront:CreateInvalidation",
            ],
            "Resource": [
                "arn:aws:cloudfront::173189841979:distribution/E8B1Z0KDY4129"
            ]
        }
    ]
}

The OIDC Token

The OIDC token is a JSON Web Token (JWT) that contains information about the GitHub Actions workflow that made the request to https://token.actions.githubusercontent.com.

As you may know a JSON Web Token contains 3 parts:

  1. Header - Contains metadata about the token e.g. signing algorithm
  2. Payload - Contains "claims" about the token issuer, audience, subject, and other workflow-specific information
  3. Signature - Created by signing the encoded header and payload, allowing recipients to verify the token's authenticity

The Header

The header is structured as a JOSE header, which is a JSON object that contains metadata about the token. It includes the token type (typ) and the cryptographic algorithm (alg) used to sign the token.

{
  "typ": "JWT",
  "alg": "RS256",
  "x5t": "ykNaY4qM_ta4k2TgZOCEYLkcYlA", 
  "kid": "001DDCD014A848E8824577B3E4F3AEDB3BCF5FFD"
}

The most important fields are:

  • typ: is the token type, e.g. JWT
  • alg: is the signing algorithm, e.g. RS256
  • x5t: is the X.509 certificate thumbprint, which is a hash of the certificate used to verify the token's integrity
  • kid: is the key ID, which uniquely identifies the OIDC token and is used to match the token to the correct public key in GitHub's JWKS endpoint
The connection between the token and the GitHub public key

The connection between the token and the correct public key is made using the "kid" (Key ID) in the JWT header.

Amazon Web Services:

  1. Sees the "kid" in the token header
  2. Fetches the JWKS from GitHub using the endpoint https://token.actions.githubusercontent.com/.well-known/jwks
  3. Finds the matching public key by "kid"
  4. Uses that public key to verify the signature

Just to reiterate, the kid (Key ID) in GitHub's OIDC tokens is typically a string that uniquely identifies the key pair being used. It's a simple identifier for the OIDC token, not the key itself.

It matches the token to the correct public key in GitHub's JWKS endpoint.

// output of the JWKS endpoint
{
    "kid": "001DDCD014A848E8824577B3E4F3AEDB3BCF5FFD",
    // other keys in the JWKS result would go here
    ...
}

The Payload (The claims)

The payload is structured as a JSON object that contains the claims of the token. Claims are statements about an entity (e.g. a user (or web principal), a client, a server, or a resource) that are conveyed in a token.

{
    "sub": "repo:sergiopichardo/github-actions-and-iam-oidc:ref:refs/heads/main", 
    "aud": "sts.amazonaws.com",
    "iss": "https://token.actions.githubusercontent.com",
    "iat": 1637346075,
    "exp": 1637346675,
    
    // other claims
    "jti": "unique-identifier",
    "ref": "refs/heads/main",
    "sha": "a1b2c3d4e5...",
    "repository": "sergiopichardo/github-actions-and-iam-oidc",
    "repository_owner": "sergiopichardo",
    "repository_owner_id": "1234567",
    "run_id": "98765",
    "run_number": "42",
    "run_attempt": "1",
    "actor": "sergiopichardo",
    "workflow": "Deploy",
    "head_ref": "",
    "base_ref": "",
    "event_name": "push",
    "ref_type": "branch",
    "job_workflow_ref": "sergiopichardo/github-actions-and-iam-oidc/.github/workflows/deploy.yml@refs/heads/main",
    "nbf": 1637346075,
    ...
}

The most important claims are:

  • sub (subject): this is the subject of the token, which is the GitHub repository that triggered the workflow. It uniquely identifies the repository and the branch that triggered the workflow. This is going to be used by AWS to check if the token is allowed to assume the role.
  • aud (audience): this is the audience of the token, which is the AWS service that is going to validate the token. In this case, it's Amazon Security Token Service (STS).
  • iss (issuer): this is the issuer of the token, which is the OIDC provider. In this case, it's GitHub's OIDC provider (https://token.actions.githubusercontent.com).
  • iat (issued at): this is the timestamp of when the token was issued.
  • exp (expires at): this is the timestamp of when the token expires.

To see the full list of claims you can make a request to:

curl https://token.actions.githubusercontent.com/.well-known/openid-configuration

The Signature

GitHub Actions uses RS256 (RSA + SHA256), an asymmetric algorithm with public/private key pairs, to sign its OIDC tokens. The signature is created by performing the RS256 operation on the token's base64-encoded header and payload using GitHub's private key.

Here's how GitHub creates the signature:

  1. GitHub creates the signature using the RS256 algorithm by performing a SHA256 operation on the base64-encoded header and payload, and then signing the result with GitHub's private key.
signature = RSA_SIGN(
    SHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload)),
    GITHUB_PRIVATE_KEY
)
  1. AWS can then verify this signature using GitHub's corresponding public key to ensure the token's authenticity. AWS will do this by making a request to GitHub's JSON Web Key Set (JWKS) endpoint.
is_valid = RSA_VERIFY(
    signature,
    SHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload)),
    GITHUB_PUBLIC_KEY  // Retrieved from GitHub's JWKS endpoint
)

The public key is not in the token. It's in a separate endpoint that AWS will retrieve from:

https://token.actions.githubusercontent.com/.well-known/jwks

Connecting the Dots

Now that we've covered the components of the IAM OIDC provider in a little more detail, we can start piecing together the sequence of events that occur when a GitHub Actions workflow is triggered.

  1. GitHub Actions workflow sends request to https://token.actions.githubusercontent.com to get OIDC token

  2. The GitHub OIDC provider creates a JWT and signs it with GitHub's private key.

  3. The OIDC provider returns the signed JWT to the GitHub Actions workflow

  4. The workflow calls AWS STS's AssumeRoleWithWebIdentity API with:

    • The JWT from GitHub
    • The IAM role ARN to assume
  5. AWS STS validates the token through these steps:

    • Matches the kid in the token header to find the correct public key by sending a request to https://token.actions.githubusercontent.com/.well-known/jwks to get the JSON Web Key Set (JWKS)
    • Caches the JWKS for subsequent validations
    • Validates the token's signature using two possible methods:
      • Primary: Validates using GitHub's public keys from JWKS against trusted Certificate Authorities
      • Fallback: Validates using the TLS certificate thumbprint configured in the IAM OIDC provider
    • Verifies the token claims in this order:
      • iss (issuer) must be https://token.actions.githubusercontent.com
      • aud (audience) must be sts.amazonaws.com
      • exp (expiration) must not be expired (the timestamp must be in the future)
      • sub (subject) must match the pattern in the trust policy (e.g., repo:owner/repository:*)
    • Evaluates all conditions in the role's trust policy
  6. If all validations pass, AWS STS:

    • Generates temporary security credentials (access key ID, secret access key, session token)
    • Returns these credentials to the workflow
  7. The GitHub Actions workflow uses these temporary credentials to make AWS API calls

    • Each API call is authorized based on the assumed role's permission policy
    • The workflow can only access resources and perform actions allowed by this policy
    • Credentials automatically expire after their session duration

Code Implementation

The following is the TypeScript code for the AWS CDK stack that sets up the IAM OIDC provider and the IAM role that GitHub Actions workflows can assume.

Only one OIDC provider per provider URL

AWS only allows one OIDC provider per unique provider URL within each AWS account. This means you can only create a single OIDC provider for token.actions.githubusercontent.com. To support multiple GitHub repositories, you don't create new providers - instead, you modify the existing provider's trust policy by adding new IAM policy statements that specify the additional repositories you want to authorize.

// github-oidc.tsx
import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as ssm from 'aws-cdk-lib/aws-ssm';
 
 
interface GithubOidcStackProps extends cdk.StackProps {
    appName: string;
    distribution: cloudfront.Distribution;
    websiteBucket: s3.Bucket;
    allowedRepositories: string[];
    githubThumbprintsList?: string[];
    existingProviderArn?: string;
}
 
export class GithubOidcStack extends cdk.Stack {
 
    // GitHub's OIDC provider domain - issues JWT tokens fo GitHub Actions workflows run
    private readonly githubDomain = 'token.actions.githubusercontent.com';
 
    // Client ID - used as the audience (aud) claim in GitHub's OIDC tokens
    private readonly clientId = 'sts.amazonaws.com';
 
    constructor(scope: Construct, id: string, props: GithubOidcStackProps) {
        super(scope, id, props);
 
        // Instead of creating a new provider
        const provider = this.getOrCreateGithubProvider({
            appName: props.appName,
            githubThumbprintsList: props.githubThumbprintsList,
            providerUrl: `https://${this.githubDomain}`,
            clientIdsList: [this.clientId],
            existingProviderArn: props.existingProviderArn,
        });
 
        const conditions = this.createOidcConditions({
            allowedRepositories: props.allowedRepositories,
        });
 
        const githubActionsRole = this.setupIamConfiguration({
            appName: props.appName,
            originBucket: props.websiteBucket,
            distribution: props.distribution,
            githubProvider: provider,
            conditions,
        });
 
        new ssm.StringParameter(this, `${props.appName}-RoleToAssume-Parameter`, {
            description: 'The IAM role ARN for GitHub Actions to assume',
            parameterName: `/${props.appName}/ROLE_TO_ASSUME_ARN`,
            stringValue: githubActionsRole.roleArn,
            tier: ssm.ParameterTier.STANDARD,
        });
    }
 
    private createOidcConditions(props: {
        allowedRepositories: string[];
    }): iam.Conditions {
        const allowedRepositories = props.allowedRepositories.map(repo => `repo:${repo}`);
 
        return {
            StringEquals: {
                [`${this.githubDomain}:aud`]: this.clientId,
            },
            StringLike: {
                [`${this.githubDomain}:sub`]: allowedRepositories,
            },
        };
    }
 
    private setupIamConfiguration(props: {
        appName: string,
        githubProvider: iam.IOpenIdConnectProvider,
        conditions: iam.Conditions,
        originBucket: s3.Bucket,
        distribution: cloudfront.Distribution,
    }): iam.Role {
        const policyDocument = new iam.PolicyDocument({
            statements: [
                new iam.PolicyStatement({
                    actions: ['s3:*'],
                    resources: [
                        props.originBucket.bucketArn,
                        `${props.originBucket.bucketArn}/*`,
                    ],
                }),
                new iam.PolicyStatement({
                    actions: ['cloudfront:*'],
                    resources: [
                        `arn:aws:cloudfront::${cdk.Stack.of(this).account}:distribution/${props.distribution.distributionId}`
                    ],
                }),
            ],
        });
 
        return new iam.Role(this, `${props.appName}GitHubActionsRole`, {
            roleName: `${props.appName}GitHubActionsRole`,
            assumedBy: new iam.WebIdentityPrincipal(
                props.githubProvider.openIdConnectProviderArn,
                props.conditions,
            ),
 
            inlinePolicies: {
                [`${props.appName}GitHubActionsPolicy`]: policyDocument,
            },
        });
    }
 
    private getOrCreateGithubProvider(props: {
        appName: string;
        githubThumbprintsList?: string[];
        providerUrl: string;
        clientIdsList: string[];
        existingProviderArn?: string;
    }): iam.IOpenIdConnectProvider {
 
        let provider: iam.IOpenIdConnectProvider;
 
        if (props.existingProviderArn) {
            provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this,
                `${props.appName}GithubProvider`,
                props.existingProviderArn as string,
            );
 
            if (provider) return provider;
        }
 
        provider = new iam.OpenIdConnectProvider(this, `${props.appName}GithubProvider`, {
            url: props.providerUrl,
            clientIds: props.clientIdsList,
            thumbprints: props.githubThumbprintsList,
        });
 
        return provider;
    }
}
Source Code

You can find the source code related to this post in this GitHub repository.

Conclusion

Using OIDC for GitHub Actions authentication with AWS is more secure and easier to maintain than storing AWS credentials in GitHub. While it takes a bit more work to set up initially, you won't have to worry about managing or rotating credentials, and you'll have better control over which repositories can access your AWS resources. It's a modern solution that makes your deployment pipeline both more secure and more automated while adhering to modern security best practices.

Resources