The Error
Error refreshing state: AccessDenied: User: arn:aws:iam::123456789:user/ci is not authorized to perform: s3:GetObject on resource: arn:aws:s3:::my-terraform-state-bucket/path/to/terraform.tfstate
status code: 403, request id: XXXXXXXXXXXXXXXX
This usually hits you during terraform plan or terraform apply in a CI/CD pipeline. Terraform tries to pull down the current state file from S3 before doing anything, and the IAM user or role running the job doesn't have permission to read it.
Why This Happens
When you configure an S3 backend, Terraform needs to read and write the state file on every run. If the IAM identity doing the run โ a CI user, an EC2 instance profile, an ECS task role โ doesn't have the right S3 permissions on that bucket and path, AWS returns a 403 and Terraform surfaces it as this error.
Common triggers:
- A new CI/CD user was created but never got an IAM policy attached for the state bucket.
- The S3 bucket policy was tightened (e.g., added an explicit deny or a condition) after Terraform was already set up.
- The backend config was moved to a different bucket or key path that the current IAM identity can't access.
- Using an IAM role that doesn't have an inline or attached policy covering the state bucket.
Step-by-Step Fix
1. Confirm the exact user and bucket
The error message tells you both. From the example above:
- IAM principal:
arn:aws:iam::123456789:user/ci - Resource: the S3 object path in the error
Double-check your backend config to make sure you know the right bucket and key:
# backend.tf
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "prod/terraform.tfstate"
region = "ap-northeast-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}
2. Create an IAM policy with the required permissions
Terraform's S3 backend needs these S3 actions at minimum:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TerraformStateAccess",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::my-terraform-state-bucket",
"arn:aws:s3:::my-terraform-state-bucket/*"
]
}
]
}
If you're using DynamoDB for state locking (you should be), add this statement too:
{
"Sid": "TerraformStateLock",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:DescribeTable"
],
"Resource": "arn:aws:dynamodb:ap-northeast-1:123456789:table/terraform-state-lock"
}
Save this as terraform-state-policy.json, then create it via CLI:
aws iam create-policy \
--policy-name TerraformStateAccess \
--policy-document file://terraform-state-policy.json
3. Attach the policy to the IAM user or role
For a CI user (ci):
aws iam attach-user-policy \
--user-name ci \
--policy-arn arn:aws:iam::123456789:policy/TerraformStateAccess
For an IAM role (EC2 instance profile, ECS task role, GitHub Actions OIDC role, etc.):
aws iam attach-role-policy \
--role-name my-ci-role \
--policy-arn arn:aws:iam::123456789:policy/TerraformStateAccess
4. Check for conflicting bucket policies or SCPs
Even with the right IAM policy, an S3 bucket policy with an explicit Deny โ or an AWS Organizations Service Control Policy (SCP) โ can override it. Check the bucket policy:
aws s3api get-bucket-policy \
--bucket my-terraform-state-bucket \
--query Policy \
--output text | python3 -m json.tool
Look for any "Effect": "Deny" statements that could be blocking your user. If you find one, either remove it or add an exception for your CI principal.
Also check if the bucket is encrypted with a customer-managed KMS key. If so, your IAM identity also needs kms:Decrypt and kms:GenerateDataKey on that key.
{
"Sid": "TerraformStateKMS",
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey"
],
"Resource": "arn:aws:kms:ap-northeast-1:123456789:key/YOUR-KEY-ID"
}
Verify the Fix
Test the S3 access directly with the AWS CLI, using the same credentials that Terraform will use:
# If using a specific profile:
aws s3 cp s3://my-terraform-state-bucket/prod/terraform.tfstate /tmp/test.tfstate \
--profile ci-user
# If testing with an assumed role:
aws sts assume-role \
--role-arn arn:aws:iam::123456789:role/my-ci-role \
--role-session-name test-session
# Then export the returned credentials and retry the s3 cp
If that succeeds, run Terraform:
terraform init
terraform plan
A successful terraform plan output (even if it shows no changes) confirms the state was read correctly. You won't see the Error refreshing state line anymore.
Quick Tips
- Scope the policy tightly. Use the full ARN with the key prefix (
arn:aws:s3:::bucket/path/*) rather than a wildcard on the entire bucket, especially if multiple teams share the same bucket with different state paths. - Use IAM roles, not long-lived users. For GitHub Actions, set up OIDC. For EC2/ECS, use instance profiles. Long-lived access keys for a
ciuser are a rotation headache and a security risk. - Use IAM Access Analyzer. If you're unsure which actions are actually needed, enable Access Analyzer on the bucket and let it generate a policy based on real access patterns.
- Policy propagation takes a few seconds. If you attached the policy and immediately retried and it still failed, wait 10โ15 seconds and try again โ IAM changes are eventually consistent.

