Skip to content
webkida wiki
Go back

[GitHub Actions] Authenticate to the cloud with OIDC

When you deploy from GitHub Actions to the cloud, this post compares putting long-lived secrets in Secrets with OIDC.

When you deploy from GitHub Actions to AWS or similar, many people get stuck on how to pass secrets.

The usual flow is: issue an IAM user Access Key, store it in GitHub Secrets, and have the workflow read that secret.

GitHub Actions also supports OIDC (OpenID Connect) and issues a short-lived JWT on every job run. AWS can verify the JWT and then hand over short-lived secrets. You do not have to leave long-lived secrets in the repository, so it is worth considering when you want to raise deploy security.

Below I walk through terms, the weaknesses of the older approach, the benefits of OIDC, the authentication flow, and the settings you need. The official GitHub explanation is also useful for the overall picture.

Terms

GitHub OIDC

When a GitHub Actions job runs, GitHub issues a short-lived JWT (JSON Web Token). GitHub acts as the identity provider, and on every job run it puts “this job is running now” into the JWT.

You do not need to put a long-lived Access Key in Secrets. Each job uses a new JWT.

Trust Policy

One setting on an AWS IAM role. It decides who may assume the role (AssumeRole). In a GitHub OIDC integration, the Trust Policy is the gate that checks the JWT contents (which repository, which branch, and so on).

Only a JWT that meets the conditions can assume the role, and then short-lived secrets are handed over.

Permission Policy

The other setting on an AWS IAM role. It decides which AWS operations are allowed after the role is assumed. The Trust Policy controls the entrance (AssumeRole). The Permission Policy controls the range of API calls afterward, such as S3 or IAM.

It is easy to mix up with the Trust Policy, but they are different settings. AWS operation permissions such as S3 write on the production role and read-only on the preview role are split in the Permission Policy.

Both approaches end with an AWS secret

When GitHub Actions calls an AWS API, what actually goes on the request is a secret issued by AWS (an Access Key ID and Secret Access Key pair).

In the long-lived-secret approach, you put the secret you got when you created the IAM user into GitHub Secrets, and the workflow uses it as-is. In the OIDC approach, GitHub issues a JWT, AWS verifies the JWT with the Trust Policy, and then it issues a new short-lived secret.

At the API-call stage, AWS is only verifying the secret. It does not care whether the secret is long-lived or short-lived. It only checks whether the secret is valid. The difference between the approaches is where you store the secret, and with what lifetime you issue it.

A JWT is not the secret used to call the API. It is proof you show AWS so you can receive a short-lived secret.

The older approach: register a long-lived secret on GitHub

A common flow is to issue an IAM user Access Key, register it in GitHub Secrets, and use it from the workflow.

env:
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

It is easy to get running, but a secret once placed in Secrets can be used until you invalidate it.

Rough flow

sequenceDiagram
    participant IAM as IAM user
    participant Sec as GitHub Secrets
    participant WF as GitHub Actions
    participant AWS as AWS API
    participant PC as Local PC

    rect rgba(120, 140, 180, 0.1)
        Note over IAM,PC: Setup, once
        IAM->>Sec: ① Issue an Access Key and store it
    end

    rect rgba(120, 160, 130, 0.1)
        Note over IAM,PC: At job run time
        WF->>Sec: ② Read the long-lived secret
        WF->>AWS: ③ Call the AWS API
    end

    rect rgba(180, 130, 120, 0.1)
        Note over IAM,PC: Where this often goes wrong
        PC->>Sec: ④ Copy the same secret
        PC->>AWS: ⑤ Call the API outside the workflow
    end

Figure: The Access Key is stored in Secrets at all times. The same secret can also be used from a local machine or anywhere outside the workflow.

The operational blind spot is that the same Access Key can also be reused outside the workflow (local development or another tool).

Weaknesses

ViewpointWhat tends to happen
LifetimeLasts until you invalidate or rotate it
RotationYou have to keep AWS, Secrets, and other environments in sync by hand
Permission designOne secret often gets reused across production, PRs, and multiple repositories
AuditCloudTrail is per IAM user. It is a different trail from GitHub jobs

Risk of the long-lived secret itself

While an Access Key is valid, it can call APIs as many times as it wants. If it leaks, the damage continues until you invalidate it.

The pain of key rotation

Recommended periodic rotation becomes a job of lining up AWS, GitHub Secrets, local environments, and more. A missed update is easy to leave in at least one place.

