Provider Authentication #

Terraform providers — AWS, GCP, Azure, and others — need credentials to create, read, and delete resources. How these credentials are obtained and managed is one of the most fundamental security decisions in a Terraform setup. The wrong choice — for example, hardcoding credentials in a configuration file or using credentials with excessive permissions — can open a gap whose impact goes far beyond Terraform itself.

flowchart TD
    A["Env Variables"] --> E["AWS Provider"]
    B["AWS Profile"] --> E
    C["Instance\nProfile (IAM)"] --> E
    D["OIDC\n(GitHub Actions)"] --> E

    style A fill:#3b82f6,stroke:#1e40af,color:#fff
    style B fill:#8b5cf6,stroke:#6d28d9,color:#fff
    style C fill:#10b981,stroke:#059669,color:#fff
    style D fill:#f59e0b,stroke:#d97706,color:#fff
    style E fill:#ef4444,stroke:#dc2626,color:#fff

How Providers Get Credentials (AWS) #

The Terraform AWS provider follows the same credential search order as the AWS SDK in general.

AWS PROVIDER CREDENTIAL SEARCH ORDER:

  1. Explicit configuration in the provider block (AVOID)
     provider "aws" { access_key = "...", secret_key = "..." }

  2. Environment variables
     AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY

  3. AWS credentials file
     ~/.aws/credentials (for local developers)

  4. AWS config file
     ~/.aws/config (can store profiles and assume roles)

  5. Container credentials (ECS task role)
     Automatically available when running on ECS

  6. Instance profile (EC2 instance role)
     Automatically available when running on EC2

  7. OIDC / Web Identity Token
     For CI/CD (GitHub Actions, GitLab CI, etc.)

Terraform uses the first credentials found in this order.

Static Credentials: The Wrong Way #

# ANTI-PATTERN: Hardcoding credentials in the provider — NEVER
provider "aws" {
  region     = "ap-southeast-1"
  access_key = "«redacted:AKIA…»"     # ✗ Will end up in Git history
  secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"  # ✗
}

# ANTI-PATTERN: Credentials in .tfvars (still dangerous)
# terraform.tfvars:
# aws_access_key = "«redacted:AKIA…»"
# → This file might not be in .gitignore and could get committed
# CORRECT: A provider without explicit credentials
# Terraform will automatically look for credentials in the environment
provider "aws" {
  region = "ap-southeast-1"
  # No access_key or secret_key here
  # Credentials come from the environment or an instance profile
}

AWS Profiles for Local Development #

For developers working locally, the right way is using AWS profiles configured in ~/.aws/.

# Set up AWS SSO (modern, safer than static credentials)
aws configure sso
# Follow the prompts: SSO start URL, region, account, role name

# Log in via SSO (needs to run every session or when the token expires)
aws sso login --profile my-dev-profile

# Use a specific profile with terraform
AWS_PROFILE=my-dev-profile terraform plan

# Or configure it in the shell for this session
export AWS_PROFILE=my-dev-profile
terraform plan
# ~/.aws/config — multi-account configuration example with SSO
[profile dev]
sso_start_url = https://mycompany.awsapps.com/start
sso_region = ap-southeast-1
sso_account_id = 111111111
sso_role_name = DeveloperRole
region = ap-southeast-1

[profile production]
sso_start_url = https://mycompany.awsapps.com/start
sso_region = ap-southeast-1
sso_account_id = 333333333
sso_role_name = ReadOnlyRole  # Developers are read-only in production
region = ap-southeast-1

Assume Role for Multi-Account #

When a Terraform configuration runs from one account (management/CI account) but needs to create resources in another account (production), use assume_role in the provider.

# Configuration for multi-account deployment
# CI/CD runs with management account credentials
# then assumes a role into the target account

provider "aws" {
  region = "ap-southeast-1"

  assume_role {
    role_arn     = "arn:aws:iam::${var.target_account_id}:role/TerraformDeployRole"
    session_name = "terraform-${var.environment}"
    # external_id  = var.external_id  # Add for extra security
  }
}

# The IAM role in the target account (TerraformDeployRole) needs a trust policy:
# {
#   "Effect": "Allow",
#   "Principal": {
#     "AWS": "arn:aws:iam::MANAGEMENT_ACCOUNT_ID:role/TerraformCIRole"
#   },
#   "Action": "sts:AssumeRole"
# }

OIDC for CI/CD: The Most Secure Way #

OIDC (OpenID Connect) lets CI/CD platforms get temporary AWS credentials without storing permanent access keys in secret storage.

HOW OIDC WORKS:

sequenceDiagram
    autonumber
    participant GH as "GitHub Actions"
    participant AWS as "AWS"

    Note over GH: 1. Request an OIDC token from GitHub
    GH->>AWS: OIDC JWT
    Note over AWS: 2. Verify the JWT with the GitHub OIDC provider
    Note over AWS: 3. Create temporary credentials (STS)
    AWS-->>GH: Temp Credentials
    Note over GH: 4. Use the temp credentials for Terraform
  • Temporary credentials expire automatically (usually after 1 hour)
  • No static keys that need rotation or can leak
# GitHub Actions with OIDC
# .github/workflows/terraform.yml

permissions:
  id-token: write   # Permission to request an OIDC token
  contents: read

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - name: Configure AWS Credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/GitHubActionsRole
          aws-region: ap-southeast-1
          # No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY needed

      - name: Terraform Plan
        run: terraform plan
