Back to Blog

Beyond Static Secrets: Hardening Your GitHub Actions with OIDC Identity Federation

Beyond Static Secrets: Hardening Your GitHub Actions with OIDC Identity Federation

Storing an AWS ACCESS_KEY_ID and SECRET_ACCESS_KEY in your GitHub repository secrets feels like burying a landmine in your backyard and hoping you remember where you put it. If those keys leak, they don't expire. They just sit there, providing a permanent backdoor into your infrastructure until someone notices and manually rotates them. OpenID Connect (OIDC) changes the game by replacing these static, long-lived credentials with short-lived, verifiable identities.

The Handshake: How it actually works

Think of OIDC as a temporary badge system. Instead of giving GitHub a permanent key to your house, you tell your cloud provider (AWS, Azure, or GCP), "I trust GitHub. If a workflow from my-org/my-repo shows up with a signed token saying they are who they say they are, give them a temporary pass for 15 minutes."

The flow looks like this:
1. The Request: Your GitHub Action workflow requests a JWT (JSON Web Token) from GitHub’s OIDC provider.
2. The Proof: GitHub generates a cryptographically signed token containing details about the repo, the branch, and the environment.
3. The Exchange: The workflow sends this token to your cloud provider.
4. The Validation: The cloud provider checks the signature against GitHub's public keys and verifies that the "claims" (like the repo name) match the trust policy you configured.
5. The Access: If everything checks out, the cloud provider returns a short-lived access token.

Setting up the Trust (The AWS Example)

I’ve found that the trickiest part isn't the GitHub side—it's getting the Cloud side to be specific enough. You don't want *any* GitHub repository to be able to assume your roles; you only want *your* repository to do it.

In AWS, this starts with creating an Identity Provider for token.actions.githubusercontent.com. But the real magic happens in the Trust Policy of the IAM Role you want the runner to assume.

Here is what a secure trust policy looks like:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "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-username/my-awesome-app:ref:refs/heads/main"
        }
      }
    }
  ]
}

Pay attention to that `sub` (subject) claim. I see people use a wildcard like repo:my-username/* often. That's fine for testing, but in production, you should lock it down to the specific repository and, ideally, the specific branch. This prevents a "confused deputy" attack where another repo in your org might try to hijack the role.

The GitHub Actions Workflow

Once the cloud side is ready, the YAML side is surprisingly clean. There is one non-negotiable step: you must grant the workflow permission to write the ID token. If you forget this, the aws-actions/configure-aws-credentials step will fail with a cryptic error because it can't fetch the JWT from the local environment.

name: Deploy to Production
on:
  push:
    branches: [ main ]

permissions:
  id-token: write # This is required for requesting the JWT
  contents: read  # This is required for actions/checkout

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy-role
          aws-region: us-east-1

      - name: S3 Sync
        run: aws s3 sync ./dist s3://my-production-bucket

Notice what's missing? No ${{ secrets.AWS_ACCESS_KEY_ID }}. No manual rotation schedules. If the job finishes or crashes, the credentials simply evaporate.

Why this is a massive win

I used to spend a non-trivial amount of time every quarter rotating keys or, worse, tracking down which keys belonged to a developer who left the company. With OIDC, that mental debt is gone.

Beyond just the security of "no secrets," you get better audit logs. When you look at your CloudTrail or GCP Audit Logs, you don't just see "AccessKeyXYZ used." You see exactly which GitHub repository and which specific workflow run triggered the action.

A Few Gotchas

- The Audience (`aud`): On AWS, this must be sts.amazonaws.com. If you're using a different action or provider, double-check what they expect.
- Clock Skew: OIDC tokens are very short-lived. If GitHub's runners or your cloud provider's API have a significant time desync (rare, but it happens), the handshake will fail.
- Reusable Workflows: If you use reusable workflows, the sub claim might change to reflect the path of the *called* workflow or the *caller*. Always check the GitHub OIDC documentation to see how the claims are structured for your specific nesting.

Stop copy-pasting hex strings into your repository settings. It’s 2024; let identity federation do the heavy lifting for you.