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
| Viewpoint | What tends to happen |
|---|---|
| Lifetime | Lasts until you invalidate or rotate it |
| Rotation | You have to keep AWS, Secrets, and other environments in sync by hand |
| Permission design | One secret often gets reused across production, PRs, and multiple repositories |
| Audit | CloudTrail 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

| Viewpoint | Long-lived secret (Secrets) | OIDC |
|---|---|---|
| What you put on GitHub | Access Key (long-lived) | No AWS secret needed (role ARN only) |
| Lifetime | Lasts until you invalidate it | Expires automatically per job |
| Rotation | Manual (AWS + Secrets + others) | Short-lived secrets are issued automatically |
| How you split permissions | Per IAM user | Branch on repository / ref / environment |
| Audit logs | Recorded as the IAM user | Role 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 approach | What changes with OIDC |
|---|---|
| Risk of a long-lived secret | You do not put an Access Key in Secrets |
| Rotation work | Short-lived secrets are issued and expire automatically per job |
| Coarse permission design | The Trust Policy can condition on repo / ref / environment |
| Audit linkage | CloudTrail 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
mainbranch ofmy-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
mainofmy-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.
| Where | What you do |
|---|---|
| AWS | Register GitHub’s OIDC provider (token.actions.githubusercontent.com) |
| AWS | Create a deploy IAM role, and write in the Trust Policy “which repo / branch JWTs are OK” |
| AWS | Attach a Permission Policy to the same role, and decide what it can do after assume (S3 writes and so on) |
| GitHub | Add permissions: id-token: write to the workflow so it can get a JWT |
| GitHub | Specify 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
| Stage | Who | What happens |
|---|---|---|
| ① JWT request | workflow → GitHub OIDC | Ask for a JWT that carries the job context (repo, branch, workflow, and so on) |
| ② JWT received | GitHub OIDC → workflow | The JWT (sub, ref, repository, workflow, and so on) is passed to the job |
| ③ Role assume | workflow → AWS | Send the JWT and the IAM role ARN to assume to AWS |
| ④ Verification | AWS IAM | Compare the Trust Policy with the JWT claims. On mismatch, deny here (no secret is returned yet) |
| ⑤ Secret issued | AWS → workflow | A short-lived Access Key / Session Token is returned to the job |
| ⑥ API execution | workflow → AWS | Run 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.
| Setting | Where | What it decides |
|---|---|---|
| Trust Policy | AWS (IAM role) | Which repo / branch / environment JWTs may assume this role |
| workflow | GitHub (.github/workflows/*.yml) | Which job assumes which IAM role |
| Permission Policy | AWS (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.