// IAM Role Trust Policy for GitHub OIDC
// Only allows specific repositories and branches
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789: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:myorg/infra-repo:ref:refs/heads/main"
          // Only the main branch can assume this role
        }
      }
    }
  ]
}

Different Credentials for Plan and Apply #

Giving the same permissions to plan and apply is a waste of privilege. Plan only needs read access; apply needs write.

# CI pipeline: two jobs with different credentials

# Job 1: Plan — needs read-only access
# Role: TerraformPlanRole (read-only policy)
# assume_role { role_arn = "...TerraformPlanRole" }

# Job 2: Apply — needs write access, only on the main branch
# Role: TerraformApplyRole (full access, but restricted by trust policy)
# The trust policy only allows the main branch
CREDENTIALS MATRIX:

  Local developer   → AWS SSO profile, read-only in production
  CI Plan job       → OIDC, TerraformPlanRole (ReadOnlyAccess)
  CI Apply job      → OIDC, TerraformApplyRole (PowerUserAccess)
                       Trust policy: main branch only
  Atlantis server   → Instance profile, TerraformApplyRole

flowchart LR
    A["Local Dev\nAWS SSO + Profile"] --> B["Terraform"]
    C["CI/CD\nOIDC"] --> B
    D["Emergency\nManual"] --> B

    style A fill:#3b82f6,stroke:#1e40af,color:#fff
    style C fill:#10b981,stroke:#059669,color:#fff
    style D fill:#f59e0b,stroke:#d97706,color:#fff
    style B fill:#8b5cf6,stroke:#6d28d9,color:#fff

Service Account Authentication Patterns #

# AWS: Instance Profile (recommended for EC2/runners)
# No credential file needed — the IAM role is automatically available
# Only applies to EC2, ECS, Lambda

# AWS: OIDC Provider (recommended for CI/CD)
# GitHub Actions, GitLab CI can get temporary credentials
# without storing long-lived access keys
# GitHub Actions OIDC with AWS
permissions:
  id-token: write
  contents: read

steps:
  - name: Configure AWS Credentials
    uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789:role/GitHubActionsRole
      aws-region: ap-southeast-1

  - name: Terraform Apply
    run: terraform apply -auto-approve
flowchart LR
    A["GitHub Actions"] -->|"OIDC token"| B["AWS STS\nAssumeRole"]
    B -->|"Temp credentials"| C["Terraform\napply"]

    style A fill:#e3f2fd,stroke:#1565c0
    style B fill:#fff3e0,stroke:#e65100
    style C fill:#e8f5e9,stroke:#2e7d32

Multi-Environment Authentication #

# Pattern: Role assumption per environment
locals {
  roles = {
    dev        = "arn:aws:iam::111111111111:role/TerraformRole"
    staging    = "arn:aws:iam::222222222222:role/TerraformRole"
    production = "arn:aws:iam::333333333333:role/TerraformRole"
  }
}

provider "aws" {
  region = "ap-southeast-1"
  
  assume_role {
    role_arn = local.roles[terraform.workspace]
  }
  
  default_tags {
    tags = {
      Environment = terraform.workspace
      ManagedBy   = "terraform"
    }
  }
}

Token-Based Authentication #

# Terraform Cloud token
terraform {
  cloud {
    organization = "my-org"
    workspaces {
      name = "my-app"
    }
  }
  # Token stored in ~/.terraformrc or the TF_TOKEN_app_terraform_io env var
}

# GitHub token for the GitHub provider
provider "github" {
  token = var.github_token  # or the GITHUB_TOKEN env var
  owner = "my-org"
}

# Cloudflare API token
provider "cloudflare" {
  api_token = var.cloudflare_api_token  # or the CLOUDFLARE_API_TOKEN env var
}
# BEST PRACTICE: Store tokens in a credential helper
# ~/.terraformrc
# credentials "app.terraform.io" {
#   token = "xxxxx.atlasv1.xxxxx"
# }

# Or use environment variables (safer for CI/CD)
export TF_TOKEN_app_terraform_io="xxxxx.atlasv1.xxxxx"

OIDC Authentication #

# GitHub Actions: OIDC authentication (without static credentials)
name: Terraform
on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
      - 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
          aws-region: ap-southeast-1
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
      
      - name: Terraform Apply
        run: |
          terraform init
          terraform apply -auto-approve          
# IAM role for GitHub OIDC
resource "aws_iam_role" "github_actions" {
  name = "github-actions-terraform"
  
  assume_role_policy = jsonencode({
    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-org/*:ref:refs/heads/main"
        }
      }
    }]
  })
}

Summary #

  • Never hardcode credentials in the Terraform configuration — an access key that enters Git history is a serious security incident.
  • For local development, use AWS SSO with profiles — safer than static credentials and credentials can be rotated centrally.
  • assume_role in the provider is the right way for multi-account deployments — CI/CD runs in one account, assumes a role into the target account.
  • OIDC is the gold standard for CI/CD — no static credentials to store or rotate, credentials expire automatically.
  • Separate plan and apply credentials — plan only needs read, apply needs write. The least privilege principle applies here.
  • Strict trust policies for the apply role — restrict who can assume the apply role (main branch only, specific repositories only) to prevent abuse.

← Previous: Terraform Cloud   Next: Environment & Secret →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact