Terraform calls AWS APIs. AWS SCPs evaluate those API calls. When an SCP denies an API call that Terraform makes, the operation fails and Terraform surfaces an error. This is expected behavior. But the interaction between Terraform's provider, the IAM role Terraform assumes, and the SCP evaluation chain produces some non-obvious results that trip up even experienced teams.
This post focuses specifically on the places where the interaction is surprising: how Terraform's multi-call resource creation maps to SCP evaluation, how condition keys behave differently than expected, and what the error output actually tells you.
Terraform Is Not a Special Principal to AWS
The AWS provider in Terraform makes API calls as the IAM principal configured in the provider block — usually a role assumed via assume_role. From AWS's perspective, there's nothing special about a call coming from Terraform vs. a call made directly through the AWS CLI or SDK. The SCP evaluates the action, the resource ARN, and the condition context of the call — not the tool that made it.
This means: if your SCP denies s3:PutPublicAccessBlock for principals not matching a specific condition, and Terraform's role doesn't satisfy that condition, Terraform fails. That's the SCP working correctly.
The complication arises from one specific Terraform behavior: a single resource block in Terraform often maps to multiple sequential API calls. Creating an S3 bucket isn't a single API call — it's at minimum CreateBucket, then depending on the configuration: PutBucketEncryption, PutPublicAccessBlock, PutBucketVersioning, PutBucketTagging, and potentially others. Each of those is an independent API call evaluated independently by the SCP.
Consider this Terraform resource:
resource "aws_s3_bucket" "data" {
bucket = "my-company-data"
tags = {
Environment = "production"
}
}
resource "aws_s3_bucket_public_access_block" "data" {
bucket = aws_s3_bucket.data.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
The aws_s3_bucket resource calls CreateBucket. The aws_s3_bucket_public_access_block resource calls PutPublicAccessBlock. If an SCP denies s3:CreateBucket, the first apply fails before the bucket is created. If the SCP denies s3:PutPublicAccessBlock, the bucket gets created but the public access block configuration fails — leaving you with a bucket that exists but doesn't have the security configuration you intended, and Terraform in an error state.
The SCP Condition Timing Problem
SCP conditions evaluate against the API request context at the moment the call is made. For resource-level conditions like s3:prefix or aws:ResourceTag, the condition fires against the actual resource state or the tags in the request — which may differ from what you expect if Terraform sends tag information in a separate call.
A concrete scenario: your SCP requires all S3 bucket creation to include a CostCenter tag. The SCP looks like:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RequireCostCenterTag",
"Effect": "Deny",
"Action": "s3:CreateBucket",
"Resource": "*",
"Condition": {
"Null": {
"aws:RequestTag/CostCenter": "true"
}
}
}
]
}
This evaluates the CostCenter tag in the CreateBucket request context. If the tag is absent from the request, the bucket creation is denied.
Now look at how Terraform handles tags. If you use the default_tags block on the AWS provider:
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
CostCenter = "eng-platform"
}
}
}
In current versions of the AWS Terraform provider, default_tags are merged into the resource tags before the API call. The CreateBucket call includes the CostCenter tag from default_tags. So the SCP condition is satisfied. This is the expected behavior.
But if you're on an older version of the provider, or if the resource type doesn't fully propagate default_tags to all sub-calls, you may see inconsistent behavior. The provider changelog tracks which resources support default_tags propagation — always worth verifying for the specific resource types your SCP conditions target.
SCP Denials in Terraform Error Output
When an SCP denies a Terraform API call, the AWS provider returns a ClientError with code AccessDenied. Terraform surfaces this as an error message like:
Error: creating S3 Bucket (my-company-data): AccessDenied: Access Denied
status code: 403, request id: XXXX, host id: YYYY
This is not particularly informative about which SCP statement caused the denial or what condition was not satisfied. The AWS CloudTrail log for the event will tell you more — the errorCode will be AccessDenied and the errorMessage will include the SCP ARN that caused the denial. But you need to know to look there.
If you're debugging an unexpected SCP denial from Terraform, the CloudTrail query to run is:
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=CreateBucket \
--start-time "2025-02-10T00:00:00Z" \
--end-time "2025-02-11T23:59:59Z" \
--query 'Events[?contains(CloudTrailEvent, `AccessDenied`)].CloudTrailEvent' \
--output text | python3 -m json.tool
The serviceEventDetails section of the event will include the policy evaluation context. For SCP denials, you'll see SCPContext with the policy ARN. This is the fastest path to understanding which SCP statement fired.
The assume_role Chain and SCP Scope
Terraform commonly uses role chaining: a base role (often an OIDC role for GitHub Actions or a service account role) assumes a deployment role in the target account. The SCP evaluation applies to the final assumed role — the one that actually makes the API call — and to the account it's calling from.
This matters when you have SCPs that use aws:PrincipalArn or aws:PrincipalTag conditions. If your SCP says:
{
"Condition": {
"StringNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/terraform-deploy-*"
}
}
}
This matches against the final assumed role ARN, not the originating principal. A Terraform deployment that chains through two roles only needs the final role to match the pattern. The intermediate assume_role call (from base role to deployment role) is not evaluated against the SCP of the target account — it's evaluated against the account where the base role lives.
The implication: if you're using SCPs to control which principals can make infrastructure changes in a member account, the aws:PrincipalArn condition needs to match the final deployed role identity, and your role naming conventions need to be consistent enough to write a reliable pattern.
Terraform State Operations and SCPs
Terraform state operations — reading and writing to an S3 backend — are API calls subject to SCP evaluation like any other. If you store Terraform state in a centralized S3 bucket in a separate security/logging account and run Terraform in member accounts, the GetObject and PutObject calls to the state bucket are made by the Terraform role in the member account, cross-account to the state bucket.
Cross-account S3 access is evaluated by the SCP of the account where the calling principal lives (the member account) and by the resource policy of the bucket. If your member account SCPs have a general-purpose region restriction that doesn't account for the cross-account S3 calls to the centralized state bucket region, those calls will be denied and Terraform won't be able to read or write state.
The fix is either to add the state bucket's region to the allowed regions in your SCP, or to use a condition that excludes cross-account calls to the state bucket ARN. The second approach is cleaner but requires maintaining the state bucket ARN in your SCP conditions — make that a variable in your SCP template, not a hard-coded string.
Planning with SCPs: What terraform plan Shows You
One of the persistent friction points in teams that enforce SCPs is that terraform plan doesn't validate against SCPs. Plan calls the GetCallerIdentity STS API and reads existing resource state, but it doesn't simulate the API calls that terraform apply will make. You can have a plan that looks clean and an apply that fails immediately on SCP denial.
We're not saying SCPs should be designed to not conflict with Terraform. We're saying that the absence of SCP evaluation in terraform plan is a known gap that teams often discover by watching an apply fail in a CI pipeline after plan succeeded. The correct response is to understand which API calls your Terraform changes will make and verify the SCP allows those calls for the deployment role — before running the pipeline.
Tools like iamlive (which instruments the AWS SDK to log API calls made during a dry run) can help identify the full set of API calls a Terraform configuration makes. Running iamlive alongside a terraform plan in a sandbox account gives you the call list to verify against your SCPs. It's an extra step in the local development workflow, but cheaper than repeated CI failures.
Writing SCPs That Are Terraform-Aware
If you own the SCP side of this relationship, there are some practices that reduce friction for teams using Terraform without weakening your controls:
Tag conditions should target the resource's final state, not just the create call. Use tag-on-create semantics where supported, and verify that the resources and services you're targeting actually propagate tags in the create request body.
Role-based exceptions should use consistent naming patterns that match your Terraform role naming convention. Wildcards in aws:PrincipalArn conditions are legitimate when the naming convention is enforced — they're not a security weakness if you control what gets that role name.
Consider whether a denial that blocks Terraform state operations is worse than the alternative. SCPs that break infrastructure deployment tooling create pressure to either weaken the SCP or work around it — neither is a good outcome. Design SCPs to control dangerous actions while being explicit about the tooling exceptions they accommodate.
The Terraform-SCP interaction is deterministic. When a plan succeeds and an apply fails at the SCP layer, there's always a specific reason: a specific API call, a specific condition that evaluated to false, a specific role that didn't match an expected pattern. Finding that reason is a debugging task with a clear path. Building the operational muscle to debug it quickly is more valuable than trying to design SCPs that never conflict with Terraform — some conflict is appropriate, and knowing how to resolve it efficiently is the goal.