DEV Community

Cover image for How I eliminated access keys from my deployment pipeline with OIDC, Terraform, and GitHub Actions
Victor Ojeje
Victor Ojeje

Posted on

How I eliminated access keys from my deployment pipeline with OIDC, Terraform, and GitHub Actions

The Problem with Traditional CI/CD

Most tutorials for deploying static sites to AWS tell you to:

  1. Generate AWS access keys
  2. Store them as GitHub secrets
  3. Hope nobody compromises your repository

This approach has serious security implications. Long-lived credentials in CI/CD pipelines are a common attack vector since if your repository is compromised or secrets are accidentally exposed, attackers gain full AWS access with those credentials.

There's a better way: OpenID Connect (OIDC) federation.

What I Built

A fully automated static website deployment pipeline that eliminates access keys entirely by using temporary credentials exchanged through OIDC.

Live Demo: https://d2jgqhup9totr6.cloudfront.net

Source Code: GitHub Repository

Architecture Overview

Developer commits → GitHub Actions → OIDC token exchange 
→ Temporary AWS credentials → Deploy to S3 → CloudFront invalidation
Enter fullscreen mode Exit fullscreen mode

Key Components:

  • S3 for static website hosting
  • CloudFront for global CDN
  • Terraform for infrastructure as code
  • GitHub Actions for CI/CD
  • OIDC for secure authentication

Why OIDC Over Access Keys?

Traditional Approach (Access Keys):

# Potential security risk

- name: Configure AWS Credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Enter fullscreen mode Exit fullscreen mode

Problems:

  • Keys are long-lived (valid until manually rotated)
  • If leaked, provide persistent AWS access
  • Require manual rotation and management
  • Stored as static secrets in GitHub

OIDC Approach:

# This is secure

- name: Configure AWS Credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
    aws-region: ${{ secrets.AWS_REGION }}
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Credentials expire in 1 hour (temporary)
  • No static secrets to leak
  • Zero credential rotation needed
  • AWS manages the trust relationship

Implementation Deep Dive

1. Setting Up the OIDC Provider in Terraform

First, create an OIDC identity provider that trusts GitHub:

resource "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"

  client_id_list = ["sts.amazonaws.com"]

  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
Enter fullscreen mode Exit fullscreen mode

The thumbprint verifies GitHub's signing certificate authenticity.

2. Creating the IAM Role with Trust Policy

For this to work, we define exactly which repositories can assume this role:

resource "aws_iam_role" "github_actions" {
  name = "github-actions-s3-deployment"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Federated = aws_iam_openid_connect_provider.github.arn
      }
      Action = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = {
          "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
        }
        StringLike = {
          "token.actions.githubusercontent.com:sub" = "repo:${var.github_username}/${var.repo_name}:ref:refs/heads/main"
        }
      }
    }]
  })
}
Enter fullscreen mode Exit fullscreen mode

Critical security feature: The StringLike condition restricts access to:

  • Your specific GitHub username
  • Your specific repository
  • Only the main branch

Even if someone forks your repo, they can't assume this role.

3. Least-Privilege IAM Permissions

The role only gets permissions it absolutely needs:

resource "aws_iam_role_policy" "github_actions" {
  role = aws_iam_role.github_actions.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:PutObject",
          "s3:GetObject",
          "s3:DeleteObject",
          "s3:ListBucket"
        ]
        Resource = [
          aws_s3_bucket.website_storage.arn,
          "${aws_s3_bucket.website_storage.arn}/*"
        ]
      },
      {
        Effect = "Allow"
        Action = "cloudfront:CreateInvalidation"
        Resource = aws_cloudfront_distribution.website_storage.arn
      }
    ]
  })
}
Enter fullscreen mode Exit fullscreen mode

No overly broad permissions.

4. CloudFront Configuration Challenge

Here's something that tripped me up: S3 website endpoints require custom origin configuration, not standard S3 origin configuration.

# This doesn't work for S3 website endpoints

origin {
  domain_name = aws_s3_bucket.website_storage.bucket_regional_domain_name
  origin_id   = "S3-Website"

  s3_origin_config {
    origin_access_identity = aws_cloudfront_origin_access_identity.default.cloudfront_access_identity_path
  }
}

# This works for the s3 website endpoints

origin {
  domain_name = aws_s3_bucket.website_storage.website_endpoint
  origin_id   = "S3-Website"

  custom_origin_config {
    http_port              = 80
    https_port             = 443
    origin_protocol_policy = "http-only"
    origin_ssl_protocols   = ["TLSv1.2"]
  }
}
Enter fullscreen mode Exit fullscreen mode

Why? S3 website endpoints function as HTTP servers, supporting redirects and error documents. Standard S3 origins access the S3 API directly and don't support these features.

5. Remote State Management

For production infrastructure, always use remote state with locking:

terraform {
  backend "s3" {
    bucket         = "escanut-tf-state"
    key            = "s3-website/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "tf-state-lock"
    encrypt        = true
  }
}
Enter fullscreen mode Exit fullscreen mode

This will enable:

  • Team collaboration without conflicts
  • State encryption at rest
  • Prevention of concurrent modifications

The GitHub Actions Workflow

The deployment workflow is remarkably simple:

name: Deploy to S3

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # Required for OIDC
      contents: read

    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ secrets.AWS_REGION }}

      - name: Sync to S3
        run: |
          aws s3 sync ./build s3://${{ secrets.S3_BUCKET }} --delete

      - name: Invalidate CloudFront
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CLOUDFRONT_ID }} \
            --paths "/*"

      - name: Deployment Overview
        run: |
          echo "Website deployment successful" 
          echo "https://${{ secrets.CLOUDFRONT_URL }}"
Enter fullscreen mode Exit fullscreen mode

Deployment time: Approximately 30 seconds from commit to live site (measured from workflow logs).

Cost Analysis

One of the best parts? This infrastructure costs almost nothing to run.

Monthly costs (based on January 2026 AWS pricing):

Service Usage Cost
S3 Storage <1GB $0.02
S3 Requests 10k/month $0.05
CloudFront 10GB transfer $0.85
Total ~$0.92/month

For comparison, a single t3.micro EC2 instance costs $8-15/month and requires active management.
.

AWS's eventually consistent nature means public access settings must be applied before the bucket policy.

Security Wins

This architecture eliminates several attack vectors:

No long-lived credentials - temporary credentials expire in 1 hour

Repository-scoped access - only your specific repo can deploy

Branch-specific trust - only main branch can trigger deployments

Least-privilege permissions - role can only write to specific S3 bucket

Encrypted state - Terraform state encrypted at rest in S3

No credential rotation - AWS handles credential lifecycle automatically

The current implementation shows:

  • Infrastructure as Code proficiency
  • Cloud security best practices
  • CI/CD automation
  • Cost optimization awareness

Conclusion

OIDC federation with GitHub Actions is the modern standard for CI/CD authentication to AWS. It's more secure, requires zero maintenance, and is easier to implement than managing long-lived access keys.

If you're still using AWS access keys in your deployment pipelines, it's time to upgrade.

Try it yourself: Fork the repository, update the variables, and deploy your own infrastructure in under 5 minutes.


Drop a comment below or reach out on LinkedIn.

If this was helpful, consider giving the GitHub repo a ⭐!

Top comments (0)