Permission design tends to get coarse

It is easy to attach wide permissions to one deploy IAM user and use the same secret for production and PR preview. It becomes hard to narrow each use to the minimum needed.

AWS logs alone cannot tell you which job the call came from

CloudTrail records the API calls as that IAM user, but the principal is always that Access Key. AWS-side logs alone cannot tell a GitHub Actions workflow or job from a local operation.

Comparison: long-lived secrets vs OIDC

ViewpointLong-lived secret (Secrets)OIDC
What you put on GitHubAccess Key (long-lived)No AWS secret needed (role ARN only)
LifetimeLasts until you invalidate itExpires automatically per job
RotationManual (AWS + Secrets + others)Short-lived secrets are issued automatically
How you split permissionsPer IAM userBranch on repository / ref / environment
Audit logsRecorded as the IAM userRole session + claims / tags

Benefits of using OIDC

Against the weaknesses in the previous section, OIDC changes things as follows.

Rough flow

sequenceDiagram
    participant GH as GitHub OIDC
    participant WF as GitHub Actions
    participant AWS as AWS
    participant API as AWS API

    Note over AWS: Setup, once<br/>OIDC provider and Trust Policy

    rect rgba(120, 160, 130, 0.1)
        Note over GH,API: At job run time
        WF->>GH: ① Request a JWT
        GH-->>WF: ② Short-lived JWT
        WF->>AWS: ③ Assume the role
        AWS->>AWS: ④ Trust Policy verifies claims
        AWS-->>WF: ⑤ Short-lived secret
        WF->>API: ⑥ Call the API
    end

    Note over WF,API: No AWS key in Secrets<br/>it expires when the job ends

Figure: A JWT is issued per job, and a short-lived secret is handed over only after the Trust Policy passes.

The big difference from the long-lived-secret approach is that you do not put a long-lived secret on GitHub, and you receive only a short-lived secret on each job run.

Weakness of the older approachWhat changes with OIDC
Risk of a long-lived secretYou do not put an Access Key in Secrets
Rotation workShort-lived secrets are issued and expire automatically per job
Coarse permission designThe Trust Policy can condition on repo / ref / environment
Audit linkageCloudTrail can more easily carry the role session + claims

You do not have to put a long-lived secret on GitHub

You configure AWS to trust GitHub as an OIDC provider. On every run, the workflow gets a short-lived JWT and assumes the role. The leak target of “a long-lived secret sleeping in the repository” goes away.

Key-rotation work goes down

You do not have to keep rotating an AWS secret on the GitHub side. Maintenance shifts to the design of the Trust Policy and IAM roles.

You can split permissions by repository, branch, and so on

A Permission Policy on the IAM role alone makes it hard to write a condition such as “only jobs from this repository’s main branch”. With OIDC, repository and ref are placed on the JWT automatically, so the Trust Policy can condition on repo / branch.

You can split production and PR-preview roles, and use the Trust Policy to split the entrance into main-only and PR-only. Write the repo / branch conditions in the Trust Policy. The workflow only specifies the role ARN to assume.

AWS-side logs can also carry GitHub Actions context

A session assumed with AssumeRoleWithWebIdentity is recorded in CloudTrail, and you can also put claims on the session name or session tags. It becomes easier to leave which repository, branch, and workflow the operation came from in the AWS-side logs as well.

OIDC authentication flow

The OIDC flow splits into two stages: setup (once) and job run time (every time).

[Setup] AWS side (Trust Policy)

“If it is a JWT issued by GitHub, and it came from the main branch of my-org/my-repo, you may lend this IAM role”

No job is running yet. This is the stage where you decide the identity conditions in advance.

[At job run time] GitHub Actions → AWS

workflow: “I brought a JWT. This job came from main of my-org/my-repo. Please let me assume the role”

GitHub OIDC issues a JWT (an ID card) per job, and the workflow shows it to AWS.

[At job run time] AWS side

AWS: “I will look at the JWT sub… it matches the Trust Policy Condition. OK, I will hand over a short-lived secret”

AWS compares the JWT contents (sub, repository, ref, and so on) with the Condition you wrote during setup. If they match, a short-lived secret comes back and you can run an S3 deploy or similar. If they do not match, it is denied here.

The JWT (issued by GitHub) and the short-lived secret (issued by AWS) are different things. The former is a declaration of “this is that kind of job”. The latter is the secret used to call AWS APIs.

Setup

