AWS Builder Center
Terragrunt 1.2 removed RootAccess from S3 state buckets, and your exposure did not change

Terragrunt 1.2 removed RootAccess from S3 state buckets, and your exposure did not change

Since 2019 Terragrunt added an s3:* statement for arn:aws:iam::<account>:root to every S3 state bucket it bootstrapped. Version 1.2 stops, and existing buckets keep it. The statement never meant the root user and never decided who reads state: in the same account, IAM identity policies do. How to find and strip it, why Terragrunt 1.1 in CI quietly puts it back, and the Deny allowlist that does what people assumed RootAccess did.

Series: Infrastructure-as-code (4 articles)

  1. …
  2. 3
    Terragrunt 1.2 removed RootAccess from S3 state buckets, and your exposure did not change This article
Engineer @ DevOps area

A breaking change about a bucket policy

Terragrunt v1.2.0-rc1 came out on 24 September 2026. Among the eleven experiments it closed, the breaking changes section carries one paragraph about S3 state buckets: Terragrunt no longer attaches the RootAccess statement when it bootstraps one.
That statement had been there since December 2019. Every S3 backend that Terragrunt created or updated got s3:* on the bucket and its objects for arn:aws:iam::<account-id>:root. In 1.2 the statement is gone, skip_bucket_root_access is deprecated, and enable_bucket_root_access = true brings the old behaviour back if you want it.
The line that matters for anyone running Terragrunt in production is the last one in that section: buckets that already have the statement keep it. Terragrunt does not clean up after itself here.

What the statement looked like

This is the statement, built the way EnableRootAccesstoS3Bucket in the S3 backend client builds it. Account and bucket names are placeholders:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EnforcedTLS",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::acme-tofu-state",
"arn:aws:s3:::acme-tofu-state/*"
],
"Condition": {
"Bool": { "aws:SecureTransport": "false" }
}
},
{
"Sid": "RootAccess",
"Effect": "Allow",
"Principal": {
"AWS": ["arn:aws:iam::111122223333:root"]
},
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::acme-tofu-state",
"arn:aws:s3:::acme-tofu-state/*"
]
}
]
}
EnforcedTLS stays in 1.2 and is a good default. Only RootAccess goes.

":root" is not the root user

Most engineers read arn:aws:iam::111122223333:root as the root user, the email-and-password identity that owns the account. Read that way, the statement looks protective. The IAM documentation says otherwise:
Using the account ARN in the Principal element does not limit permissions to only the root user of the account.
The account ARN and the bare account ID behave the same way. Both delegate to the account, and access then goes to whichever users and roles in that account have an identity policy allowing it.

"So removing it locks the bucket down?"

No, and this is where the release notes and the docs disagree slightly. The release notes say the statement "widened reach to state files that routinely hold secrets." The state backend docs say the account already owns the bucket, "so the statement gave the account no reach it did not have."
The docs are right. Inside one account, S3 allows a request from an IAM principal when either its identity policy or the bucket policy allows it and nothing denies it. A role with s3:GetObject on * could read your state before RootAccess existed, while it was there, and after you delete it.
IdentityReads state?Why
AdministratorAccessyes* on *
PowerUserAccessyeseverything except IAM, Organizations, Account
AmazonS3FullAccessyess3:* on *
CI role with s3:* on *yeswhatever made the pipeline green
Any of these, after RootAccess is removedstill yesthe bucket policy was never the gate
What the statement did, for almost seven years, was make the bucket policy look like it was the thing protecting state.

The issue that will bite you: 1.1 writes it back

Before 1.2, Terragrunt did not add RootAccess only at creation. On every bootstrap it checked whether the statement was present and flagged the bucket for an update when it was missing. From internal/remotestate/backend/s3/client.go at v1.1.5:
1
2
3
4
5
6
7
8
9
10
11
12
if !client.SkipBucketRootAccess {
enabled, err := client.checkIfBucketRootAccess(ctx, l, bucketName)
if err != nil {
return false, toUpdate, err
}

if !enabled {
toUpdate.RootAccess = true

updates = append(updates, "Bucket Root Access")
}
}
The update then asks for confirmation:
1
Remote state S3 bucket acme-tofu-state is out of date. Would you like Terragrunt to update it? (y/n)
CI runs with --non-interactive, and the prompt code answers for you:
1
2
3
4
5
6
if nonInteractive {
l.Debugf("%s", prompt)
l.Debugf("The non-interactive flag is set to true, so assuming 'yes' for all prompts")

return "yes", nil
}
Put together: strip the statement while one pipeline anywhere still runs Terragrunt 1.1 with --backend-bootstrap, and that pipeline's next run adds it back at debug log level, where nobody reads.
Warning
Upgrade every runner and laptop that bootstraps the backend to 1.2 before you clean up, or set skip_bucket_root_access = true in root.hcl wherever you cannot upgrade yet.

