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:#fffHow 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:#fffService 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:#2e7d32Multi-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_rolein 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.