Before the workflow runs, you set up the trust relationship and roles on AWS and GitHub. What you do is in the next table.

WhereWhat you do
AWSRegister GitHub’s OIDC provider (token.actions.githubusercontent.com)
AWSCreate a deploy IAM role, and write in the Trust Policy “which repo / branch JWTs are OK”
AWSAttach a Permission Policy to the same role, and decide what it can do after assume (S3 writes and so on)
GitHubAdd permissions: id-token: write to the workflow so it can get a JWT
GitHubSpecify the role ARN to assume with configure-aws-credentials or similar

No API is called at this stage. This is the work of building the foundation: GitHub trusting AWS, and AWS trusting JWTs issued by GitHub.

At job run time

Every time a workflow job runs, a JWT is issued and exchanged for a short-lived secret in the following flow.

sequenceDiagram
    participant GH as GitHub Actions
    participant IdP as GitHub OIDC Provider
    participant AWS as AWS (IAM)

    GH->>IdP: ① Request a JWT
    IdP-->>GH: ② Return a JWT (sub, ref, repository, and so on)
    GH->>AWS: ③ Present the JWT and call AssumeRoleWithWebIdentity
    AWS->>AWS: ④ Verify claims against the Trust Policy
    AWS-->>GH: ⑤ Return a short-lived secret
    GH->>AWS: ⑥ Run S3 deploy and similar with the short-lived secret
StageWhoWhat happens
① JWT requestworkflow → GitHub OIDCAsk for a JWT that carries the job context (repo, branch, workflow, and so on)
② JWT receivedGitHub OIDC → workflowThe JWT (sub, ref, repository, workflow, and so on) is passed to the job
③ Role assumeworkflow → AWSSend the JWT and the IAM role ARN to assume to AWS
④ VerificationAWS IAMCompare the Trust Policy with the JWT claims. On mismatch, deny here (no secret is returned yet)
⑤ Secret issuedAWS → workflowA short-lived Access Key / Session Token is returned to the job
⑥ API executionworkflow → AWSRun the deploy and similar with the short-lived secret. It expires after the job ends

The JWT in ② (issued by GitHub) and the short-lived secret in ⑤ (issued by AWS) are different things. The JWT is identity proof. The short-lived secret is the credential used to call AWS APIs.

The difference from the long-lived-secret approach is that ① through ⑤ are inserted every time before ⑥. Instead of reading a secret from Secrets, each job takes the path JWT → short-lived secret.

Required settings

What you write, and where, splits into these three.

SettingWhereWhat it decides
Trust PolicyAWS (IAM role)Which repo / branch / environment JWTs may assume this role
workflowGitHub (.github/workflows/*.yml)Which job assumes which IAM role
Permission PolicyAWS (IAM role)After assume, what the role can do on AWS, such as S3

AWS: Trust Policy

The JSON you write on the IAM role’s “Trust relationships” tab is the Trust Policy. For a production role, you write a condition such as from the main branch only.

{
  "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"
    },
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
    }
  }
}

The sub format is repo:<owner>/<repo>:ref:refs/heads/<branch>. For a PR-preview role you use something like refs/pull/*, and you split the Trust Policy per role. If you use a GitHub Environment, the form becomes repo:my-org/my-repo:environment:production.

GitHub: workflow

After you decide the entrance with the Trust Policy, the workflow specifies the IAM role ARN to assume.

jobs:
  deploy-prod:
    if: github.ref == 'refs/heads/main'
    environment: production
    permissions:
      id-token: write   # JWT 取得に必要
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-prod
          aws-region: ap-northeast-1

  deploy-preview:
    if: github.event_name == 'pull_request'
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-preview
          aws-region: ap-northeast-1

AWS: Permission Policy

This is the policy you attach on the IAM role’s “Permissions” tab. As in Permission Policy in Terms, this is where you decide which AWS operations are allowed after assume. If you split production and preview roles, attach the Permission Policy each one needs.

Takeaways

When you deploy from GitHub Actions to the cloud, putting an Access Key in Secrets is easy, but it is weak on managing long-lived secrets, rotation, and how you split permissions.

With OIDC, you do not have to leave a long-lived secret on GitHub. You build authorization with a per-job JWT and a Trust Policy, and you call AWS APIs with a short-lived secret.


Share this post:

Previous Post
[Cursor] Subagents and background execution
Next Post
【AWS】 If you want SAA-C03, skip the mocks — do Ping-t