Finding and stripping it

The Terragrunt docs give the removal recipe:
1
2
3
4
aws s3api get-bucket-policy --bucket acme-tofu-state --query Policy --output text \
| jq '.Statement |= map(select(.Sid != "RootAccess"))' > policy.json

aws s3api put-bucket-policy --bucket acme-tofu-state --policy file://policy.json
I ran the two jq filters locally against the policy above, saved as policy.json, before pointing them at anything real. The detection filter:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ jq '.Statement[] | select(.Sid == "RootAccess")' policy.json
{
"Sid": "RootAccess",
"Effect": "Allow",
"Principal": {
"AWS": [
"arn:aws:iam::111122223333:root"
]
},
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::acme-tofu-state",
"arn:aws:s3:::acme-tofu-state/*"
]
}
And the removal filter, checking what survives:
1
2
3
$ jq '.Statement |= map(select(.Sid != "RootAccess"))' policy.json > clean.json
$ jq -c '.Statement[] | {Sid, Effect}' clean.json
{"Sid":"EnforcedTLS","Effect":"Deny"}
If the bucket was bootstrapped with skip_bucket_enforced_tls = true, RootAccess can be the only statement, and the filter leaves an empty list that S3 will not accept as a policy. Delete the policy instead:
1
aws s3api delete-bucket-policy --bucket acme-tofu-state
To sweep a whole account:
1
2
3
4
5
for b in $(aws s3api list-buckets --query 'Buckets[].Name' --output text); do
aws s3api get-bucket-policy --bucket "$b" --query Policy --output text 2>/dev/null \
| jq -e '.Statement[] | select(.Sid == "RootAccess")' > /dev/null \
&& echo "$b"
done
And to stop anyone reintroducing the deprecated option, the skip-bucket-root-access strict control turns its warning into an error:
1
terragrunt run --all --strict-control skip-bucket-root-access -- plan

The lock people assumed they had

If the intent was "only these identities read state," the tool is an explicit Deny with a principal allowlist. AWS documents this as the replacement for NotPrincipal:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"Sid": "StateReadersOnly",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::acme-tofu-state",
"arn:aws:s3:::acme-tofu-state/*"
],
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": [
"arn:aws:iam::111122223333:role/terragrunt-ci",
"arn:aws:iam::111122223333:role/terragrunt-plan-readonly",
"arn:aws:iam::111122223333:role/break-glass"
]
}
}
}
An explicit deny wins over any identity policy, so AdministratorAccess on an unrelated role no longer opens state.

Honest trade-offs

The allowlist can lock you out. The same deny applies to whoever tries to repair a mistake in it. Keep a break-glass role on the list and roll it out on a non-production state bucket first, because every principal you forgot about stops working the moment it applies.
It is one more thing to maintain. Every new pipeline role has to be added to the bucket policy as well as to IAM. For a small team with one CI role that is trivial; for an estate with a role per repository it is a real cost.
Removing RootAccess alone buys you nothing measurable. It is hygiene. The bucket policy stops implying protection it never gave, and policy scanners stop flagging an s3:* allow. Exposure changes only when you touch IAM or add the deny.
State hygiene has two halves. Keeping secrets out of state (encrypted stores at apply time, write-only arguments where the provider supports them) is one. Who can open the file is the other. Write-only arguments do not reach generated passwords, credentials a provider returns as attributes, or resources written before they existed, so the second half still matters.

Summary

Terragrunt 1.2 removed a statement that looked like security and was not. :root in a Principal never meant the root user; it meant "ask IAM," and IAM was always the gate. Upgrade every bootstrapping pipeline first so 1.1 cannot write the statement back, strip it with the docs recipe, and if the list of roles that can read state is longer than it should be, add a Deny with an aws:PrincipalArn allowlist.

Series: Infrastructure-as-code (4 articles)

  1. …
  2. 3
    Terragrunt 1.2 removed RootAccess from S3 state buckets, and your exposure did not change This